feat(peers): settings dialog + hide for revoked peers, pending badge in header
This commit is contained in:
@@ -18,13 +18,14 @@
|
|||||||
import { useMemo, useState } from 'preact/hooks'
|
import { useMemo, useState } from 'preact/hooks'
|
||||||
import { useContainerStore } from '@lib/hooks/useContainerStore'
|
import { useContainerStore } from '@lib/hooks/useContainerStore'
|
||||||
import { useMasterRegistry } from '@lib/hooks/useMasterRegistry'
|
import { useMasterRegistry } from '@lib/hooks/useMasterRegistry'
|
||||||
|
import { useTrustStore } from '@lib/hooks/useTrustStore'
|
||||||
import { mergeServices } from '@lib/domain/merge'
|
import { mergeServices } from '@lib/domain/merge'
|
||||||
import { filterServices, serviceCounts } from '@lib/domain/filter'
|
import { filterServices, serviceCounts } from '@lib/domain/filter'
|
||||||
import type { DatabaseStatus, ServiceFilter } from '@lib/types'
|
import type { DatabaseStatus, ServiceFilter } from '@lib/types'
|
||||||
import { Header } from '@components/layout/header'
|
import { Header } from '@components/layout/header'
|
||||||
import { Toolbar } from '@components/layout/toolbar'
|
import { Toolbar } from '@components/layout/toolbar'
|
||||||
import { ServiceGrid } from '@components/service/service-grid'
|
import { ServiceGrid } from '@components/service/service-grid'
|
||||||
import { TrustedServices } from '@components/service/trusted-services'
|
import { SettingsDialog } from '@components/settings/settings-dialog'
|
||||||
import { Toaster } from '@components/ui/sonner'
|
import { Toaster } from '@components/ui/sonner'
|
||||||
import { EmptyState, LoadingState, RegistryErrorState } from '@components/layout/status-states'
|
import { EmptyState, LoadingState, RegistryErrorState } from '@components/layout/status-states'
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ export function App() {
|
|||||||
const registry = useMasterRegistry()
|
const registry = useMasterRegistry()
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [filter, setFilter] = useState<ServiceFilter>('all')
|
const [filter, setFilter] = useState<ServiceFilter>('all')
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
const trust = useTrustStore(settingsOpen)
|
||||||
|
|
||||||
const services = useMemo(() => {
|
const services = useMemo(() => {
|
||||||
const items = registry.state.kind === 'ok' ? registry.state.items : []
|
const items = registry.state.kind === 'ok' ? registry.state.items : []
|
||||||
@@ -78,13 +81,22 @@ export function App() {
|
|||||||
setFilter('all')
|
setFilter('all')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openSettings = () => {
|
||||||
|
setSettingsOpen(true)
|
||||||
|
void trust.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingTrust = trust.load.kind === 'ok' ? trust.load.state.pending.length : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-slot="app" className="min-h-screen bg-background text-foreground antialiased">
|
<div data-slot="app" className="min-h-screen bg-background text-foreground antialiased">
|
||||||
<Header
|
<Header
|
||||||
installed={counts.installed}
|
installed={counts.installed}
|
||||||
updates={counts.updates}
|
updates={counts.updates}
|
||||||
connection={status}
|
connection={status}
|
||||||
|
pendingTrust={pendingTrust}
|
||||||
onShowUpdates={showUpdates}
|
onShowUpdates={showUpdates}
|
||||||
|
onOpenSettings={openSettings}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main data-slot="main" className="mx-auto flex min-h-[calc(100dvh-4rem)] w-full max-w-console flex-col gap-6 px-4 py-6 pb-16 sm:px-6">
|
<main data-slot="main" className="mx-auto flex min-h-[calc(100dvh-4rem)] w-full max-w-console flex-col gap-6 px-4 py-6 pb-16 sm:px-6">
|
||||||
@@ -107,8 +119,6 @@ export function App() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TrustedServices />
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground" role="status">
|
<p className="text-xs text-muted-foreground" role="status">
|
||||||
Showing {visible.length} of {counts.total} services
|
Showing {visible.length} of {counts.total} services
|
||||||
</p>
|
</p>
|
||||||
@@ -122,6 +132,13 @@ export function App() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<SettingsDialog
|
||||||
|
open={settingsOpen}
|
||||||
|
onOpenChange={setSettingsOpen}
|
||||||
|
trust={trust.load}
|
||||||
|
onRefreshTrust={trust.refresh}
|
||||||
|
/>
|
||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,18 +15,22 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Settings } from 'lucide-react'
|
||||||
import { BrandMark } from '@components/brand-mark'
|
import { BrandMark } from '@components/brand-mark'
|
||||||
import { ConnectionBadge } from '@components/connection/connection-badge'
|
import { ConnectionBadge } from '@components/connection/connection-badge'
|
||||||
|
import { Button } from '@components/ui/button'
|
||||||
import type { ConnectionStatus } from '@lib/ws/registry'
|
import type { ConnectionStatus } from '@lib/ws/registry'
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
installed: number
|
installed: number
|
||||||
updates: number
|
updates: number
|
||||||
connection: ConnectionStatus
|
connection: ConnectionStatus
|
||||||
|
pendingTrust?: number
|
||||||
onShowUpdates: () => void
|
onShowUpdates: () => void
|
||||||
|
onOpenSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ installed, updates, connection, onShowUpdates }: HeaderProps) {
|
export function Header({ installed, updates, connection, pendingTrust = 0, onShowUpdates, onOpenSettings }: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header data-slot="header" className="sticky top-0 z-50 border-b border-border/70 bg-background/85 backdrop-blur-md">
|
<header data-slot="header" className="sticky top-0 z-50 border-b border-border/70 bg-background/85 backdrop-blur-md">
|
||||||
<div className="mx-auto flex h-16 w-full max-w-console items-center justify-between gap-4 px-4 sm:px-6">
|
<div className="mx-auto flex h-16 w-full max-w-console items-center justify-between gap-4 px-4 sm:px-6">
|
||||||
@@ -63,6 +67,26 @@ export function Header({ installed, updates, connection, onShowUpdates }: Header
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ConnectionBadge status={connection} />
|
<ConnectionBadge status={connection} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
data-slot="header-settings"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onPress={onOpenSettings}
|
||||||
|
aria-label="Settings"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
className="relative text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Settings aria-hidden="true" className="size-4" />
|
||||||
|
{pendingTrust > 0 && (
|
||||||
|
<span
|
||||||
|
data-slot="header-settings-pending"
|
||||||
|
className="absolute -right-1 -top-1 inline-flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-warning px-0.5 font-mono text-[11px] font-semibold leading-none text-background ring-2 ring-background"
|
||||||
|
>
|
||||||
|
{pendingTrust > 9 ? '9+' : pendingTrust}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -15,20 +15,15 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'preact/hooks'
|
import { BadgeCheck, Check, ChevronRight, EyeOff, LoaderCircle, RotateCcw, ShieldCheck, X } from 'lucide-react'
|
||||||
import { BadgeCheck, Check, LoaderCircle, RotateCcw, ShieldCheck, X } from 'lucide-react'
|
|
||||||
import { Button } from '@components/ui/button'
|
import { Button } from '@components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@components/ui/card'
|
import { Disclosure, DisclosurePanel, Heading } from 'react-aria-components'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { approvePeer, fetchTrustState, rejectPeer, revokePeer } from '@lib/api/peers'
|
import { approvePeer, hidePeer, rejectPeer, revokePeer } from '@lib/api/peers'
|
||||||
import { useAsyncAction } from '@lib/hooks/useAsyncAction'
|
import { useAsyncAction } from '@lib/hooks/useAsyncAction'
|
||||||
|
import type { TrustLoadState } from '@lib/hooks/useTrustStore'
|
||||||
import { cn } from '@lib/utils'
|
import { cn } from '@lib/utils'
|
||||||
import type { PeerAuthorized, PeerPending, PeerTrustState } from '@lib/types'
|
import type { PeerAuthorized, PeerPending } from '@lib/types'
|
||||||
|
|
||||||
type LoadState =
|
|
||||||
| { kind: 'loading' }
|
|
||||||
| { kind: 'ok'; state: PeerTrustState }
|
|
||||||
| { kind: 'error'; message: string }
|
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
function errorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : 'Unknown error'
|
return error instanceof Error ? error.message : 'Unknown error'
|
||||||
@@ -46,86 +41,114 @@ function fmtTime(iso?: string | null): string {
|
|||||||
return d.toLocaleString()
|
return d.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TrustedServicesSectionProps {
|
||||||
|
load: TrustLoadState
|
||||||
|
onRefresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The trusted-services surface (security theme M2 §3): pending first-contact
|
* The trusted-services surface (security theme M2 §3): pending first-contact
|
||||||
* authorizations, the approved trust store, and revoked keys. Maestro is only
|
* authorizations, the approved trust store, and revoked keys. Maestro is only
|
||||||
* the UI — the auth service owns the enforcement, so this panel is a
|
* the UI — the auth service owns the enforcement, so this panel is a
|
||||||
* convenience, not a security boundary. Hidden entirely when the management
|
* convenience, not a security boundary. Rendered as a section inside the
|
||||||
* API is unreachable.
|
* settings dialog.
|
||||||
*/
|
*/
|
||||||
export function TrustedServices() {
|
export function TrustedServicesSection({ load, onRefresh }: TrustedServicesSectionProps) {
|
||||||
const [state, setState] = useState<LoadState>({ kind: 'loading' })
|
if (load.kind === 'loading') {
|
||||||
|
return (
|
||||||
const load = async () => {
|
<div
|
||||||
try {
|
data-slot="trusted-loading"
|
||||||
const trust = await fetchTrustState()
|
className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground"
|
||||||
setState({ kind: 'ok', state: trust })
|
>
|
||||||
} catch (error) {
|
<LoaderCircle className="size-4 animate-spin" aria-hidden="true" />
|
||||||
setState({ kind: 'error', message: errorMessage(error) })
|
Loading trust state…
|
||||||
}
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
if (load.kind === 'error') {
|
||||||
void load()
|
return (
|
||||||
}, [])
|
<div
|
||||||
|
data-slot="trusted-error"
|
||||||
|
className="flex items-center justify-between gap-3 rounded-lg border border-destructive/25 bg-destructive/5 px-3 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
<p className="min-w-0 text-muted-foreground">
|
||||||
|
Couldn't reach the auth service.
|
||||||
|
<span className="ml-1.5 text-destructive">{load.message}</span>
|
||||||
|
</p>
|
||||||
|
<Button size="xs" variant="outline" onPress={onRefresh}>
|
||||||
|
<RotateCcw aria-hidden="true" className="size-3" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (state.kind === 'loading') return null
|
const pending = load.state.pending ?? []
|
||||||
if (state.kind === 'error') return null
|
const authorized = load.state.authorized ?? []
|
||||||
|
const active = authorized.filter((k) => !k.revoked_at && !k.hidden_at)
|
||||||
|
const revoked = authorized.filter((k) => k.revoked_at && !k.hidden_at)
|
||||||
|
|
||||||
const pending = state.state.pending ?? []
|
if (pending.length === 0 && active.length === 0 && revoked.length === 0) {
|
||||||
const authorized = state.state.authorized ?? []
|
return (
|
||||||
const active = authorized.filter((k) => !k.revoked_at)
|
<div
|
||||||
const revoked = authorized.filter((k) => k.revoked_at)
|
data-slot="trusted-empty"
|
||||||
|
className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-muted/30 px-3 py-8 text-sm text-muted-foreground"
|
||||||
if (pending.length === 0 && active.length === 0 && revoked.length === 0) return null
|
>
|
||||||
|
<ShieldCheck className="size-4 text-muted-foreground/60" aria-hidden="true" />
|
||||||
|
No services have asked to talk to the auth service yet.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card data-slot="trusted-services">
|
<div data-slot="trusted-section" className="grid min-w-0 gap-4">
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<ShieldCheck className="size-4 text-muted-foreground" />
|
|
||||||
Trusted services
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Services asking to talk to the auth service. Accept authorizes a fingerprint (SSH
|
|
||||||
authorized_keys, applied to the service fleet), reject drops a request without
|
|
||||||
authorizing it, revoke cuts off an accepted peer immediately, re-authorize restores one.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{pending.length > 0 && (
|
{pending.length > 0 && (
|
||||||
<CardContent data-slot="trusted-pending" className="grid gap-2">
|
<div data-slot="trusted-pending" className="grid min-w-0 gap-2">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-amber-600">
|
<p className="text-xs font-medium uppercase tracking-wide text-warning">
|
||||||
Requested access ({pending.length})
|
Requested access ({pending.length})
|
||||||
</p>
|
</p>
|
||||||
{pending.map((p) => (
|
{pending.map((p) => (
|
||||||
<PendingRow key={`${p.service}:${p.fingerprint}`} entry={p} onDone={load} />
|
<PendingRow key={`${p.service}:${p.fingerprint}`} entry={p} onDone={onRefresh} />
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active.length > 0 && (
|
{active.length > 0 && (
|
||||||
<CardContent data-slot="trusted-approved" className="grid gap-2">
|
<div data-slot="trusted-approved" className="grid min-w-0 gap-2">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-emerald-600">
|
<p className="text-xs font-medium uppercase tracking-wide text-success">
|
||||||
Accepted ({active.length})
|
Accepted ({active.length})
|
||||||
</p>
|
</p>
|
||||||
{active.map((k) => (
|
{active.map((k) => (
|
||||||
<AuthorizedRow key={`${k.service}:${k.fingerprint}`} entry={k} onDone={load} />
|
<AuthorizedRow key={`${k.service}:${k.fingerprint}`} entry={k} onDone={onRefresh} />
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{revoked.length > 0 && (
|
{revoked.length > 0 && (
|
||||||
<CardContent data-slot="trusted-revoked" className="grid gap-2">
|
<Disclosure data-slot="trusted-revoked" defaultExpanded={false} className="group min-w-0">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<Heading className="m-0">
|
||||||
|
<Button
|
||||||
|
slot="trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start gap-1.5 px-1 font-medium text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-200 group-data-[expanded]:rotate-90"
|
||||||
|
/>
|
||||||
Revoked ({revoked.length})
|
Revoked ({revoked.length})
|
||||||
</p>
|
</Button>
|
||||||
|
</Heading>
|
||||||
|
<DisclosurePanel className="mt-1 grid min-w-0 gap-2 px-1">
|
||||||
{revoked.map((k) => (
|
{revoked.map((k) => (
|
||||||
<RevokedRow key={`${k.service}:${k.fingerprint}`} entry={k} onDone={load} />
|
<RevokedRow key={`${k.service}:${k.fingerprint}`} entry={k} onDone={onRefresh} />
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</DisclosurePanel>
|
||||||
|
</Disclosure>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,10 +174,10 @@ function PendingRow({ entry, onDone }: { entry: PeerPending; onDone: () => void
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2">
|
<div className="flex min-w-0 items-center justify-between gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="flex items-center gap-1.5 text-sm font-medium">
|
<p className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
<span className="inline-block size-1.5 shrink-0 rounded-full bg-amber-500" aria-hidden />
|
<span className="inline-block size-1.5 shrink-0 rounded-full bg-warning" aria-hidden />
|
||||||
{entry.service}
|
{entry.service}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate font-mono text-xs text-muted-foreground">{entry.fingerprint}</p>
|
<p className="truncate font-mono text-xs text-muted-foreground">{entry.fingerprint}</p>
|
||||||
@@ -186,10 +209,10 @@ function AuthorizedRow({ entry, onDone }: { entry: PeerAuthorized; onDone: () =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
<div className="flex min-w-0 items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="flex items-center gap-1.5 text-sm font-medium">
|
<p className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
<BadgeCheck className="size-4 text-emerald-500" aria-hidden />
|
<BadgeCheck className="size-4 text-success" aria-hidden />
|
||||||
{entry.service}
|
{entry.service}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate font-mono text-xs text-muted-foreground">
|
<p className="truncate font-mono text-xs text-muted-foreground">
|
||||||
@@ -224,10 +247,22 @@ function RevokedRow({ entry, onDone }: { entry: PeerAuthorized; onDone: () => vo
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hide = useAsyncAction(async () => {
|
||||||
|
try {
|
||||||
|
await hidePeer(entry.service, entry.fingerprint)
|
||||||
|
toast.success(`Hidden ${entry.service}`, {
|
||||||
|
description: `${shortFingerprint(entry.fingerprint)} removed from the dashboard`,
|
||||||
|
})
|
||||||
|
onDone()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`Failed to hide ${entry.service}`, { description: errorMessage(error) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2'
|
'flex min-w-0 items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -241,6 +276,17 @@ function RevokedRow({ entry, onDone }: { entry: PeerAuthorized; onDone: () => vo
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground/70">Revoked {fmtTime(entry.revoked_at)}</p>
|
<p className="text-xs text-muted-foreground/70">Revoked {fmtTime(entry.revoked_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
onPress={() => void hide.run()}
|
||||||
|
isDisabled={hide.pending}
|
||||||
|
aria-label={`Hide ${entry.service} permanently from the dashboard`}
|
||||||
|
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
>
|
||||||
|
{hide.pending ? <LoaderCircle className="animate-spin" /> : <EyeOff />}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -251,5 +297,6 @@ function RevokedRow({ entry, onDone }: { entry: PeerAuthorized; onDone: () => vo
|
|||||||
Re-authorize
|
Re-authorize
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Dcntrlized
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Settings, ShieldCheck, X } from 'lucide-react'
|
||||||
|
import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
|
||||||
|
import { Button } from '@components/ui/button'
|
||||||
|
import { TrustedServicesSection } from '@components/service/trusted-services'
|
||||||
|
import type { TrustLoadState } from '@lib/hooks/useTrustStore'
|
||||||
|
|
||||||
|
interface SettingsDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
trust: TrustLoadState
|
||||||
|
onRefreshTrust: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsDialog({ open, onOpenChange, trust, onRefreshTrust }: SettingsDialogProps) {
|
||||||
|
return (
|
||||||
|
<ModalOverlay
|
||||||
|
isOpen={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
isDismissable
|
||||||
|
className="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/60 p-4 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<Modal className="w-full max-w-lg min-w-0">
|
||||||
|
<Dialog
|
||||||
|
aria-label="Settings"
|
||||||
|
className="max-h-[min(100dvh-2rem,44rem)] w-full min-w-0 overflow-y-auto rounded-xl border border-border bg-popover text-popover-foreground shadow-toast outline-none"
|
||||||
|
>
|
||||||
|
<header className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="flex items-center gap-2 text-sm font-semibold tracking-tight text-foreground">
|
||||||
|
<Settings className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
|
Settings
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
Manage how this node trusts the services on its fleet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
data-slot="settings-close"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Close settings"
|
||||||
|
onPress={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid min-w-0 gap-6 px-5 py-5">
|
||||||
|
<section data-slot="settings-section-trusted" aria-labelledby="settings-trusted-title" className="min-w-0">
|
||||||
|
<h3
|
||||||
|
id="settings-trusted-title"
|
||||||
|
className="flex items-center gap-2 text-sm font-semibold tracking-tight text-foreground"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
|
Trusted services
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
Services asking to talk to the auth service. Accept authorizes a fingerprint (SSH
|
||||||
|
authorized_keys, applied to the service fleet), reject drops a request without
|
||||||
|
authorizing it, revoke cuts off an accepted peer immediately, re-authorize restores
|
||||||
|
one.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 min-w-0">
|
||||||
|
<TrustedServicesSection load={trust} onRefresh={onRefreshTrust} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -42,3 +42,8 @@ export function revokePeer(service: string, fingerprint: string): Promise<void>
|
|||||||
export function rejectPeer(service: string, fingerprint: string): Promise<void> {
|
export function rejectPeer(service: string, fingerprint: string): Promise<void> {
|
||||||
return post<void>('/api/peers/reject', { service, fingerprint })
|
return post<void>('/api/peers/reject', { service, fingerprint })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Permanently hides a revoked peer from the dashboard (row stays in the trust store). */
|
||||||
|
export function hidePeer(service: string, fingerprint: string): Promise<void> {
|
||||||
|
return post<void>('/api/peers/hide', { service, fingerprint })
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Dcntrlized
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'preact/hooks'
|
||||||
|
import { fetchTrustState } from '@lib/api/peers'
|
||||||
|
import type { PeerTrustState } from '@lib/types'
|
||||||
|
|
||||||
|
export type TrustLoadState =
|
||||||
|
| { kind: 'loading' }
|
||||||
|
| { kind: 'ok'; state: PeerTrustState }
|
||||||
|
| { kind: 'error'; message: string }
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the peer trust state for the console. Fetches once on mount and then
|
||||||
|
* polls: every 10s while the settings dialog is open (the operator is watching
|
||||||
|
* the trust store), every 2 minutes otherwise (keeps the header pending badge
|
||||||
|
* fresh). Refreshes replace the last known state silently so an in-flight
|
||||||
|
* mutation never flashes a loading spinner over live rows; mutations also
|
||||||
|
* trigger an immediate refresh through the same hook.
|
||||||
|
*/
|
||||||
|
export function useTrustStore(open: boolean) {
|
||||||
|
const [load, setLoad] = useState<TrustLoadState>({ kind: 'loading' })
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const state = await fetchTrustState()
|
||||||
|
setLoad({ kind: 'ok', state })
|
||||||
|
} catch (error) {
|
||||||
|
setLoad({ kind: 'error', message: errorMessage(error) })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh()
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void refresh()
|
||||||
|
}, open ? 10_000 : 120_000)
|
||||||
|
return () => window.clearInterval(interval)
|
||||||
|
}, [open, refresh])
|
||||||
|
|
||||||
|
return { load, refresh }
|
||||||
|
}
|
||||||
@@ -118,6 +118,7 @@ export interface PeerAuthorized {
|
|||||||
approved_by: string
|
approved_by: string
|
||||||
approved_at: string
|
approved_at: string
|
||||||
revoked_at?: string | null
|
revoked_at?: string | null
|
||||||
|
hidden_at?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Combined pending + approved trust state served by GET /api/peers. */
|
/** Combined pending + approved trust state served by GET /api/peers. */
|
||||||
|
|||||||
@@ -134,6 +134,26 @@ func (h *Handler) Reject(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "rejected"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "rejected"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide serves POST /api/peers/hide — permanently hides a revoked peer from
|
||||||
|
// the dashboard: the auth service keeps the row (audit trail survives) but
|
||||||
|
// marks it hidden; a re-approval restores visibility.
|
||||||
|
func (h *Handler) Hide(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var action authzAction
|
||||||
|
if !decodeAction(r, w, &action) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, err := h.ensureClient()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := client.Hide(r.Context(), action.Service, action.Fingerprint); err != nil {
|
||||||
|
http.Error(w, "failed to hide peer: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "hidden"})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) ensureClient() (*authmgmt.Client, error) {
|
func (h *Handler) ensureClient() (*authmgmt.Client, error) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ type AuthorizedKey struct {
|
|||||||
ApprovedBy string `json:"approved_by"`
|
ApprovedBy string `json:"approved_by"`
|
||||||
ApprovedAt string `json:"approved_at"`
|
ApprovedAt string `json:"approved_at"`
|
||||||
RevokedAt *string `json:"revoked_at"`
|
RevokedAt *string `json:"revoked_at"`
|
||||||
|
HiddenAt *string `json:"hidden_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PendingAuthorization is a first-contact request awaiting the owner's
|
// PendingAuthorization is a first-contact request awaiting the owner's
|
||||||
@@ -211,6 +212,14 @@ func (c *Client) Reject(ctx context.Context, service, fingerprint string) error
|
|||||||
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
|
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide permanently hides a revoked peer from the dashboard: the auth service
|
||||||
|
// keeps the row (audit trail survives) but marks it hidden_at; a re-approval
|
||||||
|
// clears the flag and makes the key visible again.
|
||||||
|
func (c *Client) Hide(ctx context.Context, service, fingerprint string) error {
|
||||||
|
return c.do(ctx, http.MethodPost, "/internal/hide",
|
||||||
|
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
||||||
var payload []byte
|
var payload []byte
|
||||||
if body != nil {
|
if body != nil {
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ func main() {
|
|||||||
mux.HandleFunc("/api/peers/approve", peersHandler.Approve)
|
mux.HandleFunc("/api/peers/approve", peersHandler.Approve)
|
||||||
mux.HandleFunc("/api/peers/revoke", peersHandler.Revoke)
|
mux.HandleFunc("/api/peers/revoke", peersHandler.Revoke)
|
||||||
mux.HandleFunc("/api/peers/reject", peersHandler.Reject)
|
mux.HandleFunc("/api/peers/reject", peersHandler.Reject)
|
||||||
|
mux.HandleFunc("/api/peers/hide", peersHandler.Hide)
|
||||||
|
|
||||||
frontendDir, err := fs.Sub(embeddedFrontend, "frontend/dist")
|
frontendDir, err := fs.Sub(embeddedFrontend, "frontend/dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user