diff --git a/create-a-container/client/src/app/Sidebar.tsx b/create-a-container/client/src/app/Sidebar.tsx
index b69533eb..3c1022b7 100644
--- a/create-a-container/client/src/app/Sidebar.tsx
+++ b/create-a-container/client/src/app/Sidebar.tsx
@@ -16,12 +16,10 @@ import {
Box,
Building2,
Container as ContainerIcon,
- ExternalLink,
Globe,
KeyRound,
Server,
Settings,
- ShieldCheck,
Users,
UsersRound,
} from 'lucide-react';
@@ -68,8 +66,6 @@ export function AppSidebar() {
const { data: session } = useSession();
const { isCollapsed, isMobileViewport } = useSidebar();
const isAdmin = !!session?.isAdmin;
- const mfaAdminUrl =
- isAdmin && session?.pushNotificationUrl ? `${session.pushNotificationUrl}/admin` : null;
const siteMatch = location.pathname.match(/^\/sites\/(\d+)(?:\/|$)/);
const urlSiteId = siteMatch ? siteMatch[1] : null;
@@ -188,16 +184,6 @@ export function AppSidebar() {
)}
{ADMIN.filter((l) => !l.adminOnly || isAdmin).map(renderLink)}
- {mfaAdminUrl && (
- }
- badge={compact ? undefined : }
- isActive={false}
- onClick={() => window.open(mfaAdminUrl, '_blank', 'noopener,noreferrer')}
- />
- )}
diff --git a/create-a-container/client/src/lib/auth.ts b/create-a-container/client/src/lib/auth.ts
index 8f3dcd19..9f846072 100644
--- a/create-a-container/client/src/lib/auth.ts
+++ b/create-a-container/client/src/lib/auth.ts
@@ -4,13 +4,13 @@ import { api, ApiError, clearCsrfToken } from './api';
export interface SessionUser {
user: string;
isAdmin: boolean;
- /** Configured push-notification service URL (admins only, empty if unset). */
- pushNotificationUrl?: string;
}
export interface ServerInfo {
status: string;
isDev: boolean;
+ /** True when an OIDC identity provider is configured for SSO. */
+ oidcEnabled: boolean;
}
export const sessionKey = ['session'] as const;
@@ -47,15 +47,12 @@ export interface LoginInput {
}
export type LoginResult =
- | { kind: 'logged-in'; user: string; isAdmin: boolean; redirect: string }
- | { kind: '2fa'; challengeId: string };
+ | { kind: 'logged-in'; user: string; isAdmin: boolean; redirect: string };
interface LoginResponse {
user?: string;
isAdmin?: boolean;
redirect?: string;
- challengeId?: string;
- requires2FA?: boolean;
}
export function useLoginMutation() {
@@ -63,9 +60,6 @@ export function useLoginMutation() {
return useMutation({
mutationFn: async (input) => {
const data = await api.post('/api/v1/auth/login', input);
- if (data.requires2FA && data.challengeId) {
- return { kind: '2fa', challengeId: data.challengeId };
- }
return {
kind: 'logged-in',
user: data.user || input.username,
@@ -84,34 +78,40 @@ export function useLoginMutation() {
isAdmin: result.isAdmin,
});
// Refetch from the server so the cached session reflects the
- // authoritative state (including pushNotificationUrl) before the
- // caller navigates into a guarded route.
+ // authoritative state before the caller navigates into a guarded route.
await qc.refetchQueries({ queryKey: sessionKey });
}
},
});
}
-export interface ChallengeStatus {
- status: 'pending' | 'approved' | 'rejected' | 'timeout' | 'failed' | 'unregistered';
- user?: string;
- isAdmin?: boolean;
- redirect?: string;
- message?: string;
- registrationUrl?: string;
-}
-
-export async function fetchChallenge(id: string): Promise {
- return api.get(`/api/v1/auth/login/challenge/${encodeURIComponent(id)}`);
-}
-
export function useLogoutMutation() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => {
- await api.post('/api/v1/auth/logout');
+ // When OIDC SSO is enabled the server returns a `logoutUrl` pointing at
+ // the IdP's end-session endpoint. We must visit it to terminate the IdP
+ // session; otherwise the live IdP session signs the user straight back in.
+ const data = await api.post<{ loggedOut: boolean; logoutUrl?: string | null }>(
+ '/api/v1/auth/logout',
+ );
+
+ // If we have an IdP logout URL, hand off to the browser *before* touching
+ // the query cache. Clearing the cache here would synchronously re-render
+ // guarded views and bounce the user to /login, whose own effect kicks off
+ // a fresh SSO redirect — racing (and beating) this navigation. Assigning
+ // first makes RP-initiated logout the only navigation that happens.
+ if (data?.logoutUrl) {
+ window.location.assign(data.logoutUrl);
+ // Block further React work this tick; the page is being replaced.
+ await new Promise(() => {});
+ }
+
+ return data;
},
- onSettled: () => {
+ onSettled: (data) => {
+ // Reached only for the local-only logout path (no IdP end-session URL).
+ if (data?.logoutUrl) return;
clearCsrfToken();
qc.setQueryData(sessionKey, null);
qc.clear();
diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts
index b9f9c0df..fc4ce818 100644
--- a/create-a-container/client/src/lib/types.ts
+++ b/create-a-container/client/src/lib/types.ts
@@ -136,7 +136,6 @@ export interface User {
status: 'pending' | 'active' | 'disabled';
groups?: { gidNumber: number; cn: string; isAdmin: boolean }[];
isAdmin: boolean;
- twoFactorWarning?: string;
}
export interface Group {
@@ -161,9 +160,6 @@ export interface ApiKeyCreated extends ApiKey {
}
export interface AppSettings {
- pushNotificationUrl: string;
- pushNotificationEnabled: boolean;
- pushNotificationApiKey: string;
smtpUrl: string;
smtpNoreplyAddress: string;
defaultContainerEnvVars: { key: string; value: string; description?: string }[];
diff --git a/create-a-container/client/src/pages/auth/LoginPage.tsx b/create-a-container/client/src/pages/auth/LoginPage.tsx
index 177d9ecd..50dd9e62 100644
--- a/create-a-container/client/src/pages/auth/LoginPage.tsx
+++ b/create-a-container/client/src/pages/auth/LoginPage.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -10,21 +10,10 @@ import {
Button,
Input,
Spinner,
- usePrefersReducedMotion,
} from '@mieweb/ui';
-import {
- ShieldCheck,
- Smartphone,
- AlertTriangle,
- XCircle,
- Eye,
- EyeOff,
- Lock,
-} from 'lucide-react';
-import { useLoginMutation, useDevLoginMutation, useServerInfo, useSession, fetchChallenge, sessionKey, type ChallengeStatus, type SessionUser } from '@/lib/auth';
-import { ApiError, clearCsrfToken } from '@/lib/api';
+import { ShieldCheck, Eye, EyeOff, Lock } from 'lucide-react';
+import { useLoginMutation, useDevLoginMutation, useServerInfo, useSession } from '@/lib/auth';
import { useDocumentTitle } from '@/lib/useDocumentTitle';
-import { useQueryClient } from '@tanstack/react-query';
const schema = z.object({
username: z.string().min(1, 'Username is required'),
@@ -32,8 +21,15 @@ const schema = z.object({
});
type FormData = z.infer;
-const POLL_INTERVAL_MS = 2000;
-const POLL_MAX_MS = 5 * 60 * 1000;
+// Human-readable messages for OIDC callback failures surfaced via ?oidc_error.
+const OIDC_ERROR_MESSAGES: Record = {
+ expired: 'Your sign-in session expired before it completed. Please try again.',
+ exchange_failed: 'We could not complete sign-in with your identity provider. Please try again.',
+ provisioning_failed: 'Sign-in succeeded but your account could not be prepared. Contact an administrator.',
+ no_account: 'No matching account was found for your identity. Contact an administrator for access.',
+ missing_email: 'Your identity provider did not share an email address, which is required to sign in.',
+ account_inactive: 'Your account is not active. Contact an administrator.',
+};
// A redirect target is "external" when it parses as an absolute http(s) URL.
// react-router's navigate() treats such strings as in-app paths and mangles
@@ -56,22 +52,22 @@ function asExternalUrl(target: string): string | null {
export function LoginPage() {
useDocumentTitle('Sign in');
const navigate = useNavigate();
- const qc = useQueryClient();
const [params] = useSearchParams();
const redirect = params.get('redirect') || '/';
+ const oidcError = params.get('oidc_error');
+ // Set when the user just signed out (locally, or via the IdP's post-logout
+ // redirect). Suppresses the automatic SSO redirect so logout doesn't loop
+ // straight back into a new sign-in.
+ const loggedOut = params.get('logged_out') !== null;
const login = useLoginMutation();
const devLogin = useDevLoginMutation();
- const { data: serverInfo } = useServerInfo();
+ const { data: serverInfo, isLoading: serverInfoLoading } = useServerInfo();
const isDev = !!serverInfo?.isDev;
+ const oidcEnabled = !!serverInfo?.oidcEnabled;
const { data: session, isLoading: sessionLoading } = useSession();
- const [challengeId, setChallengeId] = useState(null);
- const [challenge, setChallenge] = useState(null);
const [showPassword, setShowPassword] = useState(false);
const [capsLock, setCapsLock] = useState(false);
- const pollTimer = useRef(null);
- const pollStart = useRef(0);
- const approvedHandled = useRef(false);
const {
register,
@@ -91,92 +87,32 @@ export function LoginPage() {
}
};
- useEffect(() => {
- return () => {
- if (pollTimer.current) window.clearTimeout(pollTimer.current);
- };
- }, []);
-
// Already-authenticated users shouldn't see the login form: send them to
- // their intended destination (or home). Guarded by !challengeId so we don't
- // pre-empt an in-progress 2FA flow on this page.
+ // their intended destination (or home).
useEffect(() => {
- if (!sessionLoading && session && !challengeId) {
+ if (!sessionLoading && session) {
goTo(redirect);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [session, sessionLoading, challengeId, redirect]);
+ }, [session, sessionLoading, redirect]);
- function startPolling(id: string) {
- pollStart.current = Date.now();
- approvedHandled.current = false;
- const poll = async () => {
- try {
- const status = await fetchChallenge(id);
- if (status.status === 'approved') {
- // The challenge is single-use: the server activates the session and
- // deletes the challenge on the first 'approved' response. Guard so a
- // second in-flight poll can't re-run this (a repeat fetch would 404
- // and surface as a spurious failure).
- if (approvedHandled.current) return;
- approvedHandled.current = true;
- // New authenticated session: drop any pre-login CSRF token so the
- // next mutation fetches one bound to it, avoiding a reactive 403.
- clearCsrfToken();
- // The server has already saved the session, so seed the cache as the
- // authoritative state. Navigate immediately — RequireAuth reads this
- // cached session and lets us through. We intentionally do NOT block
- // navigation on a refetch: a transient refetch failure/race must not
- // bounce the now-authenticated user back to the login screen.
- qc.setQueryData(sessionKey, {
- user: status.user || '',
- isAdmin: !!status.isAdmin,
- });
- void qc.invalidateQueries({ queryKey: sessionKey });
- goTo(status.redirect && status.redirect !== '/' ? status.redirect : redirect);
- return;
- }
- // Only surface non-approved statuses (keeps an 'approved' status from
- // ever rendering through the error/fallback view).
- setChallenge(status);
- if (
- status.status === 'rejected' ||
- status.status === 'timeout' ||
- status.status === 'failed' ||
- status.status === 'unregistered'
- ) {
- return;
- }
- if (Date.now() - pollStart.current > POLL_MAX_MS) {
- setChallenge({ status: 'timeout', message: 'Challenge expired' });
- return;
- }
- pollTimer.current = window.setTimeout(poll, POLL_INTERVAL_MS);
- } catch (err) {
- // If we've already handled approval and navigated, ignore late errors
- // from any straggling poll (e.g. a 404 for the now-deleted challenge).
- if (approvedHandled.current) return;
- setChallenge({
- status: 'failed',
- message: err instanceof ApiError ? err.message : 'Failed to check challenge',
- });
- }
- };
- poll();
- }
+ // When an identity provider is configured, the login screen automatically
+ // redirects to it. We only auto-redirect once we know there's no active
+ // session and the previous attempt didn't fail (avoids a redirect loop), and
+ // not immediately after an explicit logout (so sign-out doesn't loop back in).
+ const shouldAutoRedirectToIdp =
+ oidcEnabled && !oidcError && !loggedOut && !sessionLoading && !session;
+ useEffect(() => {
+ if (shouldAutoRedirectToIdp) {
+ const url = `/api/v1/auth/oidc/login?redirect=${encodeURIComponent(redirect)}`;
+ window.location.assign(url);
+ }
+ }, [shouldAutoRedirectToIdp, redirect]);
const onSubmit = handleSubmit(async (values) => {
- setChallenge(null);
- setChallengeId(null);
try {
const result = await login.mutateAsync({ ...values, redirect });
- if (result.kind === 'logged-in') {
- goTo(result.redirect && result.redirect !== '/' ? result.redirect : redirect);
- } else {
- setChallengeId(result.challengeId);
- setChallenge({ status: 'pending' });
- startPolling(result.challengeId);
- }
+ goTo(result.redirect && result.redirect !== '/' ? result.redirect : redirect);
} catch {
/* error handled via login.error */
}
@@ -198,13 +134,9 @@ export function LoginPage() {
? 'Invalid username or password'
: null;
- if (challengeId && challenge) {
- return setChallengeId(null)} />;
- }
-
- // Avoid flashing the form while we resolve the session / redirect an
- // already-authenticated user.
- if (sessionLoading || (session && !challengeId)) {
+ // Avoid flashing the form while we resolve the session / server info, or
+ // while we hand off to the identity provider.
+ if (sessionLoading || serverInfoLoading || (session && !sessionLoading) || shouldAutoRedirectToIdp) {
return (
@@ -212,6 +144,58 @@ export function LoginPage() {
);
}
+ // OIDC is configured but we landed back here with an error (or after a
+ // failed attempt). Internal password login is disabled, so offer a retry.
+ if (oidcEnabled) {
+ return (
+
+
+
+ Sign in
+
+
+ This site uses single sign-on through your identity provider.
+
+
+
+ {oidcError && (
+
+ Sign in failed
+
+ {OIDC_ERROR_MESSAGES[oidcError] || 'Sign-in could not be completed. Please try again.'}
+
+
+ )}
+
+ {!oidcError && loggedOut && (
+
+ Signed out
+
+ You have been signed out. Use single sign-on to sign back in.
+
+
+ )}
+
+
+
+ );
+ }
+
const passwordField = register('password');
return (
@@ -361,104 +345,9 @@ export function LoginPage() {
- Protected by push-approved sign-in.{' '}
+ Secured by your organization’s sign-in policy.{' '}