feat: render one login button per configured auth provider
This commit is contained in:
+95
-48
@@ -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<string, string> = {
|
||||
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<PublicProvider[]>([]);
|
||||
// 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 (
|
||||
<div className="relative flex min-h-screen items-center justify-center p-4">
|
||||
<NetworkBackground />
|
||||
@@ -57,56 +92,68 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(e) => 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 && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{ssoError}</div>}
|
||||
|
||||
{showLocal && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(error as Error).message}</div>}
|
||||
|
||||
<Button type="submit" variant="primary" loading={isPending} className="w-full justify-center">
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{showDivider && (
|
||||
<div className="my-5 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs uppercase tracking-wider text-text-tertiary">or</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => 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 && (
|
||||
<div className="space-y-2">
|
||||
{providers.map((p) => (
|
||||
<a key={p.id} href={auth.ssoStartUrl(p.id)} className="block">
|
||||
<Button type="button" variant="secondary" className="w-full justify-center gap-2">
|
||||
<ProviderIcon preset={p.preset} />
|
||||
Continue with {p.name}
|
||||
</Button>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(error as Error).message}</div>}
|
||||
|
||||
<Button type="submit" variant="primary" loading={isPending} className="w-full justify-center">
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="my-5 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs uppercase tracking-wider text-text-tertiary">or</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<a href="/auth/oidc/start" className="block">
|
||||
<Button type="button" variant="secondary" className="w-full justify-center">
|
||||
Sign in with your instance's SSO
|
||||
</Button>
|
||||
</a>
|
||||
<p className="mt-3 text-center text-xs text-text-tertiary">SSO must be enabled for this instance by an administrator.</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 2a10 10 0 00-3.16 19.49c.5.09.68-.22.68-.48v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.89 1.53 2.34 1.09 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.56-1.11-4.56-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02a9.5 9.5 0 015 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.69-4.57 4.94.36.31.68.92.68 1.85v2.74c0 .27.18.58.69.48A10 10 0 0012 2z" />
|
||||
</svg>
|
||||
);
|
||||
case "google":
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 11v2.6h6.2c-.25 1.6-1.87 4.7-6.2 4.7A6.3 6.3 0 1112 5.7c1.98 0 3.3.85 4.06 1.58l2.77-2.67A9.9 9.9 0 0012 2a10 10 0 100 20c5.77 0 9.6-4.06 9.6-9.77 0-.66-.07-1.16-.16-1.66H12z" />
|
||||
</svg>
|
||||
);
|
||||
case "entra":
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 2L3 19h5.4L12 12l3.6 7H21L12 2z" />
|
||||
</svg>
|
||||
);
|
||||
case "okta":
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="7" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.03 5.91l-1.72 1.72a.75.75 0 01-.53.22H9.75v1.5a.75.75 0 01-.75.75H7.5v1.5a.75.75 0 01-.75.75H4.5a.75.75 0 01-.75-.75v-2.19a.75.75 0 01.22-.53l5.11-5.11A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ProvidersResponse> {
|
||||
return authRequest<ProvidersResponse>("/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<AuthPreset[]> {
|
||||
return request<AuthPreset[]>("/auth/presets");
|
||||
},
|
||||
|
||||
listAuthProviders(): Promise<AuthProvider[]> {
|
||||
return request<AuthProvider[]>("/auth/providers");
|
||||
},
|
||||
|
||||
createAuthProvider(input: AuthProviderInput): Promise<AuthProvider> {
|
||||
return request<AuthProvider>("/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<Server[]> {
|
||||
return request<Server[]>("/servers");
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user