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}
+ {
+ await navigator.clipboard.writeText(url);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }}
+ >
+ {copied ? "Copied!" : "Copy"}
+
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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"}
+
+
+ update({ enabled: e.target.checked })}
+ className="h-4 w-4 rounded-sm border-border bg-surface-2 accent-accent"
+ />
+ Enabled
+
+
+
+ {p.callback_notice && (
+
+
This provider's callback URL has changed. Update it in your identity provider or sign-in will fail.
+
ack()}>
+ I've updated it
+
+
+ )}
+
+
+
Callback URL to register with this provider:
+
+
+
+
+
+
+ setSecret(e.target.value)} className={inputClass} />
+
+
+
update({ client_secret: secret })}>
+ Save
+
+
+
+ {testResult && (
+
+ {testResult.message}
+
+ )}
+
+ {error &&
{error.message}
}
+
+
+ test()}>
+ Test connection
+
+ remove()}>
+ Remove
+
+
+
+ );
+}
+
+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"] });
+ }}
+ />
+ ) : (
+ setAdding(true)}>
+ Add provider
+
+ )}
+
+
+
+
+ onLocalLoginChange(e.target.checked)}
+ className="h-4 w-4 rounded-sm border-border bg-surface-2 accent-accent"
+ />
+ Allow email and password sign-in
+
+
+ {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}
-
- {copied ? "Copied!" : "Copy"}
-
-
-
-
-
-
- );
-}
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),