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 [ {