feat: manage multiple sign-in providers from settings
This commit is contained in:
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user