feat: enhance authentication and password reset flows with new components and layout
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Replaced LoginForm with LoginPageContent for improved login handling and user experience. - Introduced ResetPasswordPage and ResetPasswordLayout components to facilitate password reset functionality. - Added new flow stages for authentication, including PasswordStage and SourceOAuthStage, to streamline user interactions. - Updated FlowChallengeForm to integrate new stages and improve error handling during authentication processes. - Refactored existing components to support the new authentication flow structure, enhancing maintainability and user experience.
This commit is contained in:
parent
496b1dfc1f
commit
de5b5a60ef
@ -2,7 +2,7 @@
|
||||
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
import { LoginForm } from "@/components/auth/login-form"
|
||||
import { LoginPageContent } from "@/components/auth/login-page-content"
|
||||
import { useNativeRuntime } from "@/lib/platform"
|
||||
import { NativeLogin } from "@/components/mobile/native-login"
|
||||
|
||||
@ -10,22 +10,12 @@ function LoginContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const error = searchParams.get("error")
|
||||
const returnTo = searchParams.get("returnTo") ?? "/mail/inbox"
|
||||
const loginHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
|
||||
const signupHref = `/signup?returnTo=${encodeURIComponent(returnTo)}`
|
||||
const forgotPasswordHref = `/forgot-password?returnTo=${encodeURIComponent(returnTo)}`
|
||||
|
||||
if (useNativeRuntime()) {
|
||||
return <NativeLogin returnTo={returnTo} />
|
||||
}
|
||||
|
||||
return (
|
||||
<LoginForm
|
||||
loginHref={loginHref}
|
||||
signupHref={signupHref}
|
||||
forgotPasswordHref={forgotPasswordHref}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
return <LoginPageContent returnTo={returnTo} error={error} />
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
|
||||
16
app/reset-password/layout.tsx
Normal file
16
app/reset-password/layout.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { LoginChrome } from "@/components/auth/login-chrome"
|
||||
import type { Metadata } from "next"
|
||||
import { suitePageMetadata } from "@/lib/suite/page-metadata"
|
||||
|
||||
export const metadata: Metadata = suitePageMetadata({
|
||||
app: "suite",
|
||||
title: "Réinitialiser le mot de passe",
|
||||
})
|
||||
|
||||
export default function ResetPasswordLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <LoginChrome>{children}</LoginChrome>
|
||||
}
|
||||
20
app/reset-password/page.tsx
Normal file
20
app/reset-password/page.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
import { ResetPasswordPageContent } from "@/components/auth/reset-password-page-content"
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const flowQuery = searchParams.toString()
|
||||
|
||||
return <ResetPasswordPageContent flowQuery={flowQuery || undefined} />
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@ -1,12 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { AuthCard } from "@/components/auth/auth-card"
|
||||
import { AuthConnectButton } from "@/components/auth/auth-connect-button"
|
||||
import { FlowChallengeForm } from "@/components/auth/flow-challenge-form"
|
||||
import {
|
||||
AUTH_FLOW_SLUGS,
|
||||
isAuthenticationFlow,
|
||||
type AuthFlowSlug,
|
||||
} from "@/lib/auth/auth-flow-slugs"
|
||||
import {
|
||||
completeAuthFlow,
|
||||
flowComponent,
|
||||
flowRedirectUrl,
|
||||
flowTitle,
|
||||
@ -14,12 +19,12 @@ import {
|
||||
isRecoveryEmailSent,
|
||||
respondAuthFlow,
|
||||
startAuthFlow,
|
||||
type AuthFlowSlug,
|
||||
type FlowChallenge,
|
||||
} from "@/lib/auth/flow-api"
|
||||
|
||||
type AuthFlowPageProps = {
|
||||
slug: AuthFlowSlug
|
||||
flowQuery?: string
|
||||
defaultTitle: string
|
||||
defaultDescription: string
|
||||
successTitle: string
|
||||
@ -28,12 +33,26 @@ type AuthFlowPageProps = {
|
||||
successHref: string
|
||||
/** Full document navigation required (OIDC login routes). */
|
||||
successExternal?: boolean
|
||||
/** After embedded auth flow, bridge to OIDC session via BFF /flows/complete. */
|
||||
bridgeAuthentication?: boolean
|
||||
returnTo?: string
|
||||
initialError?: string | null
|
||||
footer: React.ReactNode
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
function decodeAuthError(value: string | null | undefined): string | null {
|
||||
if (!value) return null
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthFlowPage({
|
||||
slug,
|
||||
flowQuery,
|
||||
defaultTitle,
|
||||
defaultDescription,
|
||||
successTitle,
|
||||
@ -41,6 +60,9 @@ export function AuthFlowPage({
|
||||
successActionLabel,
|
||||
successHref,
|
||||
successExternal = false,
|
||||
bridgeAuthentication = false,
|
||||
returnTo = "/mail/inbox",
|
||||
initialError = null,
|
||||
footer,
|
||||
onSuccess,
|
||||
}: AuthFlowPageProps) {
|
||||
@ -48,35 +70,92 @@ export function AuthFlowPage({
|
||||
const [challenge, setChallenge] = useState<FlowChallenge | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [bridging, setBridging] = useState(false)
|
||||
const [flowError, setFlowError] = useState<string | null>(null)
|
||||
const [done, setDone] = useState(false)
|
||||
const [denied, setDenied] = useState(false)
|
||||
const bridgedRef = useRef(false)
|
||||
const redirectError = decodeAuthError(initialError)
|
||||
|
||||
const finishAuthentication = useCallback(async () => {
|
||||
if (!bridgeAuthentication || !isAuthenticationFlow(slug) || bridgedRef.current) {
|
||||
onSuccess?.()
|
||||
return
|
||||
}
|
||||
bridgedRef.current = true
|
||||
setBridging(true)
|
||||
setFlowError(null)
|
||||
try {
|
||||
const { redirectUrl } = await completeAuthFlow(returnTo)
|
||||
window.location.href = redirectUrl
|
||||
} catch (err) {
|
||||
bridgedRef.current = false
|
||||
setBridging(false)
|
||||
setFlowError(
|
||||
err instanceof Error ? err.message : "Impossible de finaliser la connexion"
|
||||
)
|
||||
}
|
||||
}, [bridgeAuthentication, onSuccess, returnTo, slug])
|
||||
|
||||
const redirectToOidcLogin = useCallback(() => {
|
||||
const params = new URLSearchParams({ returnTo })
|
||||
window.location.assign(`/api/auth/login?${params.toString()}`)
|
||||
}, [returnTo])
|
||||
|
||||
const bootstrap = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const step = await startAuthFlow(slug)
|
||||
const step = await startAuthFlow(slug, flowQuery)
|
||||
setSessionId(step.sessionId)
|
||||
setChallenge(step.challenge)
|
||||
setDone(step.done)
|
||||
setDenied(step.denied)
|
||||
if (step.done && !step.denied) {
|
||||
if (bridgeAuthentication) {
|
||||
// Start returned immediate redirect — no flow session cookie for /complete.
|
||||
redirectToOidcLogin()
|
||||
return
|
||||
}
|
||||
onSuccess?.()
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Impossible de démarrer le parcours")
|
||||
setFlowError(
|
||||
err instanceof Error ? err.message : "Impossible de démarrer le parcours"
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [onSuccess, slug])
|
||||
}, [bridgeAuthentication, flowQuery, onSuccess, redirectToOidcLogin, slug])
|
||||
|
||||
useEffect(() => {
|
||||
void bootstrap()
|
||||
}, [bootstrap])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (payload: Record<string, unknown>) => {
|
||||
if (!sessionId) return
|
||||
setSubmitting(true)
|
||||
setFlowError(null)
|
||||
try {
|
||||
const step = await respondAuthFlow(slug, payload, flowQuery)
|
||||
setSessionId(step.sessionId)
|
||||
setChallenge(step.challenge)
|
||||
setDone(step.done)
|
||||
setDenied(step.denied)
|
||||
if (step.done && !step.denied) {
|
||||
await finishAuthentication()
|
||||
}
|
||||
} catch (err) {
|
||||
setFlowError(err instanceof Error ? err.message : "Échec de l'étape")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[finishAuthentication, flowQuery, sessionId, slug]
|
||||
)
|
||||
|
||||
const validationErrors = useMemo(() => flowValidationErrors(challenge), [challenge])
|
||||
const formError = error ?? validationErrors._form ?? null
|
||||
const formError = flowError ?? redirectError ?? validationErrors._form ?? null
|
||||
|
||||
const title = useMemo(() => {
|
||||
if ((done && !denied) || isRecoveryEmailSent(slug, challenge)) return successTitle
|
||||
@ -92,37 +171,18 @@ export function AuthFlowPage({
|
||||
return defaultDescription
|
||||
}, [challenge, defaultDescription, denied, done, slug, successDescription])
|
||||
|
||||
async function handleSubmit(payload: Record<string, unknown>) {
|
||||
if (!sessionId) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const step = await respondAuthFlow(slug, payload)
|
||||
setSessionId(step.sessionId)
|
||||
setChallenge(step.challenge)
|
||||
setDone(step.done)
|
||||
setDenied(step.denied)
|
||||
if (step.done && !step.denied) {
|
||||
onSuccess?.()
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Échec de l'étape")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const component = flowComponent(challenge)
|
||||
const redirectUrl = flowRedirectUrl(challenge)
|
||||
const recoveryEmailSent = isRecoveryEmailSent(slug, challenge)
|
||||
const showSuccess = (done && !denied) || recoveryEmailSent
|
||||
const showSuccess =
|
||||
(done && !denied && !bridgeAuthentication) || recoveryEmailSent
|
||||
|
||||
return (
|
||||
<AuthCard title={title} description={description} error={formError} footer={footer}>
|
||||
{loading ? (
|
||||
{loading || bridging ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" aria-hidden />
|
||||
<span className="sr-only">Chargement…</span>
|
||||
<span className="sr-only">{bridging ? "Finalisation…" : "Chargement…"}</span>
|
||||
</div>
|
||||
) : showSuccess ? (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
|
||||
@ -13,6 +13,26 @@ import { AUTH_FLOW_SLUGS } from "@/lib/auth/auth-flow-slugs"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import type { FlowChallenge } from "@/lib/auth/flow-api"
|
||||
import { flowRedirectUrl } from "@/lib/auth/flow-api"
|
||||
import {
|
||||
FLOW_STAGE_REGISTRY,
|
||||
isKnownFlowComponent,
|
||||
readPrimaryAction,
|
||||
} from "@/components/auth/flow-stage-registry"
|
||||
import { PasswordStage, UserLoginStage } from "@/components/auth/flow-stages/password-stage"
|
||||
import { WebAuthnStage } from "@/components/auth/flow-stages/webauthn-stage"
|
||||
import { SourceOAuthStage } from "@/components/auth/flow-stages/source-oauth-stage"
|
||||
import {
|
||||
AccessDeniedStage,
|
||||
FlowRedirectStage,
|
||||
} from "@/components/auth/flow-stages/access-denied-stage"
|
||||
|
||||
FLOW_STAGE_REGISTRY["ak-stage-password"] = PasswordStage
|
||||
FLOW_STAGE_REGISTRY["ak-stage-user-login"] = UserLoginStage
|
||||
FLOW_STAGE_REGISTRY["ak-stage-authenticator-webauthn"] = WebAuthnStage
|
||||
FLOW_STAGE_REGISTRY["ak-stage-source"] = SourceOAuthStage
|
||||
FLOW_STAGE_REGISTRY["ak-stage-access-denied"] = AccessDeniedStage
|
||||
FLOW_STAGE_REGISTRY["xak-flow-redirect"] = FlowRedirectStage
|
||||
|
||||
type PromptField = {
|
||||
field_key: string
|
||||
@ -51,6 +71,7 @@ export function FlowChallengeForm({
|
||||
flowSlug === AUTH_FLOW_SLUGS.enrollment &&
|
||||
component === "ak-stage-prompt" &&
|
||||
isSignupCredentialsStep(promptFields)
|
||||
const RegistryStage = FLOW_STAGE_REGISTRY[component]
|
||||
|
||||
useEffect(() => {
|
||||
if (!signupCredentials) {
|
||||
@ -78,6 +99,12 @@ export function FlowChallengeForm({
|
||||
if (component === "ak-stage-email") {
|
||||
next.email = ""
|
||||
}
|
||||
if (component === "ak-stage-authenticator-validate") {
|
||||
next.code = ""
|
||||
}
|
||||
if (component === "ak-stage-password") {
|
||||
next.password = ""
|
||||
}
|
||||
setValues(next)
|
||||
}, [challenge, component, promptFields])
|
||||
|
||||
@ -108,17 +135,66 @@ export function FlowChallengeForm({
|
||||
payload.email = values.email ?? ""
|
||||
} else if (component === "ak-stage-authenticator-validate") {
|
||||
payload.code = values.code ?? ""
|
||||
} else {
|
||||
} else if (component === "ak-stage-password") {
|
||||
payload.password = values.password ?? ""
|
||||
} else if (!RegistryStage) {
|
||||
Object.assign(payload, values)
|
||||
}
|
||||
|
||||
await onSubmit(payload)
|
||||
}
|
||||
|
||||
if (!component || component === "ak-stage-access-denied") {
|
||||
if (!component) {
|
||||
return null
|
||||
}
|
||||
|
||||
const autoSubmitStage =
|
||||
component === "ak-stage-user-login" ||
|
||||
component === "ak-stage-authenticator-webauthn" ||
|
||||
component === "xak-flow-redirect" ||
|
||||
(component === "ak-stage-source" && Boolean(flowRedirectUrl(challenge)))
|
||||
|
||||
if (component === "ak-stage-access-denied") {
|
||||
return <AccessDeniedStage />
|
||||
}
|
||||
|
||||
if (RegistryStage) {
|
||||
if (component === "ak-stage-password") {
|
||||
return (
|
||||
<form className="space-y-4" autoComplete="on" onSubmit={handleSubmit}>
|
||||
<RegistryStage
|
||||
challenge={challenge}
|
||||
values={values}
|
||||
fieldErrors={fieldErrors}
|
||||
submitting={submitting}
|
||||
onChange={(key, value) => setValues((prev) => ({ ...prev, [key]: value }))}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
<AuthConnectButton type="submit" disabled={submitting}>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden />
|
||||
<span>Patientez…</span>
|
||||
</>
|
||||
) : (
|
||||
primaryAction
|
||||
)}
|
||||
</AuthConnectButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<RegistryStage
|
||||
challenge={challenge}
|
||||
values={values}
|
||||
fieldErrors={fieldErrors}
|
||||
submitting={submitting}
|
||||
onChange={(key, value) => setValues((prev) => ({ ...prev, [key]: value }))}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-4" autoComplete="on" onSubmit={handleSubmit}>
|
||||
{component === "ak-stage-prompt" ? (
|
||||
@ -181,13 +257,13 @@ export function FlowChallengeForm({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isKnownComponent(component) ? (
|
||||
{!isKnownFlowComponent(component) ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Étape non prise en charge ({component}). Utilisez le portail Authentik.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isKnownComponent(component) ? (
|
||||
{isKnownFlowComponent(component) && !autoSubmitStage ? (
|
||||
<AuthConnectButton
|
||||
type="submit"
|
||||
disabled={submitting || (signupCredentials && !signupCanSubmit)}
|
||||
@ -414,12 +490,6 @@ function isPromptField(value: unknown): value is PromptField {
|
||||
typeof (value as PromptField).field_key === "string"
|
||||
)
|
||||
}
|
||||
|
||||
function readPrimaryAction(challenge: FlowChallenge | null): string {
|
||||
const action = challenge?.primary_action
|
||||
return typeof action === "string" && action.trim() ? action : "Continuer"
|
||||
}
|
||||
|
||||
function readPasswordFields(challenge: FlowChallenge | null): boolean {
|
||||
return challenge?.password_fields === true
|
||||
}
|
||||
@ -441,11 +511,3 @@ function autoCompleteFor(fieldKey: string, type?: string): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isKnownComponent(component: string): boolean {
|
||||
return [
|
||||
"ak-stage-prompt",
|
||||
"ak-stage-identification",
|
||||
"ak-stage-email",
|
||||
"ak-stage-authenticator-validate",
|
||||
].includes(component)
|
||||
}
|
||||
|
||||
39
components/auth/flow-stage-registry.ts
Normal file
39
components/auth/flow-stage-registry.ts
Normal file
@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import type { FlowChallenge } from "@/lib/auth/flow-api"
|
||||
|
||||
export type StageRendererProps = {
|
||||
challenge: FlowChallenge | null
|
||||
values: Record<string, string>
|
||||
fieldErrors: Record<string, string>
|
||||
submitting: boolean
|
||||
onChange: (key: string, value: string) => void
|
||||
onSubmit: (payload: Record<string, unknown>) => void | Promise<void>
|
||||
}
|
||||
|
||||
export type StageRenderer = React.ComponentType<StageRendererProps>
|
||||
|
||||
export function readPrimaryAction(challenge: FlowChallenge | null): string {
|
||||
const action = challenge?.primary_action
|
||||
return typeof action === "string" && action.trim() ? action : "Continuer"
|
||||
}
|
||||
|
||||
export function isKnownFlowComponent(component: string): boolean {
|
||||
return component in FLOW_STAGE_REGISTRY || LEGACY_KNOWN_COMPONENTS.has(component)
|
||||
}
|
||||
|
||||
const LEGACY_KNOWN_COMPONENTS = new Set([
|
||||
"ak-stage-prompt",
|
||||
"ak-stage-identification",
|
||||
"ak-stage-email",
|
||||
"ak-stage-authenticator-validate",
|
||||
"ak-stage-password",
|
||||
"ak-stage-user-login",
|
||||
"ak-stage-authenticator-webauthn",
|
||||
"ak-stage-source",
|
||||
"xak-flow-redirect",
|
||||
"ak-stage-access-denied",
|
||||
])
|
||||
|
||||
/** Registry for Phase 3 stages. */
|
||||
export const FLOW_STAGE_REGISTRY: Record<string, StageRenderer> = {}
|
||||
19
components/auth/flow-stages/access-denied-stage.tsx
Normal file
19
components/auth/flow-stages/access-denied-stage.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
|
||||
|
||||
export function AccessDeniedStage() {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Accès refusé. Vérifiez vos identifiants ou contactez le support.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function FlowRedirectStage({ challenge }: StageRendererProps) {
|
||||
return (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Finalisation de la connexion…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
31
components/auth/flow-stages/password-stage.tsx
Normal file
31
components/auth/flow-stages/password-stage.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { PasswordFieldBlock } from "@/components/auth/password-field-block"
|
||||
import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
|
||||
|
||||
export function PasswordStage({ values, fieldErrors, onChange }: StageRendererProps) {
|
||||
return (
|
||||
<PasswordFieldBlock
|
||||
id="password"
|
||||
label="Mot de passe"
|
||||
value={values.password ?? ""}
|
||||
error={fieldErrors.password}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
onChange={(value) => onChange("password", value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function UserLoginStage({ submitting, onSubmit }: StageRendererProps) {
|
||||
const submittedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (submitting || submittedRef.current) return
|
||||
submittedRef.current = true
|
||||
void onSubmit({ component: "ak-stage-user-login" })
|
||||
}, [onSubmit, submitting])
|
||||
|
||||
return null
|
||||
}
|
||||
67
components/auth/flow-stages/source-oauth-stage.tsx
Normal file
67
components/auth/flow-stages/source-oauth-stage.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { AuthConnectButton } from "@/components/auth/auth-connect-button"
|
||||
import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
|
||||
import { flowRedirectUrl } from "@/lib/auth/flow-api"
|
||||
|
||||
type FlowSource = {
|
||||
pk?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
function readSources(challenge: StageRendererProps["challenge"]): FlowSource[] {
|
||||
const raw = challenge?.sources
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.filter(
|
||||
(item): item is FlowSource =>
|
||||
typeof item === "object" && item !== null && typeof (item as FlowSource).pk === "string"
|
||||
)
|
||||
}
|
||||
|
||||
export function SourceOAuthStage({ challenge, submitting, onSubmit }: StageRendererProps) {
|
||||
const redirect = flowRedirectUrl(challenge)
|
||||
const sources = readSources(challenge)
|
||||
|
||||
useEffect(() => {
|
||||
if (redirect) {
|
||||
window.location.href = redirect
|
||||
}
|
||||
}, [redirect])
|
||||
|
||||
if (redirect) {
|
||||
return (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Redirection vers le fournisseur…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (sources.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connexion tierce indisponible. Utilisez votre identifiant UltiSpace.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{sources.map((source) => (
|
||||
<AuthConnectButton
|
||||
key={source.pk}
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() =>
|
||||
void onSubmit({
|
||||
component: "ak-stage-source",
|
||||
selected_source: source.pk,
|
||||
})
|
||||
}
|
||||
>
|
||||
{source.name ?? "Continuer"}
|
||||
</AuthConnectButton>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
components/auth/flow-stages/webauthn-stage.tsx
Normal file
100
components/auth/flow-stages/webauthn-stage.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
|
||||
|
||||
function readRequestOptions(
|
||||
challenge: StageRendererProps["challenge"]
|
||||
): PublicKeyCredentialRequestOptions | null {
|
||||
const raw = challenge?.request_options ?? challenge?.requestOptions
|
||||
if (!raw || typeof raw !== "object") return null
|
||||
const nested = raw as { publicKey?: PublicKeyCredentialRequestOptions }
|
||||
if (nested.publicKey) return nested.publicKey
|
||||
return raw as PublicKeyCredentialRequestOptions
|
||||
}
|
||||
|
||||
function bufferFromBase64URL(value: string): ArrayBuffer {
|
||||
const padded = value.replace(/-/g, "+").replace(/_/g, "/")
|
||||
const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4))
|
||||
const binary = atob(padded + pad)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
function bufferToBase64URL(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ""
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i]!)
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
|
||||
function normalizeRequestOptions(
|
||||
options: PublicKeyCredentialRequestOptions
|
||||
): PublicKeyCredentialRequestOptions {
|
||||
const challenge = options.challenge
|
||||
if (typeof challenge === "string") {
|
||||
return { ...options, challenge: bufferFromBase64URL(challenge) }
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function serializeCredential(credential: PublicKeyCredential): Record<string, unknown> {
|
||||
const response = credential.response as AuthenticatorAssertionResponse
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64URL(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
|
||||
authenticatorData: bufferToBase64URL(response.authenticatorData),
|
||||
signature: bufferToBase64URL(response.signature),
|
||||
userHandle: response.userHandle
|
||||
? bufferToBase64URL(response.userHandle)
|
||||
: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function WebAuthnStage({ challenge, submitting, onSubmit }: StageRendererProps) {
|
||||
const startedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (startedRef.current || submitting) return
|
||||
const options = readRequestOptions(challenge)
|
||||
if (!options || typeof window === "undefined" || !window.PublicKeyCredential) {
|
||||
return
|
||||
}
|
||||
startedRef.current = true
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const credential = (await navigator.credentials.get({
|
||||
publicKey: normalizeRequestOptions(options),
|
||||
})) as PublicKeyCredential | null
|
||||
if (!credential) {
|
||||
startedRef.current = false
|
||||
return
|
||||
}
|
||||
await onSubmit({
|
||||
component: "ak-stage-authenticator-webauthn",
|
||||
response: serializeCredential(credential),
|
||||
})
|
||||
} catch {
|
||||
startedRef.current = false
|
||||
}
|
||||
})()
|
||||
}, [challenge, onSubmit, submitting])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" aria-hidden />
|
||||
<p>Suivez l'invite de votre clé de sécurité ou biométrie…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
components/auth/login-page-content.tsx
Normal file
55
components/auth/login-page-content.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { AuthFlowPage } from "@/components/auth/auth-flow-page"
|
||||
import { AUTH_FLOW_SLUGS } from "@/lib/auth/auth-flow-slugs"
|
||||
|
||||
type LoginPageContentProps = {
|
||||
returnTo?: string
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export function LoginPageContent({
|
||||
returnTo = "/mail/inbox",
|
||||
error = null,
|
||||
}: LoginPageContentProps) {
|
||||
const signupHref = `/signup?returnTo=${encodeURIComponent(returnTo)}`
|
||||
const forgotPasswordHref = `/forgot-password?returnTo=${encodeURIComponent(returnTo)}`
|
||||
const oidcFallbackHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
|
||||
|
||||
return (
|
||||
<AuthFlowPage
|
||||
slug={AUTH_FLOW_SLUGS.authentication}
|
||||
flowQuery={undefined}
|
||||
defaultTitle="Connexion"
|
||||
defaultDescription="Connecte-toi avec ton compte UltiSpace pour accéder à ta suite."
|
||||
successTitle="Connexion réussie"
|
||||
successDescription="Redirection vers votre espace…"
|
||||
successActionLabel="Continuer"
|
||||
successHref={returnTo}
|
||||
bridgeAuthentication
|
||||
returnTo={returnTo}
|
||||
initialError={error}
|
||||
footer={
|
||||
<div className="flex w-full flex-col gap-3 text-center text-sm text-muted-foreground">
|
||||
<p>
|
||||
<a className="font-medium text-primary underline" href={oidcFallbackHref}>
|
||||
Connexion via redirect UltiSpace
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Pas encore de compte ?{" "}
|
||||
<Link className="font-medium text-primary underline" href={signupHref}>
|
||||
Créer un compte
|
||||
</Link>
|
||||
</p>
|
||||
<p>
|
||||
<Link className="font-medium text-primary underline" href={forgotPasswordHref}>
|
||||
Mot de passe oublié ?
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
31
components/auth/reset-password-page-content.tsx
Normal file
31
components/auth/reset-password-page-content.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { AuthFlowPage } from "@/components/auth/auth-flow-page"
|
||||
import { AUTH_FLOW_SLUGS } from "@/lib/auth/auth-flow-slugs"
|
||||
|
||||
type ResetPasswordPageContentProps = {
|
||||
flowQuery?: string
|
||||
}
|
||||
|
||||
export function ResetPasswordPageContent({ flowQuery }: ResetPasswordPageContentProps) {
|
||||
return (
|
||||
<AuthFlowPage
|
||||
slug={AUTH_FLOW_SLUGS.recovery}
|
||||
flowQuery={flowQuery}
|
||||
defaultTitle="Nouveau mot de passe"
|
||||
defaultDescription="Choisissez un nouveau mot de passe pour votre compte UltiSpace."
|
||||
successTitle="Mot de passe mis à jour"
|
||||
successDescription="Votre mot de passe a été modifié. Connectez-vous pour continuer."
|
||||
successActionLabel="Se connecter"
|
||||
successHref="/login"
|
||||
footer={
|
||||
<p className="w-full text-center text-sm text-muted-foreground">
|
||||
<Link className="font-medium text-primary underline" href="/login">
|
||||
Retour à la connexion
|
||||
</Link>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -2,6 +2,11 @@
|
||||
export const AUTH_FLOW_SLUGS = {
|
||||
enrollment: "ulti-enrollment",
|
||||
recovery: "ulti-recovery",
|
||||
authentication: "default-authentication-flow",
|
||||
} as const
|
||||
|
||||
export type AuthFlowSlug = (typeof AUTH_FLOW_SLUGS)[keyof typeof AUTH_FLOW_SLUGS]
|
||||
|
||||
export function isAuthenticationFlow(slug: AuthFlowSlug): boolean {
|
||||
return slug === AUTH_FLOW_SLUGS.authentication
|
||||
}
|
||||
|
||||
@ -9,6 +9,10 @@ export type FlowStepResponse = {
|
||||
denied: boolean
|
||||
}
|
||||
|
||||
export type FlowCompleteResponse = {
|
||||
redirectUrl: string
|
||||
}
|
||||
|
||||
export type FlowApiError = {
|
||||
code?: string
|
||||
message?: string
|
||||
@ -18,6 +22,12 @@ function flowApiBase(slug: AuthFlowSlug): string {
|
||||
return `/api/v1/auth/flows/${encodeURIComponent(slug)}`
|
||||
}
|
||||
|
||||
function buildQuerySuffix(query?: string): string {
|
||||
const trimmed = query?.trim()
|
||||
if (!trimmed) return ""
|
||||
return `?query=${encodeURIComponent(trimmed)}`
|
||||
}
|
||||
|
||||
async function parseFlowResponse(res: Response): Promise<FlowStepResponse> {
|
||||
const body = (await res.json()) as FlowStepResponse & FlowApiError
|
||||
if (!res.ok) {
|
||||
@ -26,8 +36,11 @@ async function parseFlowResponse(res: Response): Promise<FlowStepResponse> {
|
||||
return body
|
||||
}
|
||||
|
||||
export async function startAuthFlow(slug: AuthFlowSlug): Promise<FlowStepResponse> {
|
||||
const res = await fetch(`${flowApiBase(slug)}/start`, {
|
||||
export async function startAuthFlow(
|
||||
slug: AuthFlowSlug,
|
||||
query?: string
|
||||
): Promise<FlowStepResponse> {
|
||||
const res = await fetch(`${flowApiBase(slug)}/start${buildQuerySuffix(query)}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
@ -37,9 +50,10 @@ export async function startAuthFlow(slug: AuthFlowSlug): Promise<FlowStepRespons
|
||||
|
||||
export async function respondAuthFlow(
|
||||
slug: AuthFlowSlug,
|
||||
payload: Record<string, unknown>
|
||||
payload: Record<string, unknown>,
|
||||
query?: string
|
||||
): Promise<FlowStepResponse> {
|
||||
const res = await fetch(`${flowApiBase(slug)}/respond`, {
|
||||
const res = await fetch(`${flowApiBase(slug)}/respond${buildQuerySuffix(query)}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
@ -51,6 +65,24 @@ export async function respondAuthFlow(
|
||||
return parseFlowResponse(res)
|
||||
}
|
||||
|
||||
/** Bridge embedded authentication to OIDC session (sets Authentik cookies + login URL). */
|
||||
export async function completeAuthFlow(returnTo: string): Promise<FlowCompleteResponse> {
|
||||
const res = await fetch("/api/v1/auth/flows/complete", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ returnTo }),
|
||||
})
|
||||
const body = (await res.json()) as FlowCompleteResponse & FlowApiError
|
||||
if (!res.ok) {
|
||||
throw new Error(body.message ?? `flow complete failed (${res.status})`)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
export function flowComponent(challenge: FlowChallenge | null | undefined): string {
|
||||
if (!challenge) return ""
|
||||
const value = challenge.component
|
||||
|
||||
@ -41,6 +41,9 @@ export const SIGNUP_PATH = "/signup"
|
||||
/** In-app forgot-password page (Phase 2 custom UI via ultid BFF). */
|
||||
export const FORGOT_PASSWORD_PATH = "/forgot-password"
|
||||
|
||||
/** In-app password reset after e-mail link (Phase 3, is_restored flow). */
|
||||
export const RESET_PASSWORD_PATH = "/reset-password"
|
||||
|
||||
export function getSignupUrl(): string {
|
||||
return SIGNUP_PATH
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const AUTH_PUBLIC_PREFIXES = ["/login", "/signup", "/forgot-password", "/auth/", "/api/auth/"]
|
||||
const AUTH_PUBLIC_PREFIXES = ["/login", "/signup", "/forgot-password", "/reset-password", "/auth/", "/api/auth/"]
|
||||
|
||||
/** Routes without session enforcement (login, public shares, interactive demos). */
|
||||
export function isAuthPublicPath(pathname: string): boolean {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user