Move allauth + auth UI to legacy/
allauth/ (44 files) is a django-allauth React UI — a separate concern from the Mizan protocol. Moved to legacy/ pending extraction into a standalone mizan-django-allauth package. Also moved to legacy/: - client/AuthContext.tsx — generic auth state from /me endpoint - client/RouterContext.tsx — framework-agnostic router adapter - client/routing.tsx — UserRoute/StaffRoute/AnonymousRoute guards - client/nextjs.tsx — Next.js router adapter for auth These are auth UI infrastructure, not Mizan protocol. The Mizan core only needs JWT for auth header selection (jwt/ stays — MizanProvider depends on useJWT() to decide between Bearer and session auth). Cleaned up re-exports in client/react.ts and vitest aliases. 33 React tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
75
legacy/allauth/components/views/LoginView.tsx
Normal file
75
legacy/allauth/components/views/LoginView.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import { useAuthContext, useConfig } from '../../contexts/AuthContext'
|
||||
import { getAuthDetails } from '../../api'
|
||||
import { AuthDjangoForm } from '../AuthDjangoForm'
|
||||
import { PasskeyLogin } from '../PasskeyLogin'
|
||||
import { ProviderList } from '../ProviderList'
|
||||
import type { AllauthConfiguration } from '../../types'
|
||||
|
||||
interface LoginViewProps {
|
||||
/** Called after successful login (or when MFA is triggered) */
|
||||
onSuccess?: () => void
|
||||
/** Called when user clicks "Create account" */
|
||||
onSignupClick?: () => void
|
||||
/** Called when user clicks "Forgot password" */
|
||||
onForgotPasswordClick?: () => void
|
||||
/** Called when user clicks "Sign in with code" */
|
||||
onLoginByCodeClick?: () => void
|
||||
/** OAuth callback URL for social providers */
|
||||
oauthCallbackUrl?: string
|
||||
}
|
||||
|
||||
export function LoginView({
|
||||
onSuccess,
|
||||
onSignupClick,
|
||||
onForgotPasswordClick,
|
||||
onLoginByCodeClick,
|
||||
oauthCallbackUrl,
|
||||
}: LoginViewProps) {
|
||||
const { refresh } = useAuthContext()
|
||||
const config = useConfig()
|
||||
|
||||
// Get feature flags from backend config
|
||||
const allauthConfig = config?.data as AllauthConfiguration | undefined
|
||||
const isSignupEnabled = allauthConfig?.account?.is_open_for_signup ?? true
|
||||
const isLoginByCodeEnabled = allauthConfig?.account?.login_by_code_enabled ?? false
|
||||
|
||||
const handleSuccess = async () => {
|
||||
const newAuth = await refresh()
|
||||
const details = getAuthDetails(newAuth)
|
||||
|
||||
// Only call onSuccess if fully authenticated (no pending MFA)
|
||||
// If MFA is pending, AllauthUI will handle showing the MFA view
|
||||
if (details.isAuthenticated) {
|
||||
onSuccess?.()
|
||||
}
|
||||
}
|
||||
|
||||
// Build footer links based on provided callbacks AND backend config
|
||||
const footerLinks: Array<{ href?: string; label: string; onClick?: () => void }> = []
|
||||
|
||||
if (onForgotPasswordClick) {
|
||||
footerLinks.push({ label: 'Forgot your password?', onClick: onForgotPasswordClick })
|
||||
}
|
||||
if (onLoginByCodeClick && isLoginByCodeEnabled) {
|
||||
footerLinks.push({ label: 'Sign in with a code instead', onClick: onLoginByCodeClick })
|
||||
}
|
||||
if (onSignupClick && isSignupEnabled) {
|
||||
footerLinks.push({ label: "Don't have an account? Sign up", onClick: onSignupClick })
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthDjangoForm
|
||||
formName="login"
|
||||
onSuccess={handleSuccess}
|
||||
footerLinks={footerLinks}
|
||||
postFields={
|
||||
<>
|
||||
<PasskeyLogin onSuccess={onSuccess} />
|
||||
{oauthCallbackUrl && <ProviderList callbackUrl={oauthCallbackUrl} />}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
137
legacy/allauth/components/views/MFAChooserView.tsx
Normal file
137
legacy/allauth/components/views/MFAChooserView.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { AuthenticatorType } from '../../defines'
|
||||
import { useAllauthAPI } from '../../contexts/APIContext'
|
||||
import { useStyles } from '../../contexts/StylesContext'
|
||||
import { AuthCard } from '../AuthCard'
|
||||
import { MFATOTPView } from './MFATOTPView'
|
||||
import { MFAWebAuthnView } from './MFAWebAuthnView'
|
||||
import { MFARecoveryCodesView } from './MFARecoveryCodesView'
|
||||
|
||||
const MFA_OPTIONS: Record<string, { label: string; description: string }> = {
|
||||
[AuthenticatorType.WEBAUTHN]: {
|
||||
label: 'Security Key / Passkey',
|
||||
description: 'Use your registered security key or passkey',
|
||||
},
|
||||
[AuthenticatorType.TOTP]: {
|
||||
label: 'Authenticator App',
|
||||
description: 'Enter a code from your authenticator app',
|
||||
},
|
||||
[AuthenticatorType.RECOVERY_CODES]: {
|
||||
label: 'Recovery Code',
|
||||
description: 'Use one of your recovery codes',
|
||||
},
|
||||
}
|
||||
|
||||
interface MFAChooserViewProps {
|
||||
types: string[]
|
||||
onSuccess?: () => void
|
||||
onCancel?: () => void
|
||||
isReauth?: boolean
|
||||
}
|
||||
|
||||
export function MFAChooserView({ types, onSuccess, onCancel, isReauth }: MFAChooserViewProps) {
|
||||
const api = useAllauthAPI()
|
||||
const styles = useStyles()
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
|
||||
// Filter to only show options that are available
|
||||
const availableOptions = types
|
||||
.filter(type => MFA_OPTIONS[type])
|
||||
.map(type => ({ type, ...MFA_OPTIONS[type] }))
|
||||
|
||||
const handleCancel = async () => {
|
||||
setCancelling(true)
|
||||
try {
|
||||
await api.session.logout()
|
||||
onCancel?.()
|
||||
} catch {
|
||||
setCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = types.length > 1 ? () => setSelectedType(null) : undefined
|
||||
|
||||
// If a type is selected, show that method's view
|
||||
if (selectedType === AuthenticatorType.TOTP) {
|
||||
return (
|
||||
<MFATOTPView
|
||||
onSuccess={onSuccess}
|
||||
onCancel={onCancel}
|
||||
onBack={handleBack}
|
||||
isReauth={isReauth}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedType === AuthenticatorType.WEBAUTHN) {
|
||||
return (
|
||||
<MFAWebAuthnView
|
||||
onSuccess={onSuccess}
|
||||
onCancel={onCancel}
|
||||
onBack={handleBack}
|
||||
isReauth={isReauth}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedType === AuthenticatorType.RECOVERY_CODES) {
|
||||
return (
|
||||
<MFARecoveryCodesView
|
||||
onSuccess={onSuccess}
|
||||
onCancel={onCancel}
|
||||
onBack={handleBack}
|
||||
isReauth={isReauth}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Show chooser
|
||||
if (availableOptions.length === 0) {
|
||||
return (
|
||||
<AuthCard
|
||||
title="Two-Factor Authentication"
|
||||
subtitle="No authentication methods available."
|
||||
footerLinks={onCancel ? [
|
||||
{ label: 'Cancel and go back', onClick: handleCancel },
|
||||
] : []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<h1 className={styles.title}>Two-Factor Authentication</h1>
|
||||
<p className={styles.subtitle}>Choose how you want to verify your identity.</p>
|
||||
|
||||
<div className={styles.form}>
|
||||
{availableOptions.map(option => (
|
||||
<button
|
||||
key={option.type}
|
||||
onClick={() => setSelectedType(option.type)}
|
||||
className={styles.providerButton}
|
||||
>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div style={{ fontWeight: 600 }}>{option.label}</div>
|
||||
<div style={{ fontSize: '0.8125rem', opacity: 0.7, marginTop: '0.25rem' }}>
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{onCancel && (
|
||||
<div className={styles.footer}>
|
||||
<button onClick={handleCancel} disabled={cancelling} className={styles.link}>
|
||||
{cancelling ? 'Cancelling...' : 'Cancel and go back'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
legacy/allauth/components/views/MFARecoveryCodesView.tsx
Normal file
51
legacy/allauth/components/views/MFARecoveryCodesView.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAllauthAPI } from '../../contexts/APIContext'
|
||||
import { AuthDjangoForm } from '../AuthDjangoForm'
|
||||
|
||||
interface MFARecoveryCodesViewProps {
|
||||
onSuccess?: () => void
|
||||
onCancel?: () => void
|
||||
onBack?: () => void
|
||||
isReauth?: boolean
|
||||
}
|
||||
|
||||
export function MFARecoveryCodesView({ onSuccess, onCancel, onBack, isReauth }: MFARecoveryCodesViewProps) {
|
||||
const api = useAllauthAPI()
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
|
||||
const handleCancel = async () => {
|
||||
setCancelling(true)
|
||||
try {
|
||||
await api.session.logout()
|
||||
onCancel?.()
|
||||
} catch {
|
||||
setCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Build footer links
|
||||
const footerLinks = []
|
||||
if (onBack) {
|
||||
footerLinks.push({ label: 'Use a different method', onClick: onBack })
|
||||
}
|
||||
if (onCancel) {
|
||||
footerLinks.push({
|
||||
label: cancelling ? 'Cancelling...' : 'Cancel',
|
||||
onClick: handleCancel
|
||||
})
|
||||
}
|
||||
|
||||
const formName = isReauth ? 'mfa_reauthenticate' : 'mfa_authenticate'
|
||||
|
||||
return (
|
||||
<AuthDjangoForm
|
||||
formName={formName}
|
||||
title="Recovery Code"
|
||||
subtitle="Enter one of your recovery codes."
|
||||
onSuccess={() => onSuccess?.()}
|
||||
footerLinks={footerLinks}
|
||||
/>
|
||||
)
|
||||
}
|
||||
51
legacy/allauth/components/views/MFATOTPView.tsx
Normal file
51
legacy/allauth/components/views/MFATOTPView.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAllauthAPI } from '../../contexts/APIContext'
|
||||
import { AuthDjangoForm } from '../AuthDjangoForm'
|
||||
|
||||
interface MFATOTPViewProps {
|
||||
onSuccess?: () => void
|
||||
onCancel?: () => void
|
||||
onBack?: () => void
|
||||
isReauth?: boolean
|
||||
}
|
||||
|
||||
export function MFATOTPView({ onSuccess, onCancel, onBack, isReauth }: MFATOTPViewProps) {
|
||||
const api = useAllauthAPI()
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
|
||||
const handleCancel = async () => {
|
||||
setCancelling(true)
|
||||
try {
|
||||
await api.session.logout()
|
||||
onCancel?.()
|
||||
} catch {
|
||||
setCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Build footer links
|
||||
const footerLinks = []
|
||||
if (onBack) {
|
||||
footerLinks.push({ label: 'Use a different method', onClick: onBack })
|
||||
}
|
||||
if (onCancel) {
|
||||
footerLinks.push({
|
||||
label: cancelling ? 'Cancelling...' : 'Cancel',
|
||||
onClick: handleCancel
|
||||
})
|
||||
}
|
||||
|
||||
const formName = isReauth ? 'mfa_reauthenticate' : 'mfa_authenticate'
|
||||
|
||||
return (
|
||||
<AuthDjangoForm
|
||||
formName={formName}
|
||||
title="Authenticator App"
|
||||
subtitle="Enter the 6-digit code from your authenticator app."
|
||||
onSuccess={() => onSuccess?.()}
|
||||
footerLinks={footerLinks}
|
||||
/>
|
||||
)
|
||||
}
|
||||
113
legacy/allauth/components/views/MFAWebAuthnView.tsx
Normal file
113
legacy/allauth/components/views/MFAWebAuthnView.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAllauthAPI } from '../../contexts/APIContext'
|
||||
import { useAuthContext } from '../../contexts/AuthContext'
|
||||
import { useStyles } from '../../contexts/StylesContext'
|
||||
|
||||
interface MFAWebAuthnViewProps {
|
||||
onSuccess?: () => void
|
||||
onCancel?: () => void
|
||||
onBack?: () => void
|
||||
isReauth?: boolean
|
||||
}
|
||||
|
||||
export function MFAWebAuthnView({ onSuccess, onCancel, onBack, isReauth }: MFAWebAuthnViewProps) {
|
||||
const api = useAllauthAPI()
|
||||
const { refresh } = useAuthContext()
|
||||
const styles = useStyles()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [authenticating, setAuthenticating] = useState(false)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
|
||||
const handleCancel = async () => {
|
||||
setCancelling(true)
|
||||
try {
|
||||
await api.session.logout()
|
||||
onCancel?.()
|
||||
} catch {
|
||||
setCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleWebAuthn = async () => {
|
||||
setError(null)
|
||||
setAuthenticating(true)
|
||||
|
||||
try {
|
||||
const { startAuthentication } = await import('@simplewebauthn/browser')
|
||||
|
||||
// Get challenge from server
|
||||
const optionsRes = isReauth
|
||||
? await api.webauthn.requestOptions.reauthentication()
|
||||
: await api.webauthn.requestOptions.authentication()
|
||||
|
||||
if (optionsRes.status !== 200 || !optionsRes.data?.request_options?.publicKey) {
|
||||
throw new Error('Failed to get authentication options')
|
||||
}
|
||||
|
||||
// Perform WebAuthn authentication
|
||||
// The allauth API returns { request_options: { publicKey: {...} } }
|
||||
// @simplewebauthn/browser v13+ expects { optionsJSON: ... }
|
||||
const credential = await startAuthentication({ optionsJSON: optionsRes.data.request_options.publicKey as any })
|
||||
|
||||
// Verify with server
|
||||
const res = isReauth
|
||||
? await api.webauthn.reauthenticate(credential)
|
||||
: await api.webauthn.authenticate(credential)
|
||||
|
||||
if (res.status === 200) {
|
||||
await refresh()
|
||||
onSuccess?.()
|
||||
} else {
|
||||
setError('Authentication failed. Please try again.')
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError' || e.name === 'NotAllowedError') {
|
||||
setError(null)
|
||||
} else {
|
||||
setError(e.message || 'Failed to authenticate with security key')
|
||||
}
|
||||
} finally {
|
||||
setAuthenticating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<h1 className={styles.title}>Security Key</h1>
|
||||
<p className={styles.subtitle}>Use your security key to verify your identity.</p>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error}>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.form}>
|
||||
<button
|
||||
onClick={handleWebAuthn}
|
||||
disabled={authenticating}
|
||||
className={styles.submit}
|
||||
>
|
||||
{authenticating ? 'Waiting for security key...' : 'Use Security Key'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
{onBack && (
|
||||
<button onClick={onBack} className={styles.link}>
|
||||
Use a different method
|
||||
</button>
|
||||
)}
|
||||
{onCancel && (
|
||||
<button onClick={handleCancel} disabled={cancelling} className={styles.link}>
|
||||
{cancelling ? 'Cancelling...' : 'Cancel'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
legacy/allauth/components/views/SignupView.tsx
Normal file
42
legacy/allauth/components/views/SignupView.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { useAuthContext } from '../../contexts/AuthContext'
|
||||
import { getAuthDetails } from '../../api'
|
||||
import { AuthDjangoForm } from '../AuthDjangoForm'
|
||||
|
||||
interface SignupViewProps {
|
||||
/** Called after successful signup */
|
||||
onSuccess?: () => void
|
||||
/** Called when user clicks "Already have an account? Sign in" */
|
||||
onLoginClick?: () => void
|
||||
}
|
||||
|
||||
export function SignupView({
|
||||
onSuccess,
|
||||
onLoginClick,
|
||||
}: SignupViewProps) {
|
||||
const { refresh } = useAuthContext()
|
||||
|
||||
const handleSuccess = async () => {
|
||||
const newAuth = await refresh()
|
||||
const details = getAuthDetails(newAuth)
|
||||
|
||||
if (details.isAuthenticated) {
|
||||
onSuccess?.()
|
||||
}
|
||||
}
|
||||
|
||||
const footerLinks: Array<{ label: string; onClick?: () => void }> = []
|
||||
|
||||
if (onLoginClick) {
|
||||
footerLinks.push({ label: 'Already have an account? Sign in', onClick: onLoginClick })
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthDjangoForm
|
||||
formName="signup"
|
||||
onSuccess={handleSuccess}
|
||||
footerLinks={footerLinks}
|
||||
/>
|
||||
)
|
||||
}
|
||||
6
legacy/allauth/components/views/index.ts
Normal file
6
legacy/allauth/components/views/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { LoginView } from './LoginView'
|
||||
export { SignupView } from './SignupView'
|
||||
export { MFAChooserView } from './MFAChooserView'
|
||||
export { MFAWebAuthnView } from './MFAWebAuthnView'
|
||||
export { MFATOTPView } from './MFATOTPView'
|
||||
export { MFARecoveryCodesView } from './MFARecoveryCodesView'
|
||||
Reference in New Issue
Block a user