From 3a626922a5a75134fc001ce46236f26f636321f3 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 3 Aug 2026 10:56:03 +0100 Subject: [PATCH] feat: render one login button per configured auth provider --- web/app/login/page.tsx | 143 +++++++++++++++-------- web/components/settings/ProviderIcon.tsx | 40 +++++++ web/lib/api.ts | 93 +++++++++++++++ 3 files changed, 228 insertions(+), 48 deletions(-) create mode 100644 web/components/settings/ProviderIcon.tsx diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx index cb149a5..3b49df2 100644 --- a/web/app/login/page.tsx +++ b/web/app/login/page.tsx @@ -2,15 +2,42 @@ import { useEffect, useState } from "react"; import { useMutation } from "@tanstack/react-query"; -import { auth } from "@/lib/api"; +import { auth, type PublicProvider } from "@/lib/api"; import { Button, Card } from "@/components/ui"; import { Logo } from "@/components/Logo"; import { NetworkBackground } from "@/components/NetworkBackground"; +import { ProviderIcon } from "@/components/settings/ProviderIcon"; + +const ERROR_MESSAGES: Record = { + oidc_unavailable: "Single sign-on is not available on this instance's plan.", + provider_unavailable: "That sign-in method is no longer available.", + provider_unreachable: "Could not reach the identity provider.", + invalid_state: "That sign-in attempt expired. Please try again.", + exchange_failed: "The identity provider rejected the sign-in.", + verification_failed: "The identity provider's response could not be verified.", + missing_id_token: "The identity provider returned no identity token.", + missing_email: "The identity provider returned no email address.", + identity_failed: "Could not read a verified email address from that account.", + provisioning_failed: "Could not create your account on this instance.", + session_failed: "Could not start your session. Please try again.", + state_failed: "Could not start sign-in. Please try again.", + unknown_host: "This address does not name a known instance.", +}; export default function LoginPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [instanceName, setInstanceName] = useState(""); + const [providers, setProviders] = useState([]); + // Default true so a slow or failing discovery call still renders something + // usable rather than an empty card. + const [localEnabled, setLocalEnabled] = useState(true); + const [ssoError, setSsoError] = useState(""); + + useEffect(() => { + const code = new URLSearchParams(window.location.search).get("error"); + if (code) setSsoError(ERROR_MESSAGES[code] ?? "Sign-in failed. Please try again."); + }, []); useEffect(() => { (async () => { @@ -22,6 +49,11 @@ export default function LoginPage() { } if (s.instance_name) setInstanceName(s.instance_name); } catch {} + try { + const p = await auth.providers(); + setProviders(p.providers); + setLocalEnabled(p.local_enabled); + } catch {} try { await auth.me(); window.location.href = "/"; @@ -45,6 +77,9 @@ export default function LoginPage() { signIn(); } + const showLocal = localEnabled || providers.length === 0; + const showDivider = showLocal && providers.length > 0; + return (
@@ -57,56 +92,68 @@ export default function LoginPage() {
-
-
- - setEmail(e.target.value)} - className="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" - /> + {ssoError &&
{ssoError}
} + + {showLocal && ( + +
+ + setEmail(e.target.value)} + className="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" + /> +
+ +
+ + setPassword(e.target.value)} + className="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" + /> +
+ + {error &&
{(error as Error).message}
} + + + + )} + + {showDivider && ( +
+
+ or +
+ )} -
- - setPassword(e.target.value)} - className="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" - /> + {providers.length > 0 && ( +
+ {providers.map((p) => ( + + + + ))}
- - {error &&
{(error as Error).message}
} - - - - -
-
- or -
-
- - - - -

SSO must be enabled for this instance by an administrator.

+ )}
diff --git a/web/components/settings/ProviderIcon.tsx b/web/components/settings/ProviderIcon.tsx new file mode 100644 index 0000000..ed2e38a --- /dev/null +++ b/web/components/settings/ProviderIcon.tsx @@ -0,0 +1,40 @@ +/** + * One glyph per preset, plus a neutral key for anything custom. These are + * simplified marks drawn with currentColor rather than brand logos: a brand + * logo carries usage terms, and currentColor is what lets a button match the + * text beside it. + */ +export function ProviderIcon({ preset, className = "h-4 w-4" }: { preset: string; className?: string }) { + switch (preset) { + case "github": + return ( + + ); + case "google": + return ( + + ); + case "entra": + return ( + + ); + case "okta": + return ( + + ); + default: + return ( + + ); + } +} diff --git a/web/lib/api.ts b/web/lib/api.ts index 54063e7..90ba052 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -364,6 +364,61 @@ export interface OrgOIDCInput { enabled: boolean; } +export interface PublicProvider { + id: string; + name: string; + preset: string; +} + +export interface ProvidersResponse { + local_enabled: boolean; + providers: PublicProvider[]; +} + +export interface AuthProvider { + provider_id: string; + instance_id: string; + name: string; + kind: "oidc" | "oauth2"; + preset: string; + issuer: string; + client_id: string; + scopes: string[]; + enabled: boolean; + callback_notice: boolean; + order: number; + created_at: string; + updated_at: string; + client_secret_set: boolean; + callback_url: string; +} + +export interface AuthPreset { + id: string; + label: string; + kind: "oidc" | "oauth2"; + input_label: string; + input_hint: string; +} + +export interface AuthProviderInput { + name: string; + preset: string; + issuer_input?: string; + client_id: string; + client_secret: string; + enabled: boolean; +} + +export interface AuthProviderUpdate { + name?: string; + issuer_input?: string; + client_id?: string; + client_secret?: string; + enabled?: boolean; + order?: number; +} + class ApiError extends Error { constructor( public status: number, @@ -457,6 +512,16 @@ export const auth = { 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"); + }, + + /** Where a provider button sends the browser. */ + ssoStartUrl(providerId: string): string { + return `/auth/oidc/${providerId}/start`; + }, }; export const api = { @@ -487,6 +552,34 @@ export const api = { return request<{ saved: boolean }>("/instance/oidc", { method: "PUT", body: JSON.stringify(input) }); }, + listAuthPresets(): Promise { + return request("/auth/presets"); + }, + + listAuthProviders(): Promise { + return request("/auth/providers"); + }, + + createAuthProvider(input: AuthProviderInput): Promise { + return request("/auth/providers", { method: "POST", body: JSON.stringify(input) }); + }, + + updateAuthProvider(id: string, input: AuthProviderUpdate): Promise<{ saved: boolean }> { + return request<{ saved: boolean }>(`/auth/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }); + }, + + deleteAuthProvider(id: string): Promise<{ deleted: boolean }> { + return request<{ deleted: boolean }>(`/auth/providers/${id}`, { method: "DELETE" }); + }, + + testAuthProvider(id: string): Promise<{ ok: boolean; message: string }> { + return request<{ ok: boolean; message: string }>(`/auth/providers/${id}/test`, { method: "POST" }); + }, + + ackAuthProviderNotice(id: string): Promise<{ acknowledged: boolean }> { + return request<{ acknowledged: boolean }>(`/auth/providers/${id}/ack-notice`, { method: "POST" }); + }, + listServers(): Promise { return request("/servers"); },