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:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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't have access</h1>
|
||||
<h1 className="text-base font-bold text-text-primary">You don'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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user