refactor(web): members and SSO move onto the settings page
Server Deploy / deploy (push) Successful in 1m12s
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:
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user