refactor(web): members and SSO move onto the settings page
Server Deploy / deploy (push) Successful in 1m12s

/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.
This commit is contained in:
mrhid6
2026-07-26 20:19:04 +01:00
parent 01eda1dbb0
commit 653bd5a755
9 changed files with 503 additions and 528 deletions
-439
View File
@@ -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 (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
{children}
{hint && <p className="mt-1 text-xs text-text-tertiary">{hint}</p>}
</div>
);
}
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<Role>("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 (
<Card>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-text-primary">Members</h2>
<p className="mt-0.5 text-sm text-text-secondary">
People with access to this instance. Owners and admins can manage settings.
</p>
</div>
<Button variant="primary" size="sm" onClick={() => setAddOpen(true)}>
Add Member
</Button>
</div>
{actionError && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{actionError.message}
</div>
)}
{isLoading ? (
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<p className="py-6 text-sm text-danger">{(error as Error).message}</p>
) : !users || users.length === 0 ? (
<p className="py-6 text-sm text-text-secondary">No members yet.</p>
) : (
<Table>
<Thead>
<Tr>
<Th>Email</Th>
<Th>Role</Th>
<Th>Sign-in</Th>
<Th>Last login</Th>
<Th className="text-right">Actions</Th>
</Tr>
</Thead>
<Tbody>
{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 (
<Tr key={u.user_id}>
<Td>
<span className="font-medium">{u.email}</span>
{isSelf && <span className="ml-2 text-xs text-text-tertiary">(you)</span>}
</Td>
<Td>
{locked ? (
<Badge variant={roleVariant(u.role)}>{u.role}</Badge>
) : (
<select
value={u.role}
onChange={(e) => changeRole({ userId: u.user_id, next: e.target.value as Role })}
className="rounded-lg border border-border bg-surface-2 px-2 py-1 text-sm text-text-primary focus:border-accent/50 focus:outline-none"
>
{assignableRoles.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
)}
</Td>
<Td>
<Badge variant="neutral">
{u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"}
</Badge>
</Td>
<Td className="text-text-secondary">
{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}
</Td>
<Td className="text-right">
{managedByHQ ? (
hqUrl ? (
<a
href={hqUrl}
target="_blank"
rel="noreferrer"
className="text-xs text-text-secondary underline"
>
Managed in Vantage HQ
</a>
) : (
<span className="text-xs text-text-tertiary">Managed in Vantage HQ</span>
)
) : (
!locked && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
}}
>
Remove
</Button>
)
)}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
)}
<Modal open={addOpen} title="Add Member" onClose={() => setAddOpen(false)}>
<form
onSubmit={(e) => {
e.preventDefault();
createUser();
}}
className="space-y-4"
>
<Field label="Email">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputClass}
/>
</Field>
<Field
label="Password"
hint="Leave blank if this member will sign in through SSO instead."
>
<input
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={inputClass}
/>
</Field>
<Field label="Role">
<select value={role} onChange={(e) => setRole(e.target.value as Role)} className={inputClass}>
{assignableRoles.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</Field>
{createError && (
<div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(createError as Error).message}
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => setAddOpen(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" loading={creating}>
Add Member
</Button>
</div>
</form>
</Modal>
</Card>
);
}
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 (
<Card>
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
</Card>
);
}
const secretSet = cfg?.client_secret_set ?? false;
return (
<Card>
<div className="mb-4">
<h2 className="text-base font-semibold text-text-primary">Single Sign-On (OIDC)</h2>
<p className="mt-0.5 text-sm text-text-secondary">
Let members sign in with your identity provider. Users are provisioned into this instance on
first sign-in.
</p>
</div>
<div className="mb-5 rounded-lg border border-border bg-surface-2 p-3">
<p className="mb-2 text-xs font-medium text-text-secondary">
Register this redirect URL with your provider:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-background px-2 py-1.5 font-mono text-xs text-text-primary">
{redirectUrl}
</code>
<Button type="button" variant="ghost" size="sm" onClick={copyRedirect}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save();
}}
className="space-y-4"
>
<Field label="Issuer URL" hint="The provider's OIDC discovery base, e.g. https://accounts.google.com">
<input
type="url"
required
value={issuer}
onChange={(e) => setIssuer(e.target.value)}
className={inputClass}
/>
</Field>
<Field label="Client ID">
<input
type="text"
required
value={clientId}
onChange={(e) => setClientId(e.target.value)}
className={inputClass}
/>
</Field>
<Field
label="Client Secret"
hint={
secretSet
? "A secret is stored. Leave this blank to keep it, or enter a new one to replace it."
: "No secret stored yet."
}
>
<input
type="password"
autoComplete="new-password"
placeholder={secretSet ? "•••••••• (unchanged)" : "Enter client secret"}
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
className={inputClass}
/>
</Field>
<div className="flex items-center gap-2 text-sm">
<span className={`inline-block h-2 w-2 rounded-full ${secretSet ? "bg-success" : "bg-text-tertiary"}`} />
<span className="text-text-secondary">
{secretSet ? "Client secret is configured" : "No client secret configured"}
</span>
</div>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
Enable SSO sign-in for this instance
</label>
{enabled && !secretSet && !clientSecret && (
<div className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
SSO cannot complete sign-in without a client secret.
</div>
)}
{error && (
<div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(error as Error).message}
</div>
)}
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{saved ? "Saved!" : "Save SSO Settings"}
</Button>
{saved && <span className="text-sm text-success">SSO settings saved.</span>}
</div>
</form>
</Card>
);
}
export default function InstanceSettingsPage() {
const { instance, isAdmin } = useAuth();
if (!isAdmin) {
return (
<div className="p-8">
<Card className="max-w-lg">
<h1 className="text-base font-semibold text-text-primary">You don&apos;t have access</h1>
<p className="mt-1 text-sm text-text-secondary">
Instance settings are available to owners and admins only. Ask an administrator if you need
access.
</p>
</Card>
</div>
);
}
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-text-primary">Instance</h1>
<p className="mt-1 text-sm text-text-secondary">
{instance ? `Manage members and sign-in for ${instance.name}.` : "Manage members and sign-in."}
</p>
</div>
<div className="space-y-6">
<MembersCard />
<OIDCCard />
</div>
</div>
);
}
+68 -75
View File
@@ -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 (
<Card className={className}>
<div className="mb-4 flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg border border-border bg-surface-2 text-accent">{icon}</div>
<div>
<h2 className="text-base font-semibold text-text-primary">{title}</h2>
{description && <p className="mt-0.5 text-sm text-text-secondary">{description}</p>}
</div>
</div>
{children}
</Card>
);
}
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 (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
<section className="space-y-6">
<h2 className="flex items-center gap-3 border-b border-border-soft pb-2 font-mono text-[0.68rem] uppercase tracking-[0.15em] text-text-secondary">{label}</h2>
{children}
{hint && <p className="mt-1 text-xs text-text-tertiary">{hint}</p>}
</div>
</section>
);
}
@@ -102,7 +97,7 @@ function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedA
}
return (
<SectionCard title="Secrets Read Token (ESO)" description="Kubernetes External Secrets Operator authenticates to the read endpoint with this bearer token." icon={<KeyIcon />}>
<SectionCard title="Secrets read token (ESO)" description="Kubernetes External Secrets Operator authenticates to the read endpoint with this bearer token." icon={<KeyIcon />}>
<p className="mb-4 text-sm text-text-secondary">
Point your <span className="font-mono">ClusterSecretStore</span> at <span className="font-mono text-text-primary">{readUrl}</span>.
</p>
@@ -116,10 +111,10 @@ function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedA
</div>
{token && (
<div className="mb-4 rounded-lg border border-warning/30 bg-warning/10 p-3">
<div className="mb-4 rounded border border-warning/30 bg-warning/10 p-3">
<p className="mb-2 text-xs font-medium text-warning">Copy this token now it will not be shown again.</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">{token}</code>
<code className="flex-1 overflow-x-auto rounded bg-well px-2 py-1.5 font-mono text-xs text-text-primary">{token}</code>
<Button type="button" variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
@@ -128,7 +123,7 @@ function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedA
)}
<Button type="button" variant="primary" loading={isPending} onClick={() => rotate()}>
{tokenSet ? "Rotate Token" : "Generate Token"}
{tokenSet ? "Rotate token" : "Generate token"}
</Button>
{tokenSet && <p className="mt-2 text-xs text-text-tertiary">Rotating invalidates the previous token. Update the Kubernetes secret afterwards.</p>}
</SectionCard>
@@ -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 (
<div className="p-8">
<Card className="max-w-lg">
<h1 className="text-base font-semibold text-text-primary">You don&apos;t have access</h1>
<h1 className="text-base font-bold text-text-primary">You don&apos;t have access</h1>
<p className="mt-1 text-sm text-text-secondary">Settings are available to owners and admins only. Ask an administrator if you need access.</p>
</Card>
</div>
@@ -197,62 +192,60 @@ export default function SettingsPage() {
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-text-primary">Settings</h1>
<p className="mt-1 text-sm text-text-secondary">Configure monitoring, alerting, and integrations.</p>
<h1 className="text-2xl font-extrabold tracking-[-0.03em] text-text-primary">Settings</h1>
<p className="mt-1 text-sm text-text-secondary">
{instance ? `Members, sign-in and monitoring for ${instance.name}.` : "Members, sign-in and monitoring for this instance."}
</p>
</div>
<div className="space-y-6">
<SectionCard title="Alerting" description="Alerts are now delivered through notification channels, triggered by service monitors." icon={<BellIcon />}>
<div className="flex flex-wrap gap-3">
<Link href="/settings/notifications">
<Button variant="secondary">Manage Notification Channels</Button>
</Link>
<Link href="/monitors">
<Button variant="ghost">View Monitors</Button>
</Link>
</div>
<p className="mt-4 text-xs text-text-tertiary">
Webhook, email (SMTP), Discord, Slack, and Telegram destinations are configured under Notification Channels and attached per monitor.
</p>
</SectionCard>
<div className="space-y-10">
<Group label="Access">
<MembersCard />
<OIDCCard />
</Group>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SectionCard title="Server Health" description="When to consider an agent-backed server offline." icon={<ServerIcon />}>
<Field label="Offline threshold (minutes)" hint="How long a server must be silent before being marked offline. Agents poll every 30s, so 5 minutes is a safe minimum.">
<input
type="number"
min={1}
max={60}
value={thresholdMinutes}
onChange={(e) => 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"
/>
</Field>
</SectionCard>
<Group label="Monitoring">
<SectionCard title="Alerting" description="Alerts are delivered through notification channels, triggered by service monitors." icon={<BellIcon />}>
<div className="flex flex-wrap gap-3">
<Link href="/settings/notifications">
<Button variant="secondary">Manage notification channels</Button>
</Link>
<Link href="/monitors">
<Button variant="ghost">View monitors</Button>
</Link>
</div>
<p className="mt-4 text-xs text-text-tertiary">
Webhook, email (SMTP), Discord, Slack, and Telegram destinations are configured under Notification Channels and attached per monitor.
</p>
</SectionCard>
<SectionCard title="Workflow Logs" description="How long run logs are kept before automatic deletion." icon={<DocumentIcon />}>
<Field label="Log retention (days)" hint="0 = keep forever. Applies to per-run step output logs.">
<input
type="number"
min={0}
value={logRetentionDays}
onChange={(e) => 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"
/>
</Field>
</SectionCard>
</div>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SectionCard title="Server health" description="When to consider an agent-backed server offline." icon={<ServerIcon />}>
<Field label="Offline threshold (minutes)" hint="How long a server must be silent before being marked offline. Agents poll every 30s, so 5 minutes is a safe minimum.">
<input type="number" min={1} max={60} value={thresholdMinutes} onChange={(e) => setThresholdMinutes(Number(e.target.value))} className={numberInputClass} />
</Field>
</SectionCard>
<div className="mt-6 flex items-center gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{saved ? "Saved!" : "Save Settings"}
</Button>
{saved && <span className="text-sm text-success">Settings saved successfully.</span>}
</div>
</form>
<SectionCard title="Workflow logs" description="How long run logs are kept before automatic deletion." icon={<DocumentIcon />}>
<Field label="Log retention (days)" hint="0 = keep forever. Applies to per-run step output logs.">
<input type="number" min={0} value={logRetentionDays} onChange={(e) => setLogRetentionDays(Number(e.target.value))} className={numberInputClass} />
</Field>
</SectionCard>
</div>
<SecretsTokenCard tokenSet={settings?.secrets?.read_token_set ?? false} rotatedAt={settings?.secrets?.rotated_at} />
<div className="mt-6 flex items-center gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{saved ? "Saved!" : "Save settings"}
</Button>
{saved && <span className="text-sm text-success">Settings saved successfully.</span>}
</div>
</form>
</Group>
<Group label="Integrations">
<SecretsTokenCard tokenSet={settings?.secrets?.read_token_set ?? false} rotatedAt={settings?.secrets?.rotated_at} />
</Group>
</div>
</div>
);
-13
View File
@@ -120,18 +120,6 @@ function StepsIcon() {
);
}
function InstanceIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"
/>
</svg>
);
}
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
@@ -140,7 +128,6 @@ const navItems: NavItem[] = [
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings/instance", label: "Instance", icon: <InstanceIcon />, adminOnly: true },
{ href: "/settings/license", label: "Licence", icon: <LicenceIcon />, adminOnly: true },
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
];
+19
View File
@@ -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 (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
{children}
{hint && <p className="mt-1 text-xs text-text-tertiary">{hint}</p>}
</div>
);
}
+213
View File
@@ -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 (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
);
}
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<Role>("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 (
<SectionCard
title="Members"
description="People with access to this instance. Owners and admins can manage settings."
icon={<UsersIcon />}
actions={
<Button variant="primary" size="sm" onClick={() => setAddOpen(true)}>
Add member
</Button>
}
>
{actionError && <div className="mb-4 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{actionError.message}</div>}
{isLoading ? (
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<p className="py-6 text-sm text-danger">{(error as Error).message}</p>
) : !users || users.length === 0 ? (
<p className="py-6 text-sm text-text-secondary">No members yet.</p>
) : (
<Table>
<Thead>
<Tr>
<Th>Email</Th>
<Th>Role</Th>
<Th>Sign-in</Th>
<Th>Last login</Th>
<Th className="text-right">Actions</Th>
</Tr>
</Thead>
<Tbody>
{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 (
<Tr key={u.user_id}>
<Td>
<span className="font-medium">{u.email}</span>
{isSelf && <span className="ml-2 text-xs text-text-tertiary">(you)</span>}
</Td>
<Td>
{locked ? (
<Badge variant={roleVariant(u.role)}>{u.role}</Badge>
) : (
<select
value={u.role}
onChange={(e) => changeRole({ userId: u.user_id, next: e.target.value as Role })}
className="rounded border border-border bg-surface-2 px-2 py-1 text-sm text-text-primary focus:border-accent/50 focus:outline-none"
>
{assignableRoles.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
)}
</Td>
<Td>
<Badge variant="neutral">{u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"}</Badge>
</Td>
<Td className="text-text-secondary">{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}</Td>
<Td className="text-right">
{managedByHQ ? (
hqUrl ? (
<a href={hqUrl} target="_blank" rel="noreferrer" className="text-xs text-text-secondary underline">
Managed in Vantage HQ
</a>
) : (
<span className="text-xs text-text-tertiary">Managed in Vantage HQ</span>
)
) : (
!locked && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
}}
>
Remove
</Button>
)
)}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
)}
<Modal open={addOpen} title="Add member" onClose={() => setAddOpen(false)}>
<form
onSubmit={(e) => {
e.preventDefault();
createUser();
}}
className="space-y-4"
>
<Field label="Email">
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} className={inputClass} />
</Field>
<Field label="Password" hint="Leave blank if this member will sign in through SSO instead.">
<input type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} className={inputClass} />
</Field>
<Field label="Role">
<select value={role} onChange={(e) => setRole(e.target.value as Role)} className={inputClass}>
{assignableRoles.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</Field>
{createError && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(createError as Error).message}</div>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => setAddOpen(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" loading={creating}>
Add member
</Button>
</div>
</form>
</Modal>
</SectionCard>
);
}
+147
View File
@@ -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 (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.964 0a9 9 0 10-11.964 0m11.964 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
}
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 (
<Card>
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
</Card>
);
}
const secretSet = cfg?.client_secret_set ?? false;
return (
<SectionCard
title="Single sign-on (OIDC)"
description="Let members sign in with your identity provider. Users are provisioned into this instance on first sign-in."
icon={<IdentityIcon />}
>
<div className="mb-5 rounded border border-border bg-surface-2 p-3">
<p className="mb-2 text-xs font-medium text-text-secondary">Register this redirect URL with your provider:</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-well px-2 py-1.5 font-mono text-xs text-text-primary">{redirectUrl}</code>
<Button type="button" variant="ghost" size="sm" onClick={copyRedirect}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save();
}}
className="space-y-4"
>
<Field label="Issuer URL" hint="The provider's OIDC discovery base, e.g. https://accounts.google.com">
<input type="url" required value={issuer} onChange={(e) => setIssuer(e.target.value)} className={inputClass} />
</Field>
<Field label="Client ID">
<input type="text" required value={clientId} onChange={(e) => setClientId(e.target.value)} className={inputClass} />
</Field>
<Field
label="Client secret"
hint={secretSet ? "A secret is stored. Leave this blank to keep it, or enter a new one to replace it." : "No secret stored yet."}
>
<input
type="password"
autoComplete="new-password"
placeholder={secretSet ? "•••••••• (unchanged)" : "Enter client secret"}
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
className={inputClass}
/>
</Field>
<div className="flex items-center gap-2 text-sm">
<span className={`inline-block h-2 w-2 rounded-full ${secretSet ? "bg-success" : "bg-text-tertiary"}`} />
<span className="text-text-secondary">{secretSet ? "Client secret is configured" : "No client secret configured"}</span>
</div>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} className="h-4 w-4 rounded-sm border-border bg-surface-2 accent-accent" />
Enable SSO sign-in for this instance
</label>
{enabled && !secretSet && !clientSecret && (
<div className="rounded border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">SSO cannot complete sign-in without a client secret.</div>
)}
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(error as Error).message}</div>}
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{saved ? "Saved!" : "Save SSO settings"}
</Button>
{saved && <span className="text-sm text-success">SSO settings saved.</span>}
</div>
</form>
</SectionCard>
);
}
+37
View File
@@ -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 (
<Card className={className}>
<div className="mb-4 flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded border border-border bg-surface-2 text-accent">{icon}</div>
<div className="min-w-0 flex-1">
<h2 className="text-base font-bold tracking-[-0.02em] text-text-primary">{title}</h2>
{description && <p className="mt-0.5 text-sm text-text-secondary">{description}</p>}
</div>
{actions && <div className="flex-shrink-0">{actions}</div>}
</div>
{children}
</Card>
);
}
+8
View File
@@ -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 [
{