From 545338016e242d5928d9988a7612eff682055dad Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Fri, 21 Aug 2026 20:59:42 +0100 Subject: [PATCH] feat(peers): settings dialog + hide for revoked peers, pending badge in header --- src/frontend/src/app.tsx | 23 +- src/frontend/src/components/layout/header.tsx | 26 ++- .../components/service/trusted-services.tsx | 201 +++++++++++------- .../components/settings/settings-dialog.tsx | 89 ++++++++ src/frontend/src/lib/api/peers.ts | 5 + src/frontend/src/lib/hooks/useTrustStore.ts | 63 ++++++ src/frontend/src/lib/types.ts | 1 + src/handlers/peers/peers.go | 20 ++ src/lib/authmgmt/client.go | 9 + src/main.go | 1 + 10 files changed, 357 insertions(+), 81 deletions(-) create mode 100644 src/frontend/src/components/settings/settings-dialog.tsx create mode 100644 src/frontend/src/lib/hooks/useTrustStore.ts diff --git a/src/frontend/src/app.tsx b/src/frontend/src/app.tsx index ebe0473..f298f9e 100644 --- a/src/frontend/src/app.tsx +++ b/src/frontend/src/app.tsx @@ -18,13 +18,14 @@ import { useMemo, useState } from 'preact/hooks' import { useContainerStore } from '@lib/hooks/useContainerStore' import { useMasterRegistry } from '@lib/hooks/useMasterRegistry' +import { useTrustStore } from '@lib/hooks/useTrustStore' import { mergeServices } from '@lib/domain/merge' import { filterServices, serviceCounts } from '@lib/domain/filter' import type { DatabaseStatus, ServiceFilter } from '@lib/types' import { Header } from '@components/layout/header' import { Toolbar } from '@components/layout/toolbar' 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 { EmptyState, LoadingState, RegistryErrorState } from '@components/layout/status-states' @@ -35,6 +36,8 @@ export function App() { const registry = useMasterRegistry() const [query, setQuery] = useState('') const [filter, setFilter] = useState('all') + const [settingsOpen, setSettingsOpen] = useState(false) + const trust = useTrustStore(settingsOpen) const services = useMemo(() => { const items = registry.state.kind === 'ok' ? registry.state.items : [] @@ -78,13 +81,22 @@ export function App() { setFilter('all') } + const openSettings = () => { + setSettingsOpen(true) + void trust.refresh() + } + + const pendingTrust = trust.load.kind === 'ok' ? trust.load.state.pending.length : 0 + return (
@@ -107,8 +119,6 @@ export function App() { }} /> - -

Showing {visible.length} of {counts.total} services

@@ -122,6 +132,13 @@ export function App() { )}
+ +
) diff --git a/src/frontend/src/components/layout/header.tsx b/src/frontend/src/components/layout/header.tsx index 2efc4e7..bc83791 100644 --- a/src/frontend/src/components/layout/header.tsx +++ b/src/frontend/src/components/layout/header.tsx @@ -15,18 +15,22 @@ * along with this program. If not, see . */ +import { Settings } from 'lucide-react' import { BrandMark } from '@components/brand-mark' import { ConnectionBadge } from '@components/connection/connection-badge' +import { Button } from '@components/ui/button' import type { ConnectionStatus } from '@lib/ws/registry' interface HeaderProps { installed: number updates: number connection: ConnectionStatus + pendingTrust?: number onShowUpdates: () => void + onOpenSettings: () => void } -export function Header({ installed, updates, connection, onShowUpdates }: HeaderProps) { +export function Header({ installed, updates, connection, pendingTrust = 0, onShowUpdates, onOpenSettings }: HeaderProps) { return (
@@ -63,6 +67,26 @@ export function Header({ installed, updates, connection, onShowUpdates }: Header )} + +
diff --git a/src/frontend/src/components/service/trusted-services.tsx b/src/frontend/src/components/service/trusted-services.tsx index 5ebc4dc..d9d8167 100644 --- a/src/frontend/src/components/service/trusted-services.tsx +++ b/src/frontend/src/components/service/trusted-services.tsx @@ -15,20 +15,15 @@ * along with this program. If not, see . */ -import { useEffect, useState } from 'preact/hooks' -import { BadgeCheck, Check, LoaderCircle, RotateCcw, ShieldCheck, X } from 'lucide-react' +import { BadgeCheck, Check, ChevronRight, EyeOff, LoaderCircle, RotateCcw, ShieldCheck, X } from 'lucide-react' 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 { approvePeer, fetchTrustState, rejectPeer, revokePeer } from '@lib/api/peers' +import { approvePeer, hidePeer, rejectPeer, revokePeer } from '@lib/api/peers' import { useAsyncAction } from '@lib/hooks/useAsyncAction' +import type { TrustLoadState } from '@lib/hooks/useTrustStore' import { cn } from '@lib/utils' -import type { PeerAuthorized, PeerPending, PeerTrustState } from '@lib/types' - -type LoadState = - | { kind: 'loading' } - | { kind: 'ok'; state: PeerTrustState } - | { kind: 'error'; message: string } +import type { PeerAuthorized, PeerPending } from '@lib/types' function errorMessage(error: unknown): string { return error instanceof Error ? error.message : 'Unknown error' @@ -46,86 +41,114 @@ function fmtTime(iso?: string | null): string { return d.toLocaleString() } +interface TrustedServicesSectionProps { + load: TrustLoadState + onRefresh: () => void +} + /** * The trusted-services surface (security theme M2 §3): pending first-contact * authorizations, the approved trust store, and revoked keys. Maestro is only * the UI — the auth service owns the enforcement, so this panel is a - * convenience, not a security boundary. Hidden entirely when the management - * API is unreachable. + * convenience, not a security boundary. Rendered as a section inside the + * settings dialog. */ -export function TrustedServices() { - const [state, setState] = useState({ kind: 'loading' }) - - const load = async () => { - try { - const trust = await fetchTrustState() - setState({ kind: 'ok', state: trust }) - } catch (error) { - setState({ kind: 'error', message: errorMessage(error) }) - } +export function TrustedServicesSection({ load, onRefresh }: TrustedServicesSectionProps) { + if (load.kind === 'loading') { + return ( +
+
+ ) } - useEffect(() => { - void load() - }, []) + if (load.kind === 'error') { + return ( +
+

+ Couldn't reach the auth service. + {load.message} +

+ +
+ ) + } - if (state.kind === 'loading') return null - if (state.kind === 'error') return null + const pending = load.state.pending ?? [] + 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 ?? [] - const authorized = state.state.authorized ?? [] - const active = authorized.filter((k) => !k.revoked_at) - const revoked = authorized.filter((k) => k.revoked_at) - - if (pending.length === 0 && active.length === 0 && revoked.length === 0) return null + if (pending.length === 0 && active.length === 0 && revoked.length === 0) { + return ( +
+
+ ) + } return ( - - - - - Trusted services - - - 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. - - - +
{pending.length > 0 && ( - -

+

+

Requested access ({pending.length})

{pending.map((p) => ( - + ))} - +
)} {active.length > 0 && ( - -

+

+

Accepted ({active.length})

{active.map((k) => ( - + ))} - +
)} {revoked.length > 0 && ( - -

- Revoked ({revoked.length}) -

- {revoked.map((k) => ( - - ))} -
+ + + + + + {revoked.map((k) => ( + + ))} + + )} - +
) } @@ -151,10 +174,10 @@ function PendingRow({ entry, onDone }: { entry: PeerPending; onDone: () => void }) return ( -
+

- + {entry.service}

{entry.fingerprint}

@@ -186,10 +209,10 @@ function AuthorizedRow({ entry, onDone }: { entry: PeerAuthorized; onDone: () => }) return ( -
+

- + {entry.service}

@@ -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 (

@@ -241,15 +276,27 @@ function RevokedRow({ entry, onDone }: { entry: PeerAuthorized; onDone: () => vo

Revoked {fmtTime(entry.revoked_at)}

- +
+ + +
) } diff --git a/src/frontend/src/components/settings/settings-dialog.tsx b/src/frontend/src/components/settings/settings-dialog.tsx new file mode 100644 index 0000000..78a6bde --- /dev/null +++ b/src/frontend/src/components/settings/settings-dialog.tsx @@ -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 . + */ + +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 ( + + + +
+
+

+

+

+ Manage how this node trusts the services on its fleet. +

+
+ +
+ +
+
+

+

+

+ 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. +

+
+ +
+
+
+
+
+
+ ) +} diff --git a/src/frontend/src/lib/api/peers.ts b/src/frontend/src/lib/api/peers.ts index 75b6d03..695e1ab 100644 --- a/src/frontend/src/lib/api/peers.ts +++ b/src/frontend/src/lib/api/peers.ts @@ -42,3 +42,8 @@ export function revokePeer(service: string, fingerprint: string): Promise export function rejectPeer(service: string, fingerprint: string): Promise { return post('/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 { + return post('/api/peers/hide', { service, fingerprint }) +} diff --git a/src/frontend/src/lib/hooks/useTrustStore.ts b/src/frontend/src/lib/hooks/useTrustStore.ts new file mode 100644 index 0000000..168bee1 --- /dev/null +++ b/src/frontend/src/lib/hooks/useTrustStore.ts @@ -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 . + */ + +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({ 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 } +} diff --git a/src/frontend/src/lib/types.ts b/src/frontend/src/lib/types.ts index cae8d15..15be551 100644 --- a/src/frontend/src/lib/types.ts +++ b/src/frontend/src/lib/types.ts @@ -118,6 +118,7 @@ export interface PeerAuthorized { approved_by: string approved_at: string revoked_at?: string | null + hidden_at?: string | null } /** Combined pending + approved trust state served by GET /api/peers. */ diff --git a/src/handlers/peers/peers.go b/src/handlers/peers/peers.go index fab8d77..9236b9b 100644 --- a/src/handlers/peers/peers.go +++ b/src/handlers/peers/peers.go @@ -134,6 +134,26 @@ func (h *Handler) Reject(w http.ResponseWriter, r *http.Request) { 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) { h.mu.Lock() defer h.mu.Unlock() diff --git a/src/lib/authmgmt/client.go b/src/lib/authmgmt/client.go index 6f9aa41..2a6ad33 100644 --- a/src/lib/authmgmt/client.go +++ b/src/lib/authmgmt/client.go @@ -66,6 +66,7 @@ type AuthorizedKey struct { ApprovedBy string `json:"approved_by"` ApprovedAt string `json:"approved_at"` RevokedAt *string `json:"revoked_at"` + HiddenAt *string `json:"hidden_at"` } // 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) } +// 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 { var payload []byte if body != nil { diff --git a/src/main.go b/src/main.go index 4bb5007..2787919 100644 --- a/src/main.go +++ b/src/main.go @@ -114,6 +114,7 @@ func main() { mux.HandleFunc("/api/peers/approve", peersHandler.Approve) mux.HandleFunc("/api/peers/revoke", peersHandler.Revoke) mux.HandleFunc("/api/peers/reject", peersHandler.Reject) + mux.HandleFunc("/api/peers/hide", peersHandler.Hide) frontendDir, err := fs.Sub(embeddedFrontend, "frontend/dist") if err != nil {