From 653bd5a7550bfb70f4f7639e54b2b56c767ad801 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sun, 26 Jul 2026 20:19:04 +0100 Subject: [PATCH] refactor(web): members and SSO move onto the settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /settings/instance held two cards — Members and Single sign-on — behind a sidebar entry called "Instance", next to one called "Settings". Nothing in either name told you which held what, and the split left both pages thin. They are now the Access group at the top of /settings, above Monitoring and Integrations. Six cards on one page needs sorting into groups or it reads as a pile, so each group carries site/'s keyed-label eyebrow on a hairline. The cards move to web/components/settings/ rather than into the page, which would have made it ~600 lines. That is also where the Field and inputClass pair now lives: the two pages each had a byte-identical copy, and folding them together is exactly when three copies would have started to drift. Members and SSO adopt SectionCard, which the rest of the page already used. A card that kept its own header treatment would read as a different kind of thing rather than another setting, which is the problem being fixed. next.config.ts keeps a permanent redirect from the old path, so bookmarks and any support reply linking it still land somewhere useful. --- claude.md | 12 +- web/app/(app)/settings/instance/page.tsx | 439 ----------------------- web/app/(app)/settings/page.tsx | 143 ++++---- web/components/Sidebar.tsx | 13 - web/components/settings/Field.tsx | 19 + web/components/settings/MembersCard.tsx | 213 +++++++++++ web/components/settings/OIDCCard.tsx | 147 ++++++++ web/components/settings/SectionCard.tsx | 37 ++ web/next.config.ts | 8 + 9 files changed, 503 insertions(+), 528 deletions(-) delete mode 100644 web/app/(app)/settings/instance/page.tsx create mode 100644 web/components/settings/Field.tsx create mode 100644 web/components/settings/MembersCard.tsx create mode 100644 web/components/settings/OIDCCard.tsx create mode 100644 web/components/settings/SectionCard.tsx diff --git a/claude.md b/claude.md index a10ff45..5815aa4 100644 --- a/claude.md +++ b/claude.md @@ -528,7 +528,17 @@ Customer nav is three destinations — Overview, People, Billing. Settings is in | `/monitors`, `/monitors/new`, `/monitors/[id][/edit]` | Checks, uptime, incidents | | `/secrets`, `/secrets/[group]` | Vault | | `/audit` | Audit log | -| `/settings`, `/settings/org`, `/settings/notifications` | Alerts, members, OIDC, channels | +| `/settings`, `/settings/notifications`, `/settings/license` | Members, OIDC, alerts, retention, ESO token · channels · licence | + +**`/settings` is one page, not a section.** Members and single sign-on used to +live at `/settings/instance` with their own sidebar entry; they are now the +**Access** group at the top of `/settings`, above **Monitoring** and +**Integrations**. Splitting "who can sign in" away from "how this instance +behaves" made two half-pages and a nav entry called Instance that no one could +distinguish from Settings. `next.config.ts` keeps a permanent redirect from the +old path. The cards live in `web/components/settings/` rather than in the page, +which is also where the `Field`/`inputClass` pair the three of them share now +lives — one copy instead of the three that existed while they were apart. --- diff --git a/web/app/(app)/settings/instance/page.tsx b/web/app/(app)/settings/instance/page.tsx deleted file mode 100644 index 6db7975..0000000 --- a/web/app/(app)/settings/instance/page.tsx +++ /dev/null @@ -1,439 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { api, auth as authApi, type InstanceUser, type Role } from "@/lib/api"; -import { useAuth } from "@/components/AuthProvider"; -import { Badge, Button, Card, Modal, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui"; - -const ROLES: Role[] = ["owner", "admin", "member"]; - -const inputClass = - "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"; - -function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { - return ( -
- - {children} - {hint &&

{hint}

} -
- ); -} - -function roleVariant(role: Role) { - if (role === "owner") return "accent" as const; - if (role === "admin") return "warning" as const; - return "neutral" as const; -} - -function MembersCard() { - const queryClient = useQueryClient(); - const { user } = useAuth(); - const [addOpen, setAddOpen] = useState(false); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [role, setRole] = useState("member"); - - const { data: users, isLoading, error } = useQuery({ queryKey: ["instance-users"], queryFn: api.listInstanceUsers }); - - const invalidate = () => queryClient.invalidateQueries({ queryKey: ["instance-users"] }); - - const { mutate: createUser, isPending: creating, error: createError } = useMutation({ - mutationFn: () => api.createInstanceUser({ email, password, role }), - onSuccess: () => { - invalidate(); - setAddOpen(false); - setEmail(""); - setPassword(""); - setRole("member"); - }, - }); - - const { mutate: changeRole, error: roleError } = useMutation({ - mutationFn: ({ userId, next }: { userId: string; next: Role }) => api.updateInstanceUserRole(userId, next), - onSuccess: invalidate, - - - onError: invalidate, - }); - - const { mutate: removeUser, error: removeError } = useMutation({ - mutationFn: (userId: string) => api.deleteInstanceUser(userId), - onSuccess: invalidate, - }); - - const actionError = (roleError ?? removeError) as Error | null; - - - - const isOwner = user?.role === "owner"; - const assignableRoles = isOwner ? ROLES : ROLES.filter((r) => r !== "owner"); - const hqUrl = process.env.NEXT_PUBLIC_HQ_URL ?? ""; - - return ( - -
-
-

Members

-

- People with access to this instance. Owners and admins can manage settings. -

-
- -
- - {actionError && ( -
- {actionError.message} -
- )} - - {isLoading ? ( -
-
-
- ) : error ? ( -

{(error as Error).message}

- ) : !users || users.length === 0 ? ( -

No members yet.

- ) : ( - - - - - - - - - - - - {users.map((u: InstanceUser) => { - const isSelf = u.user_id === user?.user_id; - - const managedByHQ = u.auth_source === "hq"; - // Locked here is a courtesy: the API returns 409 for an hq-sourced - // role change or deletion whether or not this select is rendered. - const locked = isSelf || managedByHQ || (u.role === "owner" && !isOwner); - return ( - - - - - - - - ); - })} - -
EmailRoleSign-inLast loginActions
- {u.email} - {isSelf && (you)} - - {locked ? ( - {u.role} - ) : ( - - )} - - - {u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"} - - - {u.last_login ? new Date(u.last_login).toLocaleString() : "Never"} - - {managedByHQ ? ( - hqUrl ? ( - - Managed in Vantage HQ - - ) : ( - Managed in Vantage HQ - ) - ) : ( - !locked && ( - - ) - )} -
- )} - - setAddOpen(false)}> -
{ - e.preventDefault(); - createUser(); - }} - className="space-y-4" - > - - setEmail(e.target.value)} - className={inputClass} - /> - - - - setPassword(e.target.value)} - className={inputClass} - /> - - - - - - - {createError && ( -
- {(createError as Error).message} -
- )} - -
- - -
-
-
- - ); -} - -function OIDCCard() { - const queryClient = useQueryClient(); - const { data: cfg, isLoading } = useQuery({ queryKey: ["instance-oidc"], queryFn: api.getInstanceOIDC }); - - const [issuer, setIssuer] = useState(""); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [enabled, setEnabled] = useState(false); - const [saved, setSaved] = useState(false); - const [copied, setCopied] = useState(false); - - const redirectUrl = authApi.oidcRedirectUrl(); - - useEffect(() => { - if (!cfg) return; - setIssuer(cfg.issuer ?? ""); - setClientId(cfg.client_id ?? ""); - setEnabled(cfg.enabled); - - setClientSecret(""); - }, [cfg]); - - const { mutate: save, isPending, error } = useMutation({ - mutationFn: () => api.saveInstanceOIDC({ issuer, client_id: clientId, client_secret: clientSecret, enabled }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["instance-oidc"] }); - setClientSecret(""); - setSaved(true); - setTimeout(() => setSaved(false), 3000); - }, - }); - - async function copyRedirect() { - await navigator.clipboard.writeText(redirectUrl); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - - if (isLoading) { - return ( - -
-
-
- - ); - } - - const secretSet = cfg?.client_secret_set ?? false; - - return ( - -
-

Single Sign-On (OIDC)

-

- Let members sign in with your identity provider. Users are provisioned into this instance on - first sign-in. -

-
- -
-

- Register this redirect URL with your provider: -

-
- - {redirectUrl} - - -
-
- -
{ - e.preventDefault(); - save(); - }} - className="space-y-4" - > - - setIssuer(e.target.value)} - className={inputClass} - /> - - - - setClientId(e.target.value)} - className={inputClass} - /> - - - - setClientSecret(e.target.value)} - className={inputClass} - /> - - -
- - - {secretSet ? "Client secret is configured" : "No client secret configured"} - -
- - - - {enabled && !secretSet && !clientSecret && ( -
- SSO cannot complete sign-in without a client secret. -
- )} - - {error && ( -
- {(error as Error).message} -
- )} - -
- - {saved && SSO settings saved.} -
-
-
- ); -} - -export default function InstanceSettingsPage() { - const { instance, isAdmin } = useAuth(); - - if (!isAdmin) { - return ( -
- -

You don't have access

-

- Instance settings are available to owners and admins only. Ask an administrator if you need - access. -

-
-
- ); - } - - return ( -
-
-

Instance

-

- {instance ? `Manage members and sign-in for ${instance.name}.` : "Manage members and sign-in."} -

-
- -
- - -
-
- ); -} diff --git a/web/app/(app)/settings/page.tsx b/web/app/(app)/settings/page.tsx index 8b95a97..66ee2c2 100644 --- a/web/app/(app)/settings/page.tsx +++ b/web/app/(app)/settings/page.tsx @@ -6,29 +6,24 @@ import Link from "next/link"; import { api } from "@/lib/api"; import { useAuth } from "@/components/AuthProvider"; import { Button, Card } from "@/components/ui"; +import { Field } from "@/components/settings/Field"; +import { SectionCard } from "@/components/settings/SectionCard"; +import { MembersCard } from "@/components/settings/MembersCard"; +import { OIDCCard } from "@/components/settings/OIDCCard"; -function SectionCard({ title, description, icon, children, className }: { title: string; description?: string; icon: React.ReactNode; children: React.ReactNode; className?: string }) { - return ( - -
-
{icon}
-
-

{title}

- {description &&

{description}

} -
-
- {children} -
- ); -} +const numberInputClass = + "w-32 rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"; -function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { +/* + * Six cards on one page needs sorting into groups, or it reads as a pile. The + * eyebrow is site/'s .tag treatment: mono, tracked, on a hairline. + */ +function Group({ label, children }: { label: string; children: React.ReactNode }) { return ( -
- +
+

{label}

{children} - {hint &&

{hint}

} -
+ ); } @@ -102,7 +97,7 @@ function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedA } return ( - }> + }>

