feat: manage multiple sign-in providers from settings

This commit is contained in:
2026-08-03 11:00:25 +01:00
parent 3a626922a5
commit 37f2c1457e
4 changed files with 296 additions and 179 deletions
+13 -2
View File
@@ -10,7 +10,7 @@ import { Field } from "@/components/settings/Field";
import { Group } from "@/components/settings/Group";
import { SectionCard } from "@/components/settings/SectionCard";
import { MembersCard } from "@/components/settings/MembersCard";
import { OIDCCard } from "@/components/settings/OIDCCard";
import { AuthProvidersCard } from "@/components/settings/AuthProvidersCard";
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";
@@ -191,7 +191,18 @@ export default function SettingsPage() {
<div className="space-y-10">
<Group label="Access">
<MembersCard />
<OIDCCard />
<AuthProvidersCard
localLoginEnabled={settings?.local_login_enabled ?? true}
onLocalLoginChange={(v) => {
if (!settings) return;
save({
alerts: settings.alerts,
email: settings.email,
workflow_log_retention_days: settings.workflow_log_retention_days,
local_login_enabled: v,
});
}}
/>
</Group>
<Group label="Monitoring">
@@ -0,0 +1,276 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type AuthProvider, type AuthPreset } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Field, inputClass } from "./Field";
import { SectionCard } from "./SectionCard";
import { ProviderIcon } from "./ProviderIcon";
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>
);
}
function CallbackRow({ url }: { url: string }) {
const [copied, setCopied] = useState(false);
return (
<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">{url}</code>
<Button
type="button"
variant="ghost"
size="sm"
onClick={async () => {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
);
}
function AddProviderForm({ presets, onDone }: { presets: AuthPreset[]; onDone: () => void }) {
const [preset, setPreset] = useState<string>("google");
const [name, setName] = useState("");
const [issuerInput, setIssuerInput] = useState("");
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const chosen = presets.find((p) => p.id === preset);
const { mutate: create, isPending, error } = useMutation({
mutationFn: () =>
api.createAuthProvider({
name: name || chosen?.label || "Single sign-on",
preset,
issuer_input: issuerInput,
client_id: clientId,
client_secret: clientSecret,
enabled: true,
}),
onSuccess: onDone,
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
create();
}}
className="space-y-4 rounded border border-border bg-surface-2 p-4"
>
<Field label="Provider type">
<select value={preset} onChange={(e) => setPreset(e.target.value)} className={inputClass}>
{presets.map((p) => (
<option key={p.id || "custom"} value={p.id}>
{p.label}
</option>
))}
</select>
</Field>
<Field label="Button label" hint="What members see on the sign-in page.">
<input type="text" required value={name} onChange={(e) => setName(e.target.value)} placeholder={chosen?.label ?? ""} className={inputClass} />
</Field>
{chosen?.input_label && (
<Field label={chosen.input_label} hint={chosen.input_hint}>
<input type="text" required value={issuerInput} onChange={(e) => setIssuerInput(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">
<input type="password" required autoComplete="new-password" value={clientSecret} onChange={(e) => setClientSecret(e.target.value)} className={inputClass} />
</Field>
{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}>
Add provider
</Button>
<Button type="button" variant="ghost" onClick={onDone}>
Cancel
</Button>
</div>
<p className="text-xs text-text-tertiary">You will get a callback URL to register with your provider once it is added.</p>
</form>
);
}
function ProviderRow({ p }: { p: AuthProvider }) {
const queryClient = useQueryClient();
const [secret, setSecret] = useState("");
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["auth-providers"] });
const { mutate: update, error: updateError } = useMutation({
mutationFn: (patch: Parameters<typeof api.updateAuthProvider>[1]) => api.updateAuthProvider(p.provider_id, patch),
onSuccess: () => {
setSecret("");
invalidate();
},
});
const { mutate: remove, error: deleteError } = useMutation({
mutationFn: () => api.deleteAuthProvider(p.provider_id),
onSuccess: invalidate,
});
const { mutate: test, isPending: testing } = useMutation({
mutationFn: () => api.testAuthProvider(p.provider_id),
onSuccess: setTestResult,
});
const { mutate: ack } = useMutation({
mutationFn: () => api.ackAuthProviderNotice(p.provider_id),
onSuccess: invalidate,
});
const error = (updateError ?? deleteError) as Error | null;
return (
<div className="space-y-3 rounded border border-border bg-surface-2 p-4">
<div className="flex items-center gap-3">
<ProviderIcon preset={p.preset} className="h-5 w-5 text-text-secondary" />
<div className="flex-1">
<p className="text-sm font-medium text-text-primary">{p.name}</p>
<p className="text-xs text-text-tertiary">{p.issuer || "GitHub"}</p>
</div>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input
type="checkbox"
checked={p.enabled}
onChange={(e) => update({ enabled: e.target.checked })}
className="h-4 w-4 rounded-sm border-border bg-surface-2 accent-accent"
/>
Enabled
</label>
</div>
{p.callback_notice && (
<div className="space-y-2 rounded border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
<p>This provider&apos;s callback URL has changed. Update it in your identity provider or sign-in will fail.</p>
<Button type="button" variant="ghost" size="sm" onClick={() => ack()}>
I&apos;ve updated it
</Button>
</div>
)}
<div>
<p className="mb-1.5 text-xs font-medium text-text-secondary">Callback URL to register with this provider:</p>
<CallbackRow url={p.callback_url} />
</div>
<div className="flex items-end gap-2">
<div className="flex-1">
<Field label="Replace client secret" hint={p.client_secret_set ? "A secret is stored. Leave blank to keep it." : "No secret stored."}>
<input type="password" autoComplete="new-password" value={secret} onChange={(e) => setSecret(e.target.value)} className={inputClass} />
</Field>
</div>
<Button type="button" variant="secondary" disabled={!secret} onClick={() => update({ client_secret: secret })}>
Save
</Button>
</div>
{testResult && (
<div className={`rounded border px-3 py-2 text-sm ${testResult.ok ? "border-success/30 bg-success/10 text-success" : "border-danger/30 bg-danger/10 text-danger"}`}>
{testResult.message}
</div>
)}
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error.message}</div>}
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="sm" loading={testing} onClick={() => test()}>
Test connection
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => remove()}>
Remove
</Button>
</div>
</div>
);
}
export function AuthProvidersCard({ localLoginEnabled, onLocalLoginChange }: { localLoginEnabled: boolean; onLocalLoginChange: (v: boolean) => void }) {
const queryClient = useQueryClient();
const [adding, setAdding] = useState(false);
const { data: providers, isLoading } = useQuery({ queryKey: ["auth-providers"], queryFn: api.listAuthProviders });
const { data: presets } = useQuery({ queryKey: ["auth-presets"], queryFn: api.listAuthPresets });
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 list = providers ?? [];
const enabledCount = list.filter((p) => p.enabled).length;
return (
<SectionCard
title="Sign-in providers"
description="Members can sign in with any provider you add here. Users are provisioned into this instance on first sign-in."
icon={<IdentityIcon />}
>
<div className="space-y-3">
{list.length === 0 && <p className="text-sm text-text-tertiary">No providers configured. Members sign in with a password.</p>}
{list.map((p) => (
<ProviderRow key={p.provider_id} p={p} />
))}
</div>
<div className="mt-4">
{adding ? (
<AddProviderForm
presets={presets ?? []}
onDone={() => {
setAdding(false);
queryClient.invalidateQueries({ queryKey: ["auth-providers"] });
}}
/>
) : (
<Button type="button" variant="secondary" onClick={() => setAdding(true)}>
Add provider
</Button>
)}
</div>
<div className="mt-6 border-t border-border pt-4">
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input
type="checkbox"
checked={localLoginEnabled}
disabled={enabledCount === 0}
onChange={(e) => onLocalLoginChange(e.target.checked)}
className="h-4 w-4 rounded-sm border-border bg-surface-2 accent-accent"
/>
Allow email and password sign-in
</label>
<p className="mt-1.5 text-xs text-text-tertiary">
{enabledCount === 0
? "Enable at least one provider before turning this off, or nobody could sign in."
: "Turn this off to require members to use a provider above."}
</p>
</div>
</SectionCard>
);
}
-147
View File
@@ -1,147 +0,0 @@
"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>
);
}
+7 -30
View File
@@ -190,6 +190,7 @@ export interface Settings {
email: EmailSettings;
secrets: SecretsSettings;
workflow_log_retention_days?: number | null;
local_login_enabled?: boolean;
}
export interface SecretGroupSummary {
@@ -349,21 +350,6 @@ export interface OrgUserInput {
role: Role;
}
export interface OrgOIDCConfig {
issuer?: string;
client_id?: string;
enabled: boolean;
client_secret_set: boolean;
updated_at?: string;
}
export interface OrgOIDCInput {
issuer: string;
client_id: string;
client_secret: string;
enabled: boolean;
}
export interface PublicProvider {
id: string;
name: string;
@@ -507,12 +493,6 @@ export const auth = {
return authRequest<MeResponse>("/auth/me");
},
/** The URL an admin must register with their OIDC provider. */
oidcRedirectUrl(): string {
if (typeof window === "undefined") return "/auth/oidc/callback";
return `${window.location.origin}/auth/oidc/callback`;
},
/** Unauthenticated: what the login page draws itself from. */
providers(): Promise<ProvidersResponse> {
return authRequest<ProvidersResponse>("/auth/providers");
@@ -544,14 +524,6 @@ export const api = {
return request<void>(`/instance/users/${userId}`, { method: "DELETE" });
},
getInstanceOIDC(): Promise<OrgOIDCConfig> {
return request<OrgOIDCConfig>("/instance/oidc");
},
saveInstanceOIDC(input: OrgOIDCInput): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>("/instance/oidc", { method: "PUT", body: JSON.stringify(input) });
},
listAuthPresets(): Promise<AuthPreset[]> {
return request<AuthPreset[]>("/auth/presets");
},
@@ -683,7 +655,12 @@ export const api = {
return request<Settings>("/settings");
},
saveSettings(settings: { alerts: AlertSettings; email: EmailSettings; workflow_log_retention_days?: number | null }): Promise<{ saved: boolean }> {
saveSettings(settings: {
alerts: AlertSettings;
email: EmailSettings;
workflow_log_retention_days?: number | null;
local_login_enabled?: boolean;
}): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>("/settings", {
method: "PUT",
body: JSON.stringify(settings),