diff --git a/web/app/(app)/settings/page.tsx b/web/app/(app)/settings/page.tsx index c27b1f3..06a737c 100644 --- a/web/app/(app)/settings/page.tsx +++ b/web/app/(app)/settings/page.tsx @@ -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() {
- + { + if (!settings) return; + save({ + alerts: settings.alerts, + email: settings.email, + workflow_log_retention_days: settings.workflow_log_retention_days, + local_login_enabled: v, + }); + }} + /> diff --git a/web/components/settings/AuthProvidersCard.tsx b/web/components/settings/AuthProvidersCard.tsx new file mode 100644 index 0000000..e33b242 --- /dev/null +++ b/web/components/settings/AuthProvidersCard.tsx @@ -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 ( + + + + ); +} + +function CallbackRow({ url }: { url: string }) { + const [copied, setCopied] = useState(false); + return ( +
+ {url} + +
+ ); +} + +function AddProviderForm({ presets, onDone }: { presets: AuthPreset[]; onDone: () => void }) { + const [preset, setPreset] = useState("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 ( +
{ + e.preventDefault(); + create(); + }} + className="space-y-4 rounded border border-border bg-surface-2 p-4" + > + + + + + + setName(e.target.value)} placeholder={chosen?.label ?? ""} className={inputClass} /> + + + {chosen?.input_label && ( + + setIssuerInput(e.target.value)} className={inputClass} /> + + )} + + + setClientId(e.target.value)} className={inputClass} /> + + + + setClientSecret(e.target.value)} className={inputClass} /> + + + {error &&
{(error as Error).message}
} + +
+ + +
+

You will get a callback URL to register with your provider once it is added.

+
+ ); +} + +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[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 ( +
+
+ +
+

{p.name}

+

{p.issuer || "GitHub"}

+
+ +
+ + {p.callback_notice && ( +
+

This provider's callback URL has changed. Update it in your identity provider or sign-in will fail.

+ +
+ )} + +
+

Callback URL to register with this provider:

+ +
+ +
+
+ + setSecret(e.target.value)} className={inputClass} /> + +
+ +
+ + {testResult && ( +
+ {testResult.message} +
+ )} + + {error &&
{error.message}
} + +
+ + +
+
+ ); +} + +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 ( + +
+
+
+ + ); + } + + const list = providers ?? []; + const enabledCount = list.filter((p) => p.enabled).length; + + return ( + } + > +
+ {list.length === 0 &&

No providers configured. Members sign in with a password.

} + {list.map((p) => ( + + ))} +
+ +
+ {adding ? ( + { + setAdding(false); + queryClient.invalidateQueries({ queryKey: ["auth-providers"] }); + }} + /> + ) : ( + + )} +
+ +
+ +

+ {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."} +

+
+
+ ); +} diff --git a/web/components/settings/OIDCCard.tsx b/web/components/settings/OIDCCard.tsx deleted file mode 100644 index 6303740..0000000 --- a/web/components/settings/OIDCCard.tsx +++ /dev/null @@ -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 ( - - - - ); -} - -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 ( - -
-
-
- - ); - } - - const secretSet = cfg?.client_secret_set ?? false; - - return ( - } - > -
-

Register this redirect URL with your provider:

-
- {redirectUrl} - -
-
- -
{ - e.preventDefault(); - save(); - }} - className="space-y-4" - > - - setIssuer(e.target.value)} className={inputClass} /> - - - - setClientId(e.target.value)} className={inputClass} /> - - - - setClientSecret(e.target.value)} - className={inputClass} - /> - - -
- - {secretSet ? "Client secret is configured" : "No client secret configured"} -
- - - - {enabled && !secretSet && !clientSecret && ( -
SSO cannot complete sign-in without a client secret.
- )} - - {error &&
{(error as Error).message}
} - -
- - {saved && SSO settings saved.} -
-
-
- ); -} diff --git a/web/lib/api.ts b/web/lib/api.ts index 90ba052..5ec9fd5 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -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("/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 { return authRequest("/auth/providers"); @@ -544,14 +524,6 @@ export const api = { return request(`/instance/users/${userId}`, { method: "DELETE" }); }, - getInstanceOIDC(): Promise { - return request("/instance/oidc"); - }, - - saveInstanceOIDC(input: OrgOIDCInput): Promise<{ saved: boolean }> { - return request<{ saved: boolean }>("/instance/oidc", { method: "PUT", body: JSON.stringify(input) }); - }, - listAuthPresets(): Promise { return request("/auth/presets"); }, @@ -683,7 +655,12 @@ export const api = { return request("/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),