Point your ClusterSecretStore at {readUrl}.

@@ -116,10 +111,10 @@ function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedA
{token && ( -
+

Copy this token now it will not be shown again.

- {token} + {token} @@ -128,7 +123,7 @@ function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedA )} {tokenSet &&

Rotating invalidates the previous token. Update the Kubernetes secret afterwards.

} @@ -137,7 +132,7 @@ function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedA export default function SettingsPage() { const queryClient = useQueryClient(); - const { isAdmin } = useAuth(); + const { instance, isAdmin } = useAuth(); const { data: settings, isLoading } = useQuery({ queryKey: ["settings"], @@ -179,7 +174,7 @@ export default function SettingsPage() { return (
-

You don't have access

+

You don't have access

Settings are available to owners and admins only. Ask an administrator if you need access.

@@ -197,62 +192,60 @@ export default function SettingsPage() { return (
-

Settings

-

Configure monitoring, alerting, and integrations.

+

Settings

+

+ {instance ? `Members, sign-in and monitoring for ${instance.name}.` : "Members, sign-in and monitoring for this instance."} +

-
- }> -
- - - - - - -
-

- Webhook, email (SMTP), Discord, Slack, and Telegram destinations are configured under Notification Channels and attached per monitor. -

-
+
+ + + + -
-
- }> - - setThresholdMinutes(Number(e.target.value))} - className="w-32 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" - /> - - + + }> +
+ + + + + + +
+

+ Webhook, email (SMTP), Discord, Slack, and Telegram destinations are configured under Notification Channels and attached per monitor. +

+
- }> - - setLogRetentionDays(Number(e.target.value))} - className="w-32 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" - /> - - -
+ +
+ }> + + setThresholdMinutes(Number(e.target.value))} className={numberInputClass} /> + + -
- - {saved && Settings saved successfully.} -
- + }> + + setLogRetentionDays(Number(e.target.value))} className={numberInputClass} /> + + +
- +
+ + {saved && Settings saved successfully.} +
+ + + + + +
); diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx index e69ed66..6ab5408 100644 --- a/web/components/Sidebar.tsx +++ b/web/components/Sidebar.tsx @@ -120,18 +120,6 @@ function StepsIcon() { ); } -function InstanceIcon() { - return ( - - - - ); -} - const navItems: NavItem[] = [ { href: "/servers", label: "Servers", icon: }, { href: "/monitors", label: "Monitors", icon: }, @@ -140,7 +128,6 @@ const navItems: NavItem[] = [ { href: "/workflows", label: "Workflows", icon: }, { href: "/steps", label: "Steps", icon: }, { href: "/audit", label: "Audit Log", icon: }, - { href: "/settings/instance", label: "Instance", icon: , adminOnly: true }, { href: "/settings/license", label: "Licence", icon: , adminOnly: true }, { href: "/settings", label: "Settings", icon: , adminOnly: true }, ]; diff --git a/web/components/settings/Field.tsx b/web/components/settings/Field.tsx new file mode 100644 index 0000000..24f23ab --- /dev/null +++ b/web/components/settings/Field.tsx @@ -0,0 +1,19 @@ +/* + * The form primitives the settings screens share. Members, SSO and the page + * itself had three byte-identical copies of both of these before they were + * folded onto one page; one copy is what stops them drifting apart now that + * they sit next to each other. + */ + +export const inputClass = + "w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"; + +export function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} diff --git a/web/components/settings/MembersCard.tsx b/web/components/settings/MembersCard.tsx new file mode 100644 index 0000000..fd2485c --- /dev/null +++ b/web/components/settings/MembersCard.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, type InstanceUser, type Role } from "@/lib/api"; +import { useAuth } from "@/components/AuthProvider"; +import { Badge, Button, Modal, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui"; +import { Field, inputClass } from "./Field"; +import { SectionCard } from "./SectionCard"; + +const ROLES: Role[] = ["owner", "admin", "member"]; + +function UsersIcon() { + return ( + + + + ); +} + +function roleVariant(role: Role) { + if (role === "owner") return "accent" as const; + if (role === "admin") return "warning" as const; + return "neutral" as const; +} + +export function MembersCard() { + const queryClient = useQueryClient(); + const { user } = useAuth(); + const [addOpen, setAddOpen] = useState(false); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [role, setRole] = useState("member"); + + const { data: users, isLoading, error } = useQuery({ queryKey: ["instance-users"], queryFn: api.listInstanceUsers }); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["instance-users"] }); + + const { + mutate: createUser, + isPending: creating, + error: createError, + } = useMutation({ + mutationFn: () => api.createInstanceUser({ email, password, role }), + onSuccess: () => { + invalidate(); + setAddOpen(false); + setEmail(""); + setPassword(""); + setRole("member"); + }, + }); + + const { mutate: changeRole, error: roleError } = useMutation({ + mutationFn: ({ userId, next }: { userId: string; next: Role }) => api.updateInstanceUserRole(userId, next), + onSuccess: invalidate, + // Refetch on failure too, so a rejected change does not leave the select + // showing a role the server never accepted. + onError: invalidate, + }); + + const { mutate: removeUser, error: removeError } = useMutation({ + mutationFn: (userId: string) => api.deleteInstanceUser(userId), + onSuccess: invalidate, + }); + + const actionError = (roleError ?? removeError) as Error | null; + + const isOwner = user?.role === "owner"; + const assignableRoles = isOwner ? ROLES : ROLES.filter((r) => r !== "owner"); + const hqUrl = process.env.NEXT_PUBLIC_HQ_URL ?? ""; + + return ( + } + actions={ + + } + > + {actionError &&
{actionError.message}
} + + {isLoading ? ( +
+
+
+ ) : error ? ( +

{(error as Error).message}

+ ) : !users || users.length === 0 ? ( +

No members yet.

+ ) : ( + + + + + + + + + + + + {users.map((u: InstanceUser) => { + const isSelf = u.user_id === user?.user_id; + const managedByHQ = u.auth_source === "hq"; + // Locked here is a courtesy: the API returns 409 for an hq-sourced + // role change or deletion whether or not this select is rendered. + const locked = isSelf || managedByHQ || (u.role === "owner" && !isOwner); + return ( + + + + + + + + ); + })} + +
EmailRoleSign-inLast loginActions
+ {u.email} + {isSelf && (you)} + + {locked ? ( + {u.role} + ) : ( + + )} + + {u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"} + {u.last_login ? new Date(u.last_login).toLocaleString() : "Never"} + {managedByHQ ? ( + hqUrl ? ( + + Managed in Vantage HQ + + ) : ( + Managed in Vantage HQ + ) + ) : ( + !locked && ( + + ) + )} +
+ )} + + setAddOpen(false)}> +
{ + e.preventDefault(); + createUser(); + }} + className="space-y-4" + > + + setEmail(e.target.value)} className={inputClass} /> + + + + setPassword(e.target.value)} className={inputClass} /> + + + + + + + {createError &&
{(createError as Error).message}
} + +
+ + +
+
+
+ + ); +} diff --git a/web/components/settings/OIDCCard.tsx b/web/components/settings/OIDCCard.tsx new file mode 100644 index 0000000..6303740 --- /dev/null +++ b/web/components/settings/OIDCCard.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, auth as authApi } from "@/lib/api"; +import { Button, Card } from "@/components/ui"; +import { Field, inputClass } from "./Field"; +import { SectionCard } from "./SectionCard"; + +function IdentityIcon() { + return ( + + + + ); +} + +export function OIDCCard() { + const queryClient = useQueryClient(); + const { data: cfg, isLoading } = useQuery({ queryKey: ["instance-oidc"], queryFn: api.getInstanceOIDC }); + + const [issuer, setIssuer] = useState(""); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [enabled, setEnabled] = useState(false); + const [saved, setSaved] = useState(false); + const [copied, setCopied] = useState(false); + + const redirectUrl = authApi.oidcRedirectUrl(); + + useEffect(() => { + if (!cfg) return; + setIssuer(cfg.issuer ?? ""); + setClientId(cfg.client_id ?? ""); + setEnabled(cfg.enabled); + // The stored secret is never sent back, so the field starts empty and + // an empty submit means "keep what is there". + setClientSecret(""); + }, [cfg]); + + const { + mutate: save, + isPending, + error, + } = useMutation({ + mutationFn: () => api.saveInstanceOIDC({ issuer, client_id: clientId, client_secret: clientSecret, enabled }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["instance-oidc"] }); + setClientSecret(""); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + }, + }); + + async function copyRedirect() { + await navigator.clipboard.writeText(redirectUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + if (isLoading) { + return ( + +
+
+
+ + ); + } + + const secretSet = cfg?.client_secret_set ?? false; + + return ( + } + > +
+

Register this redirect URL with your provider:

+
+ {redirectUrl} + +
+
+ +
{ + e.preventDefault(); + save(); + }} + className="space-y-4" + > + + setIssuer(e.target.value)} className={inputClass} /> + + + + setClientId(e.target.value)} className={inputClass} /> + + + + setClientSecret(e.target.value)} + className={inputClass} + /> + + +
+ + {secretSet ? "Client secret is configured" : "No client secret configured"} +
+ + + + {enabled && !secretSet && !clientSecret && ( +
SSO cannot complete sign-in without a client secret.
+ )} + + {error &&
{(error as Error).message}
} + +
+ + {saved && SSO settings saved.} +
+
+
+ ); +} diff --git a/web/components/settings/SectionCard.tsx b/web/components/settings/SectionCard.tsx new file mode 100644 index 0000000..886093f --- /dev/null +++ b/web/components/settings/SectionCard.tsx @@ -0,0 +1,37 @@ +import { Card } from "@/components/ui"; + +/* + * Every block on the settings page is one of these: icon, title, one line of + * explanation, then the controls. Members and SSO adopted it when they moved + * off their own page — a card that looked different would read as a different + * kind of thing rather than another setting. + */ +export function SectionCard({ + title, + description, + icon, + actions, + children, + className, +}: { + title: string; + description?: string; + icon: React.ReactNode; + actions?: React.ReactNode; + children: React.ReactNode; + className?: string; +}) { + return ( + +
+
{icon}
+
+

{title}

+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ {children} +
+ ); +} diff --git a/web/next.config.ts b/web/next.config.ts index 355d255..ab79cc8 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -4,6 +4,14 @@ const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080"; const nextConfig: NextConfig = { output: "standalone", + async redirects() { + return [ + // Members and SSO moved onto /settings. Permanent, because the old + // page is gone rather than temporarily unavailable — but it costs + // nothing to keep an old bookmark or a linked support reply working. + { source: "/settings/instance", destination: "/settings", permanent: true }, + ]; + }, async rewrites() { return [ {