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:
142
legacy/AuthContext.tsx
Normal file
142
legacy/AuthContext.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { createDjangoCSRClient, Auth } from './index'
|
||||
import type { BaseUser, AuthDetails, AuthRoutes } from './types'
|
||||
|
||||
/**
|
||||
* Auth state provided by AuthContext.
|
||||
*/
|
||||
export interface AuthState<TUser extends BaseUser = BaseUser> {
|
||||
/** Current user (null if not authenticated) */
|
||||
user: TUser | null
|
||||
/** Whether auth state is loading */
|
||||
isLoading: boolean
|
||||
/** Refresh user from server */
|
||||
refresh: () => Promise<TUser | null>
|
||||
}
|
||||
|
||||
const Context = createContext<AuthState | null>(null)
|
||||
|
||||
/**
|
||||
* Default routes configuration.
|
||||
*/
|
||||
export const defaultRoutes: AuthRoutes = {
|
||||
login: '/auth/login',
|
||||
authenticated: '/dashboard',
|
||||
}
|
||||
|
||||
const RoutesContext = createContext<AuthRoutes>(defaultRoutes)
|
||||
|
||||
export interface AuthContextProps<TUser extends BaseUser = BaseUser> {
|
||||
children: ReactNode
|
||||
/** Initial user from SSR hydration (null if not authenticated) */
|
||||
user?: TUser | null
|
||||
/** API endpoint to fetch user data (default: '/api/auth/me/') */
|
||||
userEndpoint?: string
|
||||
/** Route configuration for guards */
|
||||
routes?: Partial<AuthRoutes>
|
||||
}
|
||||
|
||||
/**
|
||||
* Base auth context for Django-React apps.
|
||||
*
|
||||
* Provides user state from a simple /me endpoint.
|
||||
* For allauth integration, use AllauthContext instead.
|
||||
*/
|
||||
// Create client once at module level (session auth, no dynamic config needed)
|
||||
const client = createDjangoCSRClient(Auth.SESSION)
|
||||
|
||||
export function AuthContext<TUser extends BaseUser = BaseUser>({
|
||||
children,
|
||||
user: initialUser = null,
|
||||
userEndpoint = '/api/auth/me/',
|
||||
routes,
|
||||
}: AuthContextProps<TUser>) {
|
||||
const [user, setUser] = useState<TUser | null>(initialUser)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const refresh = useCallback(async (): Promise<TUser | null> => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const resp = await client.request('GET', userEndpoint)
|
||||
if (resp.ok) {
|
||||
const userData = await resp.json()
|
||||
setUser(userData)
|
||||
return userData
|
||||
} else if (resp.status === 401 || resp.status === 403) {
|
||||
setUser(null)
|
||||
return null
|
||||
}
|
||||
throw new Error(`Failed to fetch user: ${resp.status}`)
|
||||
} catch (e) {
|
||||
console.error('[AuthContext] Failed to fetch user:', e)
|
||||
return null
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [userEndpoint])
|
||||
|
||||
const authState = useMemo<AuthState<TUser>>(() => ({
|
||||
user,
|
||||
isLoading,
|
||||
refresh,
|
||||
}), [user, isLoading, refresh])
|
||||
|
||||
const routesValue = useMemo(() => ({
|
||||
...defaultRoutes,
|
||||
...routes,
|
||||
}), [routes])
|
||||
|
||||
return (
|
||||
<RoutesContext value={routesValue}>
|
||||
<Context value={authState}>
|
||||
{children}
|
||||
</Context>
|
||||
</RoutesContext>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access auth state.
|
||||
* Throws if used outside AuthContext.
|
||||
*/
|
||||
export function useAuthState<TUser extends BaseUser = BaseUser>(): AuthState<TUser> {
|
||||
const ctx = useContext(Context)
|
||||
if (!ctx) throw new Error('useAuthState must be used within AuthContext')
|
||||
return ctx as AuthState<TUser>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access current user.
|
||||
* Returns null if not authenticated.
|
||||
*/
|
||||
export function useUser<TUser extends BaseUser = BaseUser>(): TUser | null {
|
||||
return useAuthState<TUser>().user
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access auth details (isAuthenticated, isStaff, etc.)
|
||||
*/
|
||||
export function useAuth(): AuthDetails {
|
||||
const user = useUser()
|
||||
return {
|
||||
isAuthenticated: user !== null,
|
||||
isStaff: user?.is_staff ?? false,
|
||||
isSuperuser: user?.is_superuser ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access route configuration.
|
||||
*/
|
||||
export function useAuthRoutes(): AuthRoutes {
|
||||
return useContext(RoutesContext)
|
||||
}
|
||||
43
legacy/RouterContext.tsx
Normal file
43
legacy/RouterContext.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Framework-agnostic router adapter.
|
||||
* Implement this interface for your framework (Next.js, Remix, etc.)
|
||||
*/
|
||||
export interface RouterAdapter {
|
||||
/** Navigate to a path (adds to history) */
|
||||
push: (path: string) => void
|
||||
/** Replace current path (no history entry) */
|
||||
replace: (path: string) => void
|
||||
/** Current pathname (e.g., "/account/login") */
|
||||
pathname: string
|
||||
/** Current search params */
|
||||
searchParams: URLSearchParams
|
||||
/** Get a specific route param (e.g., from /auth/[...path]) - optional */
|
||||
getParam?: (name: string) => string | string[] | undefined
|
||||
}
|
||||
|
||||
const Context = createContext<RouterAdapter | null>(null)
|
||||
|
||||
interface RouterContextProps {
|
||||
children: ReactNode
|
||||
router: RouterAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides router adapter to route guards.
|
||||
*/
|
||||
export function RouterContext({ children, router }: RouterContextProps) {
|
||||
return <Context value={router}>{children}</Context>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access router adapter.
|
||||
*/
|
||||
export function useRouter(): RouterAdapter {
|
||||
const ctx = useContext(Context)
|
||||
if (!ctx) throw new Error('useRouter must be used within RouterContext')
|
||||
return ctx
|
||||
}
|
||||
11
legacy/allauth/adapters/router.ts
Normal file
11
legacy/allauth/adapters/router.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Re-export RouterAdapter from mizan/client.
|
||||
*
|
||||
* Allauth extends this with a required getParam method.
|
||||
*/
|
||||
import type { RouterAdapter as BaseRouterAdapter } from 'mizan/client'
|
||||
|
||||
export interface RouterAdapter extends BaseRouterAdapter {
|
||||
/** Get a specific route param (e.g., from /auth/[...path]) - required for allauth */
|
||||
getParam: (name: string) => string | string[] | undefined
|
||||
}
|
||||
309
legacy/allauth/api.ts
Normal file
309
legacy/allauth/api.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { OAuthProcess, apiURL } from './defines'
|
||||
|
||||
import {
|
||||
type RegistrationResponseJSON,
|
||||
type AuthenticationResponseJSON,
|
||||
} from '@simplewebauthn/browser'
|
||||
|
||||
import type {
|
||||
// Core types
|
||||
AuthError,
|
||||
User,
|
||||
Flow,
|
||||
Authenticated,
|
||||
AuthenticationMeta,
|
||||
// Request types
|
||||
LoginRequest,
|
||||
SignupRequest,
|
||||
ProviderSignupRequest,
|
||||
ReauthenticateRequest,
|
||||
ChangePasswordRequest,
|
||||
ResetPasswordRequest,
|
||||
MFAAuthenticateRequest,
|
||||
WebAuthnUpdateRequest,
|
||||
// Response types
|
||||
AllauthResponse,
|
||||
AuthenticatedResponse,
|
||||
AuthenticationRequiredResponse,
|
||||
ReauthenticationRequiredResponse,
|
||||
ConfigurationResponse,
|
||||
EmailListResponse,
|
||||
SessionListResponse,
|
||||
AuthenticatorListResponse,
|
||||
ProviderAccountListResponse,
|
||||
TOTPStatusResponse,
|
||||
RecoveryCodesResponse,
|
||||
WebAuthnCreationOptionsResponse,
|
||||
WebAuthnRequestOptionsResponse,
|
||||
EmailVerificationInfoResponse,
|
||||
ErrorResponse,
|
||||
} from './types'
|
||||
|
||||
export type { AuthError } from './types'
|
||||
|
||||
// Registration = creating new credentials (signup, add)
|
||||
// Authentication = verifying existing credentials (login, authenticate, reauthenticate)
|
||||
type RegistrationCredential = RegistrationResponseJSON
|
||||
type AuthenticationCredential = AuthenticationResponseJSON
|
||||
|
||||
/**
|
||||
* Union of all possible auth responses
|
||||
*/
|
||||
export type AuthResponse =
|
||||
| AuthenticatedResponse
|
||||
| AuthenticationRequiredResponse
|
||||
| ReauthenticationRequiredResponse
|
||||
| ConfigurationResponse
|
||||
| EmailListResponse
|
||||
| SessionListResponse
|
||||
| AuthenticatorListResponse
|
||||
| ProviderAccountListResponse
|
||||
| TOTPStatusResponse
|
||||
| RecoveryCodesResponse
|
||||
| WebAuthnCreationOptionsResponse
|
||||
| WebAuthnRequestOptionsResponse
|
||||
| EmailVerificationInfoResponse
|
||||
| ErrorResponse
|
||||
| AllauthResponse
|
||||
|
||||
export interface AuthDetails {
|
||||
isAuthenticated: boolean
|
||||
requiresReauthentication: boolean
|
||||
user: User | null
|
||||
pendingFlow: Flow | undefined
|
||||
}
|
||||
|
||||
export const getAuthDetails = (auth: AllauthResponse | null | undefined): AuthDetails => {
|
||||
const meta = auth?.meta as AuthenticationMeta | undefined
|
||||
const isAuthenticated = !!auth && (auth?.status === 200 || (auth?.status === 401 && !!meta?.is_authenticated))
|
||||
const requiresReauthentication = !!(isAuthenticated && auth?.status === 401)
|
||||
const data = auth?.data as Authenticated | { flows?: Flow[]; user?: User } | undefined
|
||||
const pendingFlow = (data as { flows?: Flow[] })?.flows?.find((flow: Flow) => flow.is_pending)
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
requiresReauthentication,
|
||||
user: isAuthenticated ? (data as Authenticated)?.user ?? null : null,
|
||||
pendingFlow
|
||||
}
|
||||
}
|
||||
|
||||
export type BrowserFormAction = (action: string, data: Record<string, string>) => void
|
||||
|
||||
type RequestFn = (method: string, path: string, data?: unknown, headers?: Record<string, string>) => Promise<AllauthResponse>
|
||||
|
||||
export const createAPI = (
|
||||
request: RequestFn,
|
||||
browserFormAction?: BrowserFormAction
|
||||
) => {
|
||||
return {
|
||||
getConfig: async (): Promise<ConfigurationResponse | ErrorResponse> =>
|
||||
await request('GET', apiURL.CONFIG) as ConfigurationResponse | ErrorResponse,
|
||||
|
||||
session: {
|
||||
getStatus: async (): Promise<AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse> =>
|
||||
await request('GET', apiURL.SESSION) as AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse,
|
||||
|
||||
list: async (): Promise<SessionListResponse | ErrorResponse> =>
|
||||
await request('GET', apiURL.SESSIONS) as SessionListResponse | ErrorResponse,
|
||||
|
||||
logout: async (): Promise<AllauthResponse> =>
|
||||
await request('DELETE', apiURL.SESSION),
|
||||
|
||||
remove: async (ids: number[]): Promise<AllauthResponse> =>
|
||||
await request('DELETE', apiURL.SESSIONS, { sessions: ids }),
|
||||
},
|
||||
|
||||
account: {
|
||||
signup: async (data: SignupRequest): Promise<AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse> =>
|
||||
await request('POST', apiURL.SIGNUP, data) as AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse,
|
||||
|
||||
login: async (data: LoginRequest): Promise<AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse> =>
|
||||
await request('POST', apiURL.LOGIN, data) as AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse,
|
||||
|
||||
reauthenticate: async (data: ReauthenticateRequest): Promise<AuthenticatedResponse | ErrorResponse> =>
|
||||
await request('POST', apiURL.REAUTHENTICATE, data) as AuthenticatedResponse | ErrorResponse,
|
||||
|
||||
emails: {
|
||||
list: async (): Promise<EmailListResponse | ErrorResponse> =>
|
||||
await request('GET', apiURL.EMAIL) as EmailListResponse | ErrorResponse,
|
||||
|
||||
add: async (email: string): Promise<EmailListResponse | ErrorResponse> =>
|
||||
await request('POST', apiURL.EMAIL, { email }) as EmailListResponse | ErrorResponse,
|
||||
|
||||
remove: async (email: string): Promise<EmailListResponse | ErrorResponse> =>
|
||||
await request('DELETE', apiURL.EMAIL, { email }) as EmailListResponse | ErrorResponse,
|
||||
|
||||
setPrimary: async (email: string): Promise<EmailListResponse | ErrorResponse> =>
|
||||
await request('PATCH', apiURL.EMAIL, { email, primary: true }) as EmailListResponse | ErrorResponse,
|
||||
|
||||
verification: {
|
||||
dispatch: async (email: string): Promise<AllauthResponse> =>
|
||||
await request('PUT', apiURL.EMAIL, { email }),
|
||||
|
||||
checkKey: async (key: string): Promise<EmailVerificationInfoResponse | ErrorResponse> =>
|
||||
await request('GET', apiURL.VERIFY_EMAIL, undefined, { 'X-Email-Verification-Key': key }) as EmailVerificationInfoResponse | ErrorResponse,
|
||||
|
||||
confirmKey: async (key: string): Promise<AuthenticatedResponse | ErrorResponse> =>
|
||||
await request('POST', apiURL.VERIFY_EMAIL, { key }) as AuthenticatedResponse | ErrorResponse,
|
||||
}
|
||||
},
|
||||
|
||||
password: {
|
||||
set: async (data: ResetPasswordRequest): Promise<AuthenticatedResponse | ErrorResponse> =>
|
||||
await request('POST', apiURL.RESET_PASSWORD, data) as AuthenticatedResponse | ErrorResponse,
|
||||
|
||||
change: async (data: ChangePasswordRequest): Promise<AllauthResponse> =>
|
||||
await request('POST', apiURL.CHANGE_PASSWORD, data),
|
||||
|
||||
reset: {
|
||||
dispatch: async (email: string): Promise<AllauthResponse> =>
|
||||
await request('POST', apiURL.REQUEST_PASSWORD_RESET, { email }),
|
||||
|
||||
checkKey: async (key: string): Promise<AllauthResponse> =>
|
||||
await request('GET', apiURL.RESET_PASSWORD, undefined, { 'X-Password-Reset-Key': key }),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
loginCodes: {
|
||||
request: async (email: string): Promise<AuthenticationRequiredResponse | ErrorResponse> =>
|
||||
await request('POST', apiURL.REQUEST_LOGIN_CODE, { email }) as AuthenticationRequiredResponse | ErrorResponse,
|
||||
|
||||
confirm: async (code: string): Promise<AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse> =>
|
||||
await request('POST', apiURL.CONFIRM_LOGIN_CODE, { code }) as AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse,
|
||||
},
|
||||
|
||||
oauth: {
|
||||
list: async (): Promise<ProviderAccountListResponse | ErrorResponse> =>
|
||||
await request('GET', apiURL.PROVIDERS) as ProviderAccountListResponse | ErrorResponse,
|
||||
|
||||
signup: async (data: ProviderSignupRequest): Promise<AuthenticatedResponse | ErrorResponse> =>
|
||||
await request('POST', apiURL.PROVIDER_SIGNUP, data) as AuthenticatedResponse | ErrorResponse,
|
||||
|
||||
provider: (providerID: string) => {
|
||||
const buildAuths = (processType: string) => {
|
||||
return {
|
||||
withToken: async (token: string): Promise<AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse> =>
|
||||
await request(
|
||||
'POST',
|
||||
apiURL.PROVIDER_TOKEN,
|
||||
{
|
||||
provider: providerID,
|
||||
process: processType,
|
||||
token: token,
|
||||
}
|
||||
) as AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse,
|
||||
|
||||
withRedirect: (endpoint: string): void => {
|
||||
if (browserFormAction) {
|
||||
if (!process.env.NEXT_PUBLIC_HOST_URL) {
|
||||
throw new Error('NEXT_PUBLIC_HOST_URL environment variable is not set. OAuth redirects require this to be set at build time.')
|
||||
}
|
||||
browserFormAction(
|
||||
apiURL.REDIRECT_TO_PROVIDER,
|
||||
{
|
||||
provider: providerID,
|
||||
process: processType,
|
||||
callback_url: new URL(`${process.env.NEXT_PUBLIC_HOST_URL}/${endpoint}`).toString(),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
removeFrom: async (accountUID: string): Promise<ProviderAccountListResponse | ErrorResponse> =>
|
||||
await request('DELETE', apiURL.PROVIDERS, { provider: providerID, account: accountUID }) as ProviderAccountListResponse | ErrorResponse,
|
||||
|
||||
login: buildAuths(OAuthProcess.LOGIN),
|
||||
connect: buildAuths(OAuthProcess.CONNECT),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mfa: {
|
||||
list: async (): Promise<AuthenticatorListResponse | ErrorResponse> =>
|
||||
await request('GET', apiURL.AUTHENTICATORS) as AuthenticatorListResponse | ErrorResponse,
|
||||
|
||||
authenticate: async (code: string): Promise<AuthenticatedResponse | ErrorResponse> =>
|
||||
await request('POST', apiURL.MFA_AUTHENTICATE, { code } as MFAAuthenticateRequest) as AuthenticatedResponse | ErrorResponse,
|
||||
|
||||
reauthenticate: async (code: string): Promise<AuthenticatedResponse | ErrorResponse> =>
|
||||
await request('POST', apiURL.MFA_REAUTHENTICATE, { code } as MFAAuthenticateRequest) as AuthenticatedResponse | ErrorResponse,
|
||||
|
||||
trust: async (trust: boolean): Promise<AllauthResponse> =>
|
||||
await request('POST', apiURL.MFA_TRUST, { trust }),
|
||||
|
||||
totp: {
|
||||
getStatus: async (): Promise<TOTPStatusResponse | ErrorResponse> =>
|
||||
await request('GET', apiURL.TOTP_AUTHENTICATOR) as TOTPStatusResponse | ErrorResponse,
|
||||
|
||||
activate: async (code: string): Promise<TOTPStatusResponse | ErrorResponse> =>
|
||||
await request('POST', apiURL.TOTP_AUTHENTICATOR, { code }) as TOTPStatusResponse | ErrorResponse,
|
||||
|
||||
deactivate: async (): Promise<AllauthResponse> =>
|
||||
await request('DELETE', apiURL.TOTP_AUTHENTICATOR),
|
||||
},
|
||||
|
||||
recoveryCodes: {
|
||||
list: async (): Promise<RecoveryCodesResponse | ErrorResponse> =>
|
||||
await request('GET', apiURL.RECOVERY_CODES) as RecoveryCodesResponse | ErrorResponse,
|
||||
|
||||
regenerate: async (): Promise<RecoveryCodesResponse | ErrorResponse> =>
|
||||
await request('POST', apiURL.RECOVERY_CODES) as RecoveryCodesResponse | ErrorResponse,
|
||||
}
|
||||
},
|
||||
|
||||
webauthn: {
|
||||
signup: async (name: string, credential: RegistrationCredential): Promise<AuthenticatedResponse | ErrorResponse> =>
|
||||
await request('PUT', apiURL.SIGNUP_WEBAUTHN, { name, credential }) as AuthenticatedResponse | ErrorResponse,
|
||||
|
||||
add: async (name: string, credential: RegistrationCredential): Promise<AllauthResponse> =>
|
||||
await request('POST', apiURL.WEBAUTHN_AUTHENTICATOR, { name, credential }),
|
||||
|
||||
login: async (credential: AuthenticationCredential): Promise<AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse> =>
|
||||
await request('POST', apiURL.LOGIN_WEBAUTHN, { credential }) as AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse,
|
||||
|
||||
authenticate: async (credential: AuthenticationCredential): Promise<AuthenticatedResponse | ErrorResponse> =>
|
||||
await request('POST', apiURL.AUTHENTICATE_WEBAUTHN, { credential }) as AuthenticatedResponse | ErrorResponse,
|
||||
|
||||
reauthenticate: async (credential: AuthenticationCredential): Promise<AuthenticatedResponse | ErrorResponse> =>
|
||||
await request('POST', apiURL.REAUTHENTICATE_WEBAUTHN, { credential }) as AuthenticatedResponse | ErrorResponse,
|
||||
|
||||
update: async (id: number, data: Omit<WebAuthnUpdateRequest, 'id'>): Promise<AllauthResponse> =>
|
||||
await request('PUT', apiURL.WEBAUTHN_AUTHENTICATOR, { id, ...data }),
|
||||
|
||||
delete: async (ids: number[]): Promise<AllauthResponse> =>
|
||||
await request('DELETE', apiURL.WEBAUTHN_AUTHENTICATOR, { authenticators: ids }),
|
||||
|
||||
passkey: {
|
||||
signup: async (email: string): Promise<AllauthResponse> =>
|
||||
await request('POST', apiURL.SIGNUP_WEBAUTHN, { email }),
|
||||
|
||||
confirm: async (): Promise<AuthenticatedResponse | ErrorResponse> =>
|
||||
await request('PUT', apiURL.SIGNUP_WEBAUTHN) as AuthenticatedResponse | ErrorResponse,
|
||||
},
|
||||
|
||||
requestOptions: {
|
||||
creation: async (passwordless: boolean): Promise<WebAuthnCreationOptionsResponse | ErrorResponse> =>
|
||||
await request('GET', apiURL.WEBAUTHN_AUTHENTICATOR + (passwordless ? '?passwordless' : '')) as WebAuthnCreationOptionsResponse | ErrorResponse,
|
||||
|
||||
creationAtSignup: async (): Promise<WebAuthnCreationOptionsResponse | ErrorResponse> =>
|
||||
await request('GET', apiURL.SIGNUP_WEBAUTHN) as WebAuthnCreationOptionsResponse | ErrorResponse,
|
||||
|
||||
login: async (): Promise<WebAuthnRequestOptionsResponse | ErrorResponse> =>
|
||||
await request('GET', apiURL.LOGIN_WEBAUTHN) as WebAuthnRequestOptionsResponse | ErrorResponse,
|
||||
|
||||
authentication: async (): Promise<WebAuthnRequestOptionsResponse | ErrorResponse> =>
|
||||
await request('GET', apiURL.AUTHENTICATE_WEBAUTHN) as WebAuthnRequestOptionsResponse | ErrorResponse,
|
||||
|
||||
reauthentication: async (): Promise<WebAuthnRequestOptionsResponse | ErrorResponse> =>
|
||||
await request('GET', apiURL.REAUTHENTICATE_WEBAUTHN) as WebAuthnRequestOptionsResponse | ErrorResponse,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type AllauthAPI = ReturnType<typeof createAPI>
|
||||
220
legacy/allauth/components/AllauthRouter.tsx
Normal file
220
legacy/allauth/components/AllauthRouter.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from '../contexts/RouterContext'
|
||||
import { useAllauthAPI } from '../contexts/APIContext'
|
||||
import { useAllauthConfig } from '../contexts/ConfigContext'
|
||||
import { DjangoFlowPaths } from '../config'
|
||||
import { AuthCard } from './AuthCard'
|
||||
import { AuthDjangoForm } from './AuthDjangoForm'
|
||||
|
||||
interface AllauthRouterProps {
|
||||
/** Called after successful completion of any flow */
|
||||
onComplete?: () => void
|
||||
/** Called when user wants to go back to login */
|
||||
onLoginClick?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* AllauthRouter handles Django-initiated flows (email verification, password reset, OAuth).
|
||||
*
|
||||
* Mount this at a catch-all route matching your basePath config:
|
||||
* app/auth/[...path]/page.tsx -> <AllauthRouter />
|
||||
*
|
||||
* The path determines which flow to render:
|
||||
* /auth/verify-email/[key] -> Email verification
|
||||
* /auth/reset-password?key=xxx -> Password reset form
|
||||
* /auth/oauth/callback -> OAuth completion
|
||||
*/
|
||||
export function AllauthRouter({ onComplete, onLoginClick }: AllauthRouterProps) {
|
||||
const router = useRouter()
|
||||
const config = useAllauthConfig()
|
||||
|
||||
// Parse the path segments after basePath
|
||||
// The router provides getParam('path') which returns the catch-all segments
|
||||
const pathParam = router.getParam('path')
|
||||
const pathSegments = Array.isArray(pathParam) ? pathParam : pathParam ? [pathParam] : []
|
||||
const path = pathSegments.length > 0 ? `/${pathSegments.join('/')}` : '/'
|
||||
|
||||
// Determine which flow based on path
|
||||
if (path.startsWith(DjangoFlowPaths.VERIFY_EMAIL)) {
|
||||
const key = pathSegments[1] || router.searchParams.get('key')
|
||||
return (
|
||||
<EmailVerifyView
|
||||
verificationKey={key}
|
||||
onComplete={onComplete}
|
||||
onLoginClick={onLoginClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (path.startsWith(DjangoFlowPaths.RESET_PASSWORD)) {
|
||||
const key = pathSegments[1] || router.searchParams.get('key')
|
||||
return (
|
||||
<PasswordResetView
|
||||
resetKey={key}
|
||||
onComplete={onComplete}
|
||||
onLoginClick={onLoginClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (path.startsWith(DjangoFlowPaths.OAUTH_ERROR)) {
|
||||
return (
|
||||
<OAuthErrorView
|
||||
onLoginClick={onLoginClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Unknown path
|
||||
return (
|
||||
<AuthCard
|
||||
title="Not Found"
|
||||
subtitle="This page doesn't exist."
|
||||
footerLinks={onLoginClick ? [
|
||||
{ label: 'Back to Sign In', onClick: onLoginClick },
|
||||
] : []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Email Verification View
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
interface EmailVerifyViewProps {
|
||||
verificationKey: string | null | undefined
|
||||
onComplete?: () => void
|
||||
onLoginClick?: () => void
|
||||
}
|
||||
|
||||
function EmailVerifyView({ verificationKey, onComplete, onLoginClick }: EmailVerifyViewProps) {
|
||||
const api = useAllauthAPI()
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!verificationKey) {
|
||||
setStatus('error')
|
||||
setError('Invalid verification link')
|
||||
return
|
||||
}
|
||||
|
||||
const verify = async () => {
|
||||
const res = await api.account.emails.verification.confirmKey(verificationKey)
|
||||
|
||||
if (res.status === 200) {
|
||||
setStatus('success')
|
||||
if (onComplete) {
|
||||
setTimeout(onComplete, 2000)
|
||||
}
|
||||
} else {
|
||||
setStatus('error')
|
||||
setError(res.errors?.[0]?.message || 'Invalid or expired verification link')
|
||||
}
|
||||
}
|
||||
|
||||
verify()
|
||||
}, [verificationKey, api, onComplete])
|
||||
|
||||
if (status === 'loading') {
|
||||
return <AuthCard title="" loading loadingText="Verifying your email..." />
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<AuthCard
|
||||
title="Email Verified"
|
||||
subtitle="Your email has been verified successfully."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthCard
|
||||
title="Verification Failed"
|
||||
error={error}
|
||||
footerLinks={onLoginClick ? [
|
||||
{ label: 'Back to Sign In', onClick: onLoginClick },
|
||||
] : []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Password Reset View
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
interface PasswordResetViewProps {
|
||||
resetKey: string | null | undefined
|
||||
onComplete?: () => void
|
||||
onLoginClick?: () => void
|
||||
}
|
||||
|
||||
function PasswordResetView({ resetKey, onComplete, onLoginClick }: PasswordResetViewProps) {
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
if (!resetKey) {
|
||||
return (
|
||||
<AuthCard
|
||||
title="Invalid Link"
|
||||
subtitle="This password reset link is invalid or has expired."
|
||||
footerLinks={onLoginClick ? [
|
||||
{ label: 'Back to Sign In', onClick: onLoginClick },
|
||||
] : []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<AuthCard
|
||||
title="Password Changed"
|
||||
subtitle="Your password has been successfully reset."
|
||||
footerLinks={onLoginClick ? [
|
||||
{ label: 'Sign In', onClick: onLoginClick },
|
||||
] : []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthDjangoForm
|
||||
formName="reset_password_from_key"
|
||||
onSuccess={() => {
|
||||
setSuccess(true)
|
||||
// Give user time to see success message before redirect
|
||||
if (onComplete) {
|
||||
setTimeout(onComplete, 2000)
|
||||
}
|
||||
}}
|
||||
footerLinks={onLoginClick ? [
|
||||
{ href: '#', label: 'Back to Sign In', onClick: onLoginClick },
|
||||
] : []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// OAuth Error View
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
interface OAuthErrorViewProps {
|
||||
onLoginClick?: () => void
|
||||
}
|
||||
|
||||
function OAuthErrorView({ onLoginClick }: OAuthErrorViewProps) {
|
||||
const router = useRouter()
|
||||
const error = router.searchParams.get('error') || 'An error occurred during authentication'
|
||||
|
||||
return (
|
||||
<AuthCard
|
||||
title="Authentication Failed"
|
||||
error={error}
|
||||
footerLinks={onLoginClick ? [
|
||||
{ label: 'Back to Sign In', onClick: onLoginClick },
|
||||
] : []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
447
legacy/allauth/components/AllauthUI.tsx
Normal file
447
legacy/allauth/components/AllauthUI.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useAuth, useAuthContext, useFeatures } from '../contexts/AuthContext'
|
||||
import { useAllauthAPI } from '../contexts/APIContext'
|
||||
import { useStyles } from '../contexts/StylesContext'
|
||||
import { getAuthDetails } from '../api'
|
||||
import { AuthenticatorType } from '../defines'
|
||||
import { AuthSettings } from './settings/AuthSettings'
|
||||
import { AuthCard } from './AuthCard'
|
||||
import { AuthDjangoForm } from './AuthDjangoForm'
|
||||
import { Button } from './settings/SettingsComponents'
|
||||
import { LoginView } from './views/LoginView'
|
||||
import { SignupView } from './views/SignupView'
|
||||
import { MFAChooserView } from './views/MFAChooserView'
|
||||
import { MFAWebAuthnView } from './views/MFAWebAuthnView'
|
||||
import { MFATOTPView } from './views/MFATOTPView'
|
||||
import { MFARecoveryCodesView } from './views/MFARecoveryCodesView'
|
||||
|
||||
/**
|
||||
* All possible views in the AllauthUI component.
|
||||
* Views are rendered based on state, not URLs.
|
||||
*/
|
||||
export type AllauthUIView =
|
||||
// Auth views (for unauthenticated users)
|
||||
| 'login'
|
||||
| 'signup'
|
||||
| 'resetPassword'
|
||||
| 'resetPasswordSent'
|
||||
| 'requestCode'
|
||||
| 'confirmCode'
|
||||
// MFA views (during auth flow)
|
||||
| 'mfaChooser'
|
||||
| 'mfaTotp'
|
||||
| 'mfaWebauthn'
|
||||
| 'mfaRecoveryCodes'
|
||||
// Authenticated views
|
||||
| 'settings'
|
||||
| 'logout'
|
||||
|
||||
/**
|
||||
* Controls how AllauthUI behaves regarding auth/settings transitions.
|
||||
*
|
||||
* - `'auto'` (default): Full SPA - shows auth views when not authenticated,
|
||||
* automatically transitions to settings after login, and back to login after logout.
|
||||
*
|
||||
* - `'auth'`: Auth-only mode - only shows auth views (login, signup, MFA, etc.).
|
||||
* Never shows settings. Use `onAuthenticated` to handle post-login navigation.
|
||||
* Ideal for a dedicated login page.
|
||||
*
|
||||
* - `'settings'`: Settings-only mode - only shows settings views.
|
||||
* If not authenticated, calls `onUnauthenticated` or shows nothing.
|
||||
* Ideal for a dedicated settings page.
|
||||
*/
|
||||
export type AllauthUIMode = 'auto' | 'auth' | 'settings'
|
||||
|
||||
interface AllauthUIProps {
|
||||
/**
|
||||
* Controls auth/settings transition behavior.
|
||||
* @default 'auto'
|
||||
*/
|
||||
mode?: AllauthUIMode
|
||||
|
||||
/**
|
||||
* Initial view when component mounts (for 'auto' and 'auth' modes).
|
||||
* Defaults to 'login' for unauthenticated, 'settings' for authenticated (in auto mode).
|
||||
*/
|
||||
initialView?: AllauthUIView
|
||||
|
||||
/**
|
||||
* Called when authentication completes successfully.
|
||||
* Required for 'auth' mode to handle post-login navigation.
|
||||
*/
|
||||
onAuthenticated?: () => void
|
||||
|
||||
/**
|
||||
* Called when user is not authenticated (for 'settings' mode).
|
||||
* Use this to redirect to login page.
|
||||
*/
|
||||
onUnauthenticated?: () => void
|
||||
|
||||
/**
|
||||
* Called when user logs out.
|
||||
* In 'auto' mode, defaults to showing login view.
|
||||
*/
|
||||
onLogout?: () => void
|
||||
|
||||
/**
|
||||
* Which settings sections to show.
|
||||
* Defaults to all sections.
|
||||
*/
|
||||
settingsSections?: Array<'profile' | 'emails' | 'password' | 'passkeys' | 'connections' | 'mfa' | 'sessions'>
|
||||
|
||||
/**
|
||||
* OAuth callback URL for social login providers.
|
||||
*/
|
||||
oauthCallbackUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* AllauthUI is the main component for rendering auth UI.
|
||||
*
|
||||
* It can operate in three modes:
|
||||
* - `'auto'` (default): Full SPA handling login, MFA, settings, and logout
|
||||
* - `'auth'`: Auth-only for dedicated login pages
|
||||
* - `'settings'`: Settings-only for dedicated settings pages
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Full SPA mode (default) - handles everything
|
||||
* <AllauthUI />
|
||||
*
|
||||
* // Auth-only mode - for a dedicated login page
|
||||
* <AllauthUI mode="auth" onAuthenticated={() => router.push('/dashboard')} />
|
||||
*
|
||||
* // Settings-only mode - for a dedicated settings page
|
||||
* <AllauthUI mode="settings" onUnauthenticated={() => router.push('/login')} />
|
||||
* ```
|
||||
*/
|
||||
export function AllauthUI({
|
||||
mode = 'auto',
|
||||
initialView,
|
||||
onAuthenticated,
|
||||
onUnauthenticated,
|
||||
onLogout,
|
||||
settingsSections,
|
||||
oauthCallbackUrl,
|
||||
}: AllauthUIProps) {
|
||||
const { isAuthenticated, pendingFlow } = useAuth()
|
||||
const { refresh } = useAuthContext()
|
||||
const api = useAllauthAPI()
|
||||
const styles = useStyles()
|
||||
const features = useFeatures()
|
||||
|
||||
// Get available MFA types from pending flow
|
||||
const mfaTypes = pendingFlow?.types || []
|
||||
|
||||
// Internal view state
|
||||
const [view, setView] = useState<AllauthUIView>(() => {
|
||||
if (initialView) return initialView
|
||||
|
||||
// Settings mode always starts at settings
|
||||
if (mode === 'settings') return 'settings'
|
||||
|
||||
// Auth mode always starts at login (or MFA if pending)
|
||||
if (mode === 'auth') {
|
||||
if (pendingFlow) {
|
||||
return mfaTypes.length === 1 ? getMFAView(mfaTypes[0]) : 'mfaChooser'
|
||||
}
|
||||
return 'login'
|
||||
}
|
||||
|
||||
// Auto mode: settings if authenticated, login otherwise
|
||||
if (isAuthenticated) return 'settings'
|
||||
if (pendingFlow) {
|
||||
return mfaTypes.length === 1 ? getMFAView(mfaTypes[0]) : 'mfaChooser'
|
||||
}
|
||||
return 'login'
|
||||
})
|
||||
|
||||
// Track auth state changes
|
||||
const wasAuthenticated = useRef(isAuthenticated)
|
||||
const hadPendingFlow = useRef(!!pendingFlow)
|
||||
|
||||
// Handle auth state transitions
|
||||
useEffect(() => {
|
||||
// User just became authenticated
|
||||
if (!wasAuthenticated.current && isAuthenticated) {
|
||||
if (onAuthenticated) {
|
||||
onAuthenticated()
|
||||
} else if (mode === 'auto') {
|
||||
setView('settings')
|
||||
}
|
||||
// In 'auth' mode without onAuthenticated, do nothing (stay on current view)
|
||||
}
|
||||
|
||||
// User just logged out
|
||||
if (wasAuthenticated.current && !isAuthenticated) {
|
||||
if (onLogout) {
|
||||
onLogout()
|
||||
} else if (mode === 'auto') {
|
||||
setView('login')
|
||||
} else if (mode === 'settings' && onUnauthenticated) {
|
||||
onUnauthenticated()
|
||||
}
|
||||
}
|
||||
|
||||
wasAuthenticated.current = isAuthenticated
|
||||
}, [isAuthenticated, onAuthenticated, onUnauthenticated, onLogout, mode])
|
||||
|
||||
// Handle MFA flow transitions
|
||||
useEffect(() => {
|
||||
if (pendingFlow && !hadPendingFlow.current) {
|
||||
// New MFA flow started
|
||||
if (mfaTypes.length === 1) {
|
||||
setView(getMFAView(mfaTypes[0]))
|
||||
} else if (mfaTypes.length > 1) {
|
||||
setView('mfaChooser')
|
||||
}
|
||||
}
|
||||
if (!pendingFlow && hadPendingFlow.current && isAuthenticated) {
|
||||
// MFA completed successfully
|
||||
if (onAuthenticated) {
|
||||
onAuthenticated()
|
||||
} else if (mode === 'auto') {
|
||||
setView('settings')
|
||||
}
|
||||
}
|
||||
hadPendingFlow.current = !!pendingFlow
|
||||
}, [pendingFlow, mfaTypes, isAuthenticated, onAuthenticated, mode])
|
||||
|
||||
// Settings mode: handle unauthenticated state
|
||||
useEffect(() => {
|
||||
if (mode === 'settings' && !isAuthenticated && onUnauthenticated) {
|
||||
onUnauthenticated()
|
||||
}
|
||||
}, [mode, isAuthenticated, onUnauthenticated])
|
||||
|
||||
// Handle logout
|
||||
const handleLogout = async () => {
|
||||
await api.session.logout()
|
||||
await refresh()
|
||||
if (onLogout) {
|
||||
onLogout()
|
||||
} else if (mode === 'auto') {
|
||||
setView('login')
|
||||
}
|
||||
// In settings mode, the useEffect will call onUnauthenticated
|
||||
}
|
||||
|
||||
// Called after successful login/signup - check for MFA or complete auth
|
||||
const handleAuthSuccess = async () => {
|
||||
const newAuth = await refresh()
|
||||
const details = getAuthDetails(newAuth)
|
||||
|
||||
// If fully authenticated, handle completion
|
||||
if (details.isAuthenticated) {
|
||||
if (onAuthenticated) {
|
||||
onAuthenticated()
|
||||
} else if (mode === 'auto') {
|
||||
setView('settings')
|
||||
}
|
||||
// In 'auth' mode without onAuthenticated, stay on current view
|
||||
}
|
||||
// If MFA pending, the useEffect will handle the view transition
|
||||
}
|
||||
|
||||
// Render based on current view
|
||||
switch (view) {
|
||||
// ============================================
|
||||
// Authenticated views
|
||||
// ============================================
|
||||
case 'settings':
|
||||
// In auth mode, never show settings
|
||||
if (mode === 'auth') {
|
||||
return null
|
||||
}
|
||||
// Not authenticated - handle based on mode
|
||||
if (!isAuthenticated) {
|
||||
if (mode === 'settings' && onUnauthenticated) {
|
||||
// Will be handled by useEffect
|
||||
return null
|
||||
}
|
||||
// Auto mode: switch to login
|
||||
setView('login')
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<AuthSettings
|
||||
sections={settingsSections}
|
||||
onSignOut={() => setView('logout')}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'logout':
|
||||
if (!isAuthenticated) {
|
||||
if (mode === 'auto') {
|
||||
setView('login')
|
||||
}
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<AuthCard
|
||||
title="Sign Out"
|
||||
subtitle="Are you sure you want to sign out?"
|
||||
footerLinks={[
|
||||
{ label: 'Cancel', onClick: () => setView('settings') },
|
||||
]}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<Button onClick={handleLogout}>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</AuthCard>
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// MFA views
|
||||
// ============================================
|
||||
case 'mfaChooser':
|
||||
return (
|
||||
<MFAChooserView
|
||||
types={mfaTypes}
|
||||
onSuccess={handleAuthSuccess}
|
||||
onCancel={() => setView('login')}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'mfaTotp':
|
||||
return (
|
||||
<MFATOTPView
|
||||
onSuccess={handleAuthSuccess}
|
||||
onCancel={() => setView('login')}
|
||||
onBack={mfaTypes.length > 1 ? () => setView('mfaChooser') : undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'mfaWebauthn':
|
||||
return (
|
||||
<MFAWebAuthnView
|
||||
onSuccess={handleAuthSuccess}
|
||||
onCancel={() => setView('login')}
|
||||
onBack={mfaTypes.length > 1 ? () => setView('mfaChooser') : undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'mfaRecoveryCodes':
|
||||
return (
|
||||
<MFARecoveryCodesView
|
||||
onSuccess={handleAuthSuccess}
|
||||
onCancel={() => setView('login')}
|
||||
onBack={mfaTypes.length > 1 ? () => setView('mfaChooser') : undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Password reset views
|
||||
// ============================================
|
||||
case 'resetPassword':
|
||||
return (
|
||||
<AuthDjangoForm
|
||||
formName="reset_password"
|
||||
onSuccess={() => setView('resetPasswordSent')}
|
||||
footerLinks={[
|
||||
{ label: 'Back to Sign In', onClick: () => setView('login') },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'resetPasswordSent':
|
||||
return (
|
||||
<AuthCard
|
||||
title="Check Your Email"
|
||||
subtitle="If an account exists with that email, we've sent password reset instructions."
|
||||
footerLinks={[
|
||||
{ label: 'Back to Sign In', onClick: () => setView('login') },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Login by code views
|
||||
// ============================================
|
||||
case 'requestCode':
|
||||
// If login by code is disabled, redirect to login
|
||||
if (!features.loginByCodeEnabled) {
|
||||
setView('login')
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<AuthDjangoForm
|
||||
formName="request_login_code"
|
||||
onSuccess={() => setView('confirmCode')}
|
||||
footerLinks={[
|
||||
{ label: 'Sign in with password instead', onClick: () => setView('login') },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'confirmCode':
|
||||
// If login by code is disabled, redirect to login
|
||||
if (!features.loginByCodeEnabled) {
|
||||
setView('login')
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<AuthDjangoForm
|
||||
formName="confirm_login_code"
|
||||
onSuccess={handleAuthSuccess}
|
||||
footerLinks={[
|
||||
{ label: 'Request a new code', onClick: () => setView('requestCode') },
|
||||
{ label: 'Sign in with password instead', onClick: () => setView('login') },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Signup view
|
||||
// ============================================
|
||||
case 'signup':
|
||||
// If signup is disabled, redirect to login
|
||||
if (!features.signupEnabled) {
|
||||
setView('login')
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<SignupView
|
||||
onSuccess={handleAuthSuccess}
|
||||
onLoginClick={() => setView('login')}
|
||||
/>
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Login view (default)
|
||||
// ============================================
|
||||
case 'login':
|
||||
default:
|
||||
return (
|
||||
<LoginView
|
||||
onSuccess={handleAuthSuccess}
|
||||
// Only provide signup callback if signups are enabled
|
||||
onSignupClick={features.signupEnabled ? () => setView('signup') : undefined}
|
||||
onForgotPasswordClick={() => setView('resetPassword')}
|
||||
// Only provide login-by-code callback if feature is enabled
|
||||
onLoginByCodeClick={features.loginByCodeEnabled ? () => setView('requestCode') : undefined}
|
||||
oauthCallbackUrl={oauthCallbackUrl}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the view name for a given MFA authenticator type.
|
||||
*/
|
||||
function getMFAView(type: string): AllauthUIView {
|
||||
switch (type) {
|
||||
case AuthenticatorType.TOTP:
|
||||
return 'mfaTotp'
|
||||
case AuthenticatorType.WEBAUTHN:
|
||||
return 'mfaWebauthn'
|
||||
case AuthenticatorType.RECOVERY_CODES:
|
||||
return 'mfaRecoveryCodes'
|
||||
default:
|
||||
return 'mfaChooser'
|
||||
}
|
||||
}
|
||||
85
legacy/allauth/components/AuthCard.tsx
Normal file
85
legacy/allauth/components/AuthCard.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import { useRouter } from '../contexts/RouterContext'
|
||||
import { useStyles } from '../contexts/StylesContext'
|
||||
|
||||
interface FooterLink {
|
||||
label: string
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
interface AuthCardProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
children?: ReactNode
|
||||
footerLinks?: FooterLink[]
|
||||
error?: string
|
||||
success?: string
|
||||
loading?: boolean
|
||||
loadingText?: string
|
||||
}
|
||||
|
||||
export function AuthCard({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
footerLinks,
|
||||
error,
|
||||
success,
|
||||
loading,
|
||||
loadingText = 'Loading...',
|
||||
}: AuthCardProps) {
|
||||
const router = useRouter()
|
||||
const styles = useStyles()
|
||||
|
||||
const handleLinkClick = (e: React.MouseEvent, href: string) => {
|
||||
e.preventDefault()
|
||||
router.push(href)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
{loading ? (
|
||||
<div className={styles.loading}>
|
||||
<div className={styles.spinner} />
|
||||
<p className={styles.subtitle}>{loadingText}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
{subtitle && <p className={styles.subtitle}>{subtitle}</p>}
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
{success && <div className={styles.success}>{success}</div>}
|
||||
|
||||
{children}
|
||||
|
||||
{footerLinks && footerLinks.length > 0 && (
|
||||
<div className={styles.footer}>
|
||||
{footerLinks.map((link, i) => (
|
||||
link.onClick ? (
|
||||
<button key={i} onClick={link.onClick} className={styles.link}>
|
||||
{link.label}
|
||||
</button>
|
||||
) : link.href ? (
|
||||
<a
|
||||
key={i}
|
||||
href={link.href}
|
||||
onClick={(e) => handleLinkClick(e, link.href!)}
|
||||
className={styles.link}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
326
legacy/allauth/components/AuthDjangoForm.tsx
Normal file
326
legacy/allauth/components/AuthDjangoForm.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
'use client'
|
||||
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import {
|
||||
useDjangoFormCore,
|
||||
type DjangoFormState,
|
||||
type FormOptions,
|
||||
type FormErrors,
|
||||
} from 'mizan'
|
||||
import { useAuthContext } from '../contexts/AuthContext'
|
||||
import { useStyles } from '../contexts/StylesContext'
|
||||
import { getAuthDetails, AuthDetails } from '../api'
|
||||
|
||||
interface FooterLink {
|
||||
label: string
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
interface AuthDjangoFormProps {
|
||||
/** Form name (e.g., "login", "signup", "change_password") */
|
||||
formName: string
|
||||
/** Callback after successful form submission */
|
||||
onSuccess?: (result: any, authDetails: AuthDetails) => void
|
||||
/** Callback after failed form submission */
|
||||
onError?: (errors: any) => void
|
||||
/** Links to show in footer (e.g., "Forgot password?") */
|
||||
footerLinks?: FooterLink[]
|
||||
/** Content to render before form fields */
|
||||
preFields?: React.ReactNode
|
||||
/** Content to render after form fields (before submit button) */
|
||||
postFields?: React.ReactNode
|
||||
/** Override the submit button label from schema */
|
||||
submitLabel?: string
|
||||
/** Override the title from schema */
|
||||
title?: string
|
||||
/** Override the subtitle from schema */
|
||||
subtitle?: string
|
||||
/** Options for form behavior (validation, schema refetch, etc.) */
|
||||
formOptions?: FormOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthDjangoForm renders a form from the mizan server functions
|
||||
* with styling consistent with the auth UI.
|
||||
*
|
||||
* It fetches the form schema (including title, subtitle, fields, submit label)
|
||||
* from the backend and renders it dynamically with real-time validation.
|
||||
*/
|
||||
export function AuthDjangoForm({
|
||||
formName,
|
||||
onSuccess,
|
||||
onError,
|
||||
footerLinks,
|
||||
preFields,
|
||||
postFields,
|
||||
submitLabel,
|
||||
title,
|
||||
subtitle,
|
||||
formOptions,
|
||||
}: AuthDjangoFormProps) {
|
||||
const form = useDjangoFormCore<Record<string, unknown>>({
|
||||
name: formName,
|
||||
options: formOptions,
|
||||
})
|
||||
const { refresh } = useAuthContext()
|
||||
const styles = useStyles()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Hydration safety: only render inputs after mount
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
const result = await form.submit()
|
||||
|
||||
if (result.success) {
|
||||
// Refresh auth state and get the updated auth for callbacks
|
||||
const newAuth = await refresh()
|
||||
onSuccess?.(result.data, getAuthDetails(newAuth))
|
||||
} else {
|
||||
onError?.(result.errors)
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (form.loading) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.loading}>
|
||||
<div className={styles.spinner} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Get form-level errors (non-field errors like "Invalid credentials")
|
||||
// These only appear after submission due to 'field-only' default
|
||||
const formErrors = form.getFormErrors()
|
||||
|
||||
// Use prop overrides or schema values
|
||||
const displayTitle = title ?? form.schema?.title
|
||||
const displaySubtitle = subtitle ?? form.schema?.subtitle
|
||||
const displaySubmitLabel = submitLabel ?? form.schema?.submit_label ?? 'Submit'
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
{displayTitle && (
|
||||
<h1 className={styles.title}>{displayTitle}</h1>
|
||||
)}
|
||||
{displaySubtitle && (
|
||||
<p className={styles.subtitle}>{displaySubtitle}</p>
|
||||
)}
|
||||
|
||||
{/* Form-level errors (shown after submission) */}
|
||||
{formErrors.length > 0 && (
|
||||
<div className={styles.error}>
|
||||
{formErrors.map((err, i) => (
|
||||
<p key={i}>{err.message}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
{preFields}
|
||||
|
||||
<div className={styles.fieldsContainer}>
|
||||
{form.schema?.fieldOrder.map(fieldName => {
|
||||
const field = form.schema!.fields[fieldName]
|
||||
return (
|
||||
<AuthField
|
||||
key={fieldName}
|
||||
field={{
|
||||
name: fieldName,
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
widget: field.widget,
|
||||
required: field.required,
|
||||
disabled: field.disabled,
|
||||
help_text: field.help_text,
|
||||
max_length: field.max_length,
|
||||
choices: field.choices,
|
||||
}}
|
||||
value={form.data[fieldName]}
|
||||
mounted={mounted}
|
||||
touched={form.touchedFields.has(fieldName)}
|
||||
errors={form.getFieldErrors(fieldName)}
|
||||
onChange={(value) => form.set(fieldName, value)}
|
||||
onBlur={() => form.touch(fieldName)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{postFields}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={form.submitting || form.validating}
|
||||
className={styles.submit}
|
||||
>
|
||||
{form.submitting ? 'Submitting...' : displaySubmitLabel}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{footerLinks && footerLinks.length > 0 && (
|
||||
<div className={styles.footer}>
|
||||
{footerLinks.map((link, i) => (
|
||||
link.onClick ? (
|
||||
<button key={i} type="button" onClick={link.onClick} className={styles.link}>
|
||||
{link.label}
|
||||
</button>
|
||||
) : link.href ? (
|
||||
<a key={i} href={link.href} className={styles.link}>
|
||||
{link.label}
|
||||
</a>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal field component with hydration-safe rendering
|
||||
*/
|
||||
interface AuthFieldProps {
|
||||
field: {
|
||||
name: string
|
||||
label: string
|
||||
type: string
|
||||
widget: string
|
||||
required: boolean
|
||||
disabled: boolean
|
||||
help_text: string
|
||||
max_length?: number | null
|
||||
choices?: Array<{ value: string; label: string }> | null
|
||||
}
|
||||
value: any
|
||||
mounted: boolean
|
||||
touched: boolean
|
||||
errors: Array<{ message: string }>
|
||||
onChange: (value: any) => void
|
||||
onBlur: () => void
|
||||
}
|
||||
|
||||
function AuthField({ field, value, mounted, touched, errors, onChange, onBlur }: AuthFieldProps) {
|
||||
const styles = useStyles()
|
||||
|
||||
const renderInput = () => {
|
||||
// Select dropdown
|
||||
if (field.choices && (field.widget === 'Select' || field.type === 'select')) {
|
||||
return (
|
||||
<select
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
required={field.required}
|
||||
disabled={field.disabled}
|
||||
className={styles.fieldInput}
|
||||
>
|
||||
{field.choices.map((choice) => (
|
||||
<option key={choice.value} value={choice.value}>
|
||||
{choice.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
// Radio buttons
|
||||
if (field.choices && field.widget === 'RadioSelect') {
|
||||
return (
|
||||
<div className={styles.radioGroup}>
|
||||
{field.choices.map((choice) => (
|
||||
<label key={choice.value} className={styles.radioItem}>
|
||||
<input
|
||||
type="radio"
|
||||
name={field.name}
|
||||
value={choice.value}
|
||||
checked={value === choice.value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
required={field.required}
|
||||
disabled={field.disabled}
|
||||
/>
|
||||
<span>{choice.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Checkbox
|
||||
if (field.type === 'checkbox') {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
onBlur={onBlur}
|
||||
required={field.required}
|
||||
disabled={field.disabled}
|
||||
className={styles.checkbox}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Textarea
|
||||
if (field.widget === 'Textarea') {
|
||||
return (
|
||||
<textarea
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
required={field.required}
|
||||
disabled={field.disabled}
|
||||
maxLength={field.max_length || undefined}
|
||||
className={styles.fieldInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Default: text input (text, password, email, etc.)
|
||||
return (
|
||||
<input
|
||||
type={field.type}
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
required={field.required}
|
||||
disabled={field.disabled}
|
||||
maxLength={field.max_length || undefined}
|
||||
className={styles.fieldInput}
|
||||
autoComplete="off"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<label className={styles.fieldLabel}>
|
||||
{field.label}
|
||||
</label>
|
||||
|
||||
{/* Hydration-safe: render placeholder until mounted */}
|
||||
{mounted ? (
|
||||
renderInput()
|
||||
) : (
|
||||
<div className={styles.fieldInput} style={{ minHeight: '2.75rem' }} />
|
||||
)}
|
||||
|
||||
{/* Field errors (only show if touched) */}
|
||||
{touched && errors.map((err, i) => (
|
||||
<p key={i} className={styles.fieldError}>{err.message}</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
99
legacy/allauth/components/AuthForm.tsx
Normal file
99
legacy/allauth/components/AuthForm.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode, useState, useEffect } from 'react'
|
||||
import { AuthDetails, AuthError, AuthResponse, getAuthDetails } from '../api'
|
||||
import { useAuthContext } from '../contexts/AuthContext'
|
||||
import { useStyles } from '../contexts/StylesContext'
|
||||
|
||||
interface AuthForm {
|
||||
submit: () => void
|
||||
authDetails: AuthDetails
|
||||
fetching: boolean
|
||||
response: AuthResponse | null
|
||||
errors: AuthError[]
|
||||
}
|
||||
|
||||
export default function useAuthForm(
|
||||
submissionAction: () => Promise<AuthResponse>,
|
||||
responseAction?: (response: AuthResponse, authDetails: AuthDetails) => void,
|
||||
): AuthForm {
|
||||
const auth = useAuthContext().auth
|
||||
const [fetching, setFetching] = useState<boolean>(false)
|
||||
const [response, setResponse] = useState<AuthResponse | null>(null)
|
||||
const [errors, setErrors] = useState<AuthError[]>([])
|
||||
const [authDetails, setAuthDetails] = useState<AuthDetails>(getAuthDetails(auth))
|
||||
|
||||
function submit() {
|
||||
setFetching(true)
|
||||
submissionAction()
|
||||
.then((r) => {
|
||||
setResponse(r)
|
||||
setErrors(r.errors || [])
|
||||
setFetching(false)
|
||||
if (r && responseAction) {
|
||||
responseAction(r, authDetails)
|
||||
}
|
||||
setAuthDetails(getAuthDetails(auth))
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
setFetching(false)
|
||||
})
|
||||
}
|
||||
|
||||
return { submit, authDetails, fetching, response, errors }
|
||||
}
|
||||
|
||||
interface AuthFieldProps {
|
||||
title: string
|
||||
name: string
|
||||
type: string
|
||||
init: string
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
authErrors: AuthError[]
|
||||
placeholder?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function AuthField({
|
||||
title,
|
||||
name,
|
||||
type,
|
||||
init,
|
||||
onChange,
|
||||
authErrors,
|
||||
placeholder,
|
||||
children,
|
||||
}: AuthFieldProps) {
|
||||
const styles = useStyles()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const fieldErrors = authErrors.filter(err => err.param === name)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<label className={styles.fieldLabel}>{title}</label>
|
||||
{mounted ? (
|
||||
<input
|
||||
type={type}
|
||||
value={init}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className={styles.fieldInput}
|
||||
autoComplete="off"
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.fieldInput} style={{ minHeight: '2.75rem' }} />
|
||||
)}
|
||||
{fieldErrors.map((err, i) => (
|
||||
<p key={i} className={styles.fieldError}>{err.message}</p>
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
127
legacy/allauth/components/AuthFormPage.tsx
Normal file
127
legacy/allauth/components/AuthFormPage.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { useState, ReactNode } from 'react'
|
||||
import { useAllauthAPI } from '../contexts/APIContext'
|
||||
import { useStyles } from '../contexts/StylesContext'
|
||||
import useAuthForm, { AuthField } from './AuthForm'
|
||||
import { AuthResponse, AuthDetails } from '../api'
|
||||
|
||||
interface FieldConfig {
|
||||
name: string
|
||||
title: string
|
||||
type: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
interface FooterLink {
|
||||
href: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface AuthFormPageProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
fields: FieldConfig[]
|
||||
submitLabel?: string
|
||||
submittingLabel?: string
|
||||
submitFn: (api: ReturnType<typeof useAllauthAPI>, data: Record<string, string>) => Promise<AuthResponse>
|
||||
onResponse: (response: AuthResponse, authDetails: AuthDetails, data: Record<string, string>) => void
|
||||
footerLinks?: FooterLink[]
|
||||
preFields?: ReactNode
|
||||
postFields?: ReactNode
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export function AuthFormPage({
|
||||
title,
|
||||
subtitle,
|
||||
fields,
|
||||
submitLabel = 'Submit',
|
||||
submittingLabel = 'Submitting...',
|
||||
submitFn,
|
||||
onResponse,
|
||||
footerLinks,
|
||||
preFields,
|
||||
postFields,
|
||||
error: externalError,
|
||||
}: AuthFormPageProps) {
|
||||
const api = useAllauthAPI()
|
||||
const styles = useStyles()
|
||||
|
||||
const [data, setData] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(fields.map(f => [f.name, '']))
|
||||
)
|
||||
|
||||
const authForm = useAuthForm(
|
||||
() => submitFn(api, data),
|
||||
(response, authDetails) => onResponse(response, authDetails, data)
|
||||
)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
authForm.submit()
|
||||
}
|
||||
|
||||
const handleFieldChange = (fieldName: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setData(prev => ({ ...prev, [fieldName]: e.target.value }))
|
||||
}
|
||||
|
||||
const formErrors = authForm.errors.filter(err => !err.param || err.param === '__all__')
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
{subtitle && <p className={styles.subtitle}>{subtitle}</p>}
|
||||
|
||||
{externalError && <p className={styles.error}>{externalError}</p>}
|
||||
{formErrors.length > 0 && (
|
||||
<div className={styles.error}>
|
||||
{formErrors.map((err, i) => (
|
||||
<p key={i}>{err.message}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className={styles.form} suppressHydrationWarning>
|
||||
{preFields}
|
||||
|
||||
<div className={styles.fieldsContainer}>
|
||||
{fields.map(field => (
|
||||
<AuthField
|
||||
key={field.name}
|
||||
title={field.title}
|
||||
name={field.name}
|
||||
type={field.type}
|
||||
init={data[field.name]}
|
||||
onChange={handleFieldChange(field.name)}
|
||||
authErrors={authForm.errors}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{postFields}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submit}
|
||||
disabled={authForm.fetching}
|
||||
>
|
||||
{authForm.fetching ? submittingLabel : submitLabel}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{footerLinks && footerLinks.length > 0 && (
|
||||
<div className={styles.footer}>
|
||||
{footerLinks.map((link, i) => (
|
||||
<a key={i} href={link.href} className={styles.link}>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
legacy/allauth/components/PasskeyLogin.tsx
Normal file
103
legacy/allauth/components/PasskeyLogin.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from '../contexts/RouterContext'
|
||||
import { useConfig } from '../contexts/AuthContext'
|
||||
import { useAllauthAPI } from '../contexts/APIContext'
|
||||
import { useAuthContext } from '../contexts/AuthContext'
|
||||
import { useStyles } from '../contexts/StylesContext'
|
||||
|
||||
interface PasskeyLoginProps {
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function PasskeyLogin({ onSuccess }: PasskeyLoginProps) {
|
||||
const router = useRouter()
|
||||
const config = useConfig()
|
||||
const api = useAllauthAPI()
|
||||
const { refresh } = useAuthContext()
|
||||
const styles = useStyles()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [authenticating, setAuthenticating] = useState(false)
|
||||
|
||||
// Check if passkey login is enabled
|
||||
const passkeyLoginEnabled = config?.data?.mfa?.passkey_login_enabled
|
||||
|
||||
if (!passkeyLoginEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
setError(null)
|
||||
setAuthenticating(true)
|
||||
|
||||
try {
|
||||
const { startAuthentication } = await import('@simplewebauthn/browser')
|
||||
|
||||
// Get login options (challenge) from server
|
||||
const optionsRes = await api.webauthn.requestOptions.login()
|
||||
|
||||
if (optionsRes.status !== 200) {
|
||||
throw new Error('Failed to get login options')
|
||||
}
|
||||
|
||||
// Extract publicKey options - allauth returns { request_options: { publicKey: {...} } }
|
||||
const publicKeyOptions = optionsRes.data?.request_options?.publicKey
|
||||
|
||||
if (!publicKeyOptions?.challenge) {
|
||||
throw new Error('Invalid login options')
|
||||
}
|
||||
|
||||
// Perform WebAuthn authentication in browser
|
||||
// @simplewebauthn/browser v13+ expects { optionsJSON: ... }
|
||||
const credential = await startAuthentication({ optionsJSON: publicKeyOptions as any })
|
||||
|
||||
// Submit credential to server for login
|
||||
const res = await api.webauthn.login(credential)
|
||||
|
||||
if (res.status === 200) {
|
||||
await refresh()
|
||||
if (onSuccess) {
|
||||
onSuccess()
|
||||
} else {
|
||||
const next = router.searchParams.get('next')
|
||||
router.push(next?.startsWith('/') ? next : '/dashboard')
|
||||
}
|
||||
} else {
|
||||
setError('Login failed. Please try again.')
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError' || e.name === 'NotAllowedError') {
|
||||
// User cancelled - not an error
|
||||
setError(null)
|
||||
} else {
|
||||
setError(e.message || 'Failed to sign in with passkey')
|
||||
}
|
||||
} finally {
|
||||
setAuthenticating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.passkeyContainer}>
|
||||
<div className={styles.divider}>
|
||||
<span className={styles.dividerText}>or</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error}>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePasskeyLogin}
|
||||
disabled={authenticating}
|
||||
className={styles.passkeyButton}
|
||||
>
|
||||
{authenticating ? 'Waiting for passkey...' : 'Sign in with Passkey'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
legacy/allauth/components/ProviderList.tsx
Normal file
56
legacy/allauth/components/ProviderList.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { useConfig } from '../contexts/AuthContext'
|
||||
import { useAllauthAPI } from '../contexts/APIContext'
|
||||
import { useStyles } from '../contexts/StylesContext'
|
||||
|
||||
interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ProviderListProps {
|
||||
callbackUrl: string
|
||||
process?: 'login' | 'connect'
|
||||
}
|
||||
|
||||
export function ProviderList({ callbackUrl, process = 'login' }: ProviderListProps) {
|
||||
const config = useConfig()
|
||||
const api = useAllauthAPI()
|
||||
const styles = useStyles()
|
||||
|
||||
const providers: Provider[] = config?.data?.socialaccount?.providers || []
|
||||
|
||||
if (providers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleProviderClick = (providerId: string) => {
|
||||
const provider = api.oauth.provider(providerId)
|
||||
if (process === 'connect') {
|
||||
provider.connect.withRedirect(callbackUrl)
|
||||
} else {
|
||||
provider.login.withRedirect(callbackUrl)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.providersContainer}>
|
||||
<div className={styles.divider}>
|
||||
<span className={styles.dividerText}>or continue with</span>
|
||||
</div>
|
||||
<div className={styles.providerButtons}>
|
||||
{providers.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => handleProviderClick(provider.id)}
|
||||
className={styles.providerButton}
|
||||
>
|
||||
{provider.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
legacy/allauth/components/index.ts
Normal file
41
legacy/allauth/components/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Main UI component
|
||||
export { AllauthUI } from './AllauthUI'
|
||||
export type { AllauthUIView, AllauthUIMode } from './AllauthUI'
|
||||
|
||||
// Core components
|
||||
export { AuthCard } from './AuthCard'
|
||||
export { AuthFormPage } from './AuthFormPage'
|
||||
export { AuthDjangoForm } from './AuthDjangoForm'
|
||||
export { ProviderList } from './ProviderList'
|
||||
export { PasskeyLogin } from './PasskeyLogin'
|
||||
export { default as useAuthForm, AuthField } from './AuthForm'
|
||||
|
||||
// Django-initiated flow handler (email verification, password reset links, OAuth)
|
||||
export { AllauthRouter } from './AllauthRouter'
|
||||
|
||||
// Settings components
|
||||
export {
|
||||
AuthSettings,
|
||||
ProfileSection,
|
||||
EmailsSection,
|
||||
PasswordSection,
|
||||
PasskeysSection,
|
||||
ConnectionsSection,
|
||||
MFASection,
|
||||
SessionsSection,
|
||||
SettingsSection,
|
||||
SettingsItem,
|
||||
SettingsList,
|
||||
Badge,
|
||||
Button,
|
||||
} from './settings'
|
||||
|
||||
// Individual auth views (for granular control)
|
||||
export {
|
||||
LoginView,
|
||||
SignupView,
|
||||
MFAChooserView,
|
||||
MFAWebAuthnView,
|
||||
MFATOTPView,
|
||||
MFARecoveryCodesView,
|
||||
} from './views'
|
||||
79
legacy/allauth/components/settings/AuthSettings.tsx
Normal file
79
legacy/allauth/components/settings/AuthSettings.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import { useStyles, cx } from '../../contexts/StylesContext'
|
||||
import { ProfileSection } from './ProfileSection'
|
||||
import { EmailsSection } from './EmailsSection'
|
||||
import { PasswordSection } from './PasswordSection'
|
||||
import { PasskeysSection } from './PasskeysSection'
|
||||
import { ConnectionsSection } from './ConnectionsSection'
|
||||
import { MFASection } from './MFASection'
|
||||
import { SessionsSection } from './SessionsSection'
|
||||
import { Button } from './SettingsComponents'
|
||||
|
||||
type SettingsSectionType = 'profile' | 'emails' | 'password' | 'passkeys' | 'connections' | 'mfa' | 'sessions'
|
||||
|
||||
interface AuthSettingsProps {
|
||||
/** Title shown at the top of the settings page */
|
||||
title?: string
|
||||
/** Called when user clicks sign out */
|
||||
onSignOut?: () => void
|
||||
/** Which sections to show. Defaults to all. */
|
||||
sections?: SettingsSectionType[]
|
||||
/** URL to redirect back to after OAuth connect (for connections section) */
|
||||
oauthRedirectUrl?: string
|
||||
}
|
||||
|
||||
const DEFAULT_SECTIONS: SettingsSectionType[] = ['profile', 'emails', 'password', 'passkeys', 'connections', 'mfa', 'sessions']
|
||||
|
||||
/**
|
||||
* AuthSettings renders a complete account settings page.
|
||||
*
|
||||
* It includes sections for:
|
||||
* - Profile (display user info)
|
||||
* - Email addresses (manage, verify, set primary)
|
||||
* - Password change
|
||||
* - Passkeys (add/remove passwordless login)
|
||||
* - Connected accounts (OAuth providers)
|
||||
* - Two-factor authentication (TOTP, recovery codes)
|
||||
* - Active sessions (view/end sessions)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AuthSettings
|
||||
* onSignOut={() => router.push('/logout')}
|
||||
* sections={['profile', 'password', 'mfa']} // Only show these sections
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function AuthSettings({
|
||||
title = 'Account Settings',
|
||||
onSignOut,
|
||||
sections = DEFAULT_SECTIONS,
|
||||
oauthRedirectUrl,
|
||||
}: AuthSettingsProps) {
|
||||
const styles = useStyles()
|
||||
const sectionSet = new Set(sections)
|
||||
|
||||
return (
|
||||
<div className={styles.settingsContainer}>
|
||||
<h1 className={styles.settingsPageTitle}>{title}</h1>
|
||||
|
||||
{sectionSet.has('profile') && <ProfileSection />}
|
||||
{sectionSet.has('emails') && <EmailsSection />}
|
||||
{sectionSet.has('password') && <PasswordSection />}
|
||||
{sectionSet.has('passkeys') && <PasskeysSection />}
|
||||
{sectionSet.has('connections') && <ConnectionsSection redirectUrl={oauthRedirectUrl} />}
|
||||
{sectionSet.has('mfa') && <MFASection />}
|
||||
{sectionSet.has('sessions') && <SessionsSection />}
|
||||
|
||||
{/* Sign Out */}
|
||||
{onSignOut && (
|
||||
<section className={styles.settingsCard}>
|
||||
<Button variant="danger" onClick={onSignOut}>
|
||||
Sign Out
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
legacy/allauth/components/settings/ConnectionsSection.tsx
Normal file
87
legacy/allauth/components/settings/ConnectionsSection.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useConfig } from '../../contexts/AuthContext'
|
||||
import { useAllauthAPI } from '../../contexts/APIContext'
|
||||
import { SettingsSection, SettingsItem, SettingsList, Button } from './SettingsComponents'
|
||||
|
||||
interface Connection {
|
||||
uid: string
|
||||
provider: { id: string; name: string }
|
||||
display: string
|
||||
}
|
||||
|
||||
interface ConnectionsSectionProps {
|
||||
/** URL to redirect back to after OAuth connect */
|
||||
redirectUrl?: string
|
||||
}
|
||||
|
||||
export function ConnectionsSection({ redirectUrl = '/account' }: ConnectionsSectionProps) {
|
||||
const api = useAllauthAPI()
|
||||
const config = useConfig()
|
||||
const [connections, setConnections] = useState<Connection[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const availableProviders = config?.data?.socialaccount?.providers || []
|
||||
|
||||
const fetchConnections = async () => {
|
||||
const res = await api.oauth.list()
|
||||
if (res.status === 200 && res.data) {
|
||||
setConnections(res.data)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchConnections() }, [])
|
||||
|
||||
const handleConnect = (providerId: string) => {
|
||||
api.oauth.provider(providerId).connect.withRedirect(redirectUrl)
|
||||
}
|
||||
|
||||
const handleDisconnect = async (providerId: string, uid: string) => {
|
||||
if (!confirm('Disconnect this account?')) return
|
||||
await api.oauth.provider(providerId).removeFrom(uid)
|
||||
fetchConnections()
|
||||
}
|
||||
|
||||
// Don't render if no providers configured or still loading
|
||||
if (loading) return null
|
||||
|
||||
const connectedProviderIds = connections.map(c => c.provider.id)
|
||||
const unconnectedProviders = availableProviders.filter(
|
||||
(p: { id: string }) => !connectedProviderIds.includes(p.id)
|
||||
)
|
||||
|
||||
// Hide section entirely if no social providers
|
||||
if (connections.length === 0 && availableProviders.length === 0) return null
|
||||
|
||||
return (
|
||||
<SettingsSection title="Connected Accounts">
|
||||
<SettingsList>
|
||||
{connections.map(conn => (
|
||||
<SettingsItem
|
||||
key={conn.uid}
|
||||
label={conn.provider.name}
|
||||
meta={conn.display}
|
||||
actions={
|
||||
<Button variant="danger" onClick={() => handleDisconnect(conn.provider.id, conn.uid)}>
|
||||
Disconnect
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{unconnectedProviders.map((provider: { id: string; name: string }) => (
|
||||
<SettingsItem
|
||||
key={provider.id}
|
||||
label={provider.name}
|
||||
actions={
|
||||
<Button onClick={() => handleConnect(provider.id)}>
|
||||
Connect
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
120
legacy/allauth/components/settings/EmailsSection.tsx
Normal file
120
legacy/allauth/components/settings/EmailsSection.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAllauthAPI } from '../../contexts/APIContext'
|
||||
import { useStyles } from '../../contexts/StylesContext'
|
||||
import { useDjangoFormCore } from 'mizan'
|
||||
import { SettingsSection, SettingsItem, SettingsList, Badge, Button } from './SettingsComponents'
|
||||
|
||||
interface Email {
|
||||
email: string
|
||||
primary: boolean
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
export function EmailsSection() {
|
||||
const api = useAllauthAPI()
|
||||
const styles = useStyles()
|
||||
const [emails, setEmails] = useState<Email[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const addEmailForm = useDjangoFormCore<Record<string, unknown>>({ name: 'add_email' })
|
||||
|
||||
const fetchEmails = async () => {
|
||||
const res = await api.account.emails.list()
|
||||
if (res.status === 200 && res.data) {
|
||||
setEmails(res.data)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchEmails() }, [])
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const result = await addEmailForm.submit()
|
||||
if (result.success) {
|
||||
addEmailForm.reset()
|
||||
fetchEmails()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (email: string) => {
|
||||
if (!confirm(`Remove ${email}?`)) return
|
||||
await api.account.emails.remove(email)
|
||||
fetchEmails()
|
||||
}
|
||||
|
||||
const handleSetPrimary = async (email: string) => {
|
||||
await api.account.emails.setPrimary(email)
|
||||
fetchEmails()
|
||||
}
|
||||
|
||||
const handleResendVerification = async (email: string) => {
|
||||
await api.account.emails.verification.dispatch(email)
|
||||
alert('Verification email sent!')
|
||||
}
|
||||
|
||||
if (loading) return null
|
||||
|
||||
return (
|
||||
<SettingsSection title="Email Addresses">
|
||||
<SettingsList>
|
||||
{emails.map(email => (
|
||||
<SettingsItem
|
||||
key={email.email}
|
||||
label={
|
||||
<>
|
||||
{email.email}
|
||||
{email.primary && <Badge variant="primary">Primary</Badge>}
|
||||
{!email.verified && <Badge variant="warning">Unverified</Badge>}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{!email.verified && (
|
||||
<Button variant="secondary" onClick={() => handleResendVerification(email.email)}>
|
||||
Verify
|
||||
</Button>
|
||||
)}
|
||||
{!email.primary && email.verified && (
|
||||
<Button onClick={() => handleSetPrimary(email.email)}>
|
||||
Make Primary
|
||||
</Button>
|
||||
)}
|
||||
{!email.primary && (
|
||||
<Button variant="danger" onClick={() => handleRemove(email.email)}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
|
||||
{!addEmailForm.loading && (
|
||||
<form onSubmit={handleAdd} className={styles.inlineForm}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.fieldLabel}>
|
||||
{addEmailForm.schema?.fields.email?.label || 'Add Email'}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={(addEmailForm.data.email as string) || ''}
|
||||
onChange={(e) => addEmailForm.set('email', e.target.value)}
|
||||
onBlur={() => addEmailForm.touch('email')}
|
||||
className={styles.fieldInput}
|
||||
required
|
||||
/>
|
||||
{addEmailForm.getFieldErrors('email').map((err, i) => (
|
||||
<p key={i} className={styles.fieldError}>{err.message}</p>
|
||||
))}
|
||||
</div>
|
||||
<Button type="submit">
|
||||
{addEmailForm.schema?.submit_label || 'Add'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
171
legacy/allauth/components/settings/MFASection.tsx
Normal file
171
legacy/allauth/components/settings/MFASection.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAllauthAPI } from '../../contexts/APIContext'
|
||||
import { useStyles } from '../../contexts/StylesContext'
|
||||
import { SettingsSection, SettingsItem, Badge, Button } from './SettingsComponents'
|
||||
import type { Authenticator, TOTPStatus } from '../../types'
|
||||
|
||||
interface TOTPSetup {
|
||||
secret: string
|
||||
totp_url: string
|
||||
}
|
||||
|
||||
export function MFASection() {
|
||||
const api = useAllauthAPI()
|
||||
const [authenticators, setAuthenticators] = useState<Authenticator[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [available, setAvailable] = useState(true)
|
||||
|
||||
const fetchAuthenticators = async () => {
|
||||
try {
|
||||
const res = await api.mfa.list()
|
||||
if (res.status === 200 && res.data) {
|
||||
setAuthenticators(res.data as Authenticator[])
|
||||
} else {
|
||||
// Non-200 status means MFA not available
|
||||
setAvailable(false)
|
||||
}
|
||||
} catch {
|
||||
setAvailable(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchAuthenticators() }, [])
|
||||
|
||||
if (loading || !available) return null
|
||||
|
||||
const hasTOTP = authenticators.some(a => a.type === 'totp')
|
||||
|
||||
return (
|
||||
<SettingsSection title="Two-Factor Authentication">
|
||||
<TOTPSubsection
|
||||
hasTOTP={hasTOTP}
|
||||
onUpdate={fetchAuthenticators}
|
||||
/>
|
||||
|
||||
{hasTOTP && (
|
||||
<RecoveryCodesSubsection />
|
||||
)}
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
// --- TOTP Subsection ---
|
||||
|
||||
function TOTPSubsection({ hasTOTP, onUpdate }: { hasTOTP: boolean; onUpdate: () => void }) {
|
||||
const api = useAllauthAPI()
|
||||
const styles = useStyles()
|
||||
const [showSetup, setShowSetup] = useState(false)
|
||||
const [setup, setSetup] = useState<TOTPSetup | null>(null)
|
||||
const [code, setCode] = useState('')
|
||||
|
||||
const handleStartSetup = async () => {
|
||||
const res = await api.mfa.totp.getStatus()
|
||||
// allauth returns TOTP status with secret and totp_url for setup
|
||||
const data = res.data as TOTPStatus | undefined
|
||||
if (data?.secret && data?.totp_url) {
|
||||
setSetup({ secret: data.secret, totp_url: data.totp_url })
|
||||
setShowSetup(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleActivate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const res = await api.mfa.totp.activate(code)
|
||||
if (res.status === 200) {
|
||||
setShowSetup(false)
|
||||
setSetup(null)
|
||||
setCode('')
|
||||
onUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
if (!confirm('Disable authenticator app?')) return
|
||||
await api.mfa.totp.deactivate()
|
||||
onUpdate()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className={styles.settingsSubtitle}>Authenticator App</h3>
|
||||
|
||||
{showSetup && setup ? (
|
||||
<div className={styles.totpSetup}>
|
||||
<p>Scan this QR code with your authenticator app:</p>
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(setup.totp_url)}`}
|
||||
alt="TOTP QR Code"
|
||||
className={styles.qrCode}
|
||||
/>
|
||||
<p className={styles.settingsItemMeta}>Secret: {setup.secret}</p>
|
||||
<form onSubmit={handleActivate} className={styles.inlineForm}>
|
||||
<div className={styles.field}>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="Verification Code"
|
||||
className={styles.fieldInput}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Activate</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => setShowSetup(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : hasTOTP ? (
|
||||
<SettingsItem
|
||||
label={<>Authenticator App <Badge variant="success">Active</Badge></>}
|
||||
actions={<Button variant="danger" onClick={handleDeactivate}>Disable</Button>}
|
||||
/>
|
||||
) : (
|
||||
<Button onClick={handleStartSetup}>Set Up Authenticator</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Recovery Codes Subsection ---
|
||||
|
||||
function RecoveryCodesSubsection() {
|
||||
const api = useAllauthAPI()
|
||||
const styles = useStyles()
|
||||
const [codes, setCodes] = useState<string[]>([])
|
||||
|
||||
const handleView = async () => {
|
||||
const res = await api.mfa.recoveryCodes.list()
|
||||
if (res.status === 200) {
|
||||
setCodes(res.data?.unused_codes || [])
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!confirm('Generate new codes? Old codes will stop working.')) return
|
||||
const res = await api.mfa.recoveryCodes.regenerate()
|
||||
if (res.status === 200) {
|
||||
setCodes(res.data?.unused_codes || [])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className={styles.settingsSubtitle}>Recovery Codes</h3>
|
||||
|
||||
{codes.length > 0 ? (
|
||||
<div>
|
||||
<div className={styles.recoveryCodes}>
|
||||
{codes.map((code, i) => <span key={i}>{code}</span>)}
|
||||
</div>
|
||||
<p className={styles.settingsItemMeta}>Store these safely. Each code works once.</p>
|
||||
<Button variant="secondary" onClick={handleRegenerate}>Regenerate</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="secondary" onClick={handleView}>View Recovery Codes</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
103
legacy/allauth/components/settings/PasskeysSection.tsx
Normal file
103
legacy/allauth/components/settings/PasskeysSection.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAllauthAPI } from '../../contexts/APIContext'
|
||||
import { useConfig } from '../../contexts/AuthContext'
|
||||
import { useStyles } from '../../contexts/StylesContext'
|
||||
import { SettingsSection, SettingsItem, SettingsList, Button } from './SettingsComponents'
|
||||
import type { Authenticator, WebAuthnAuthenticator } from '../../types'
|
||||
|
||||
export function PasskeysSection() {
|
||||
const api = useAllauthAPI()
|
||||
const config = useConfig()
|
||||
const styles = useStyles()
|
||||
const [passkeys, setPasskeys] = useState<WebAuthnAuthenticator[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Check if passkey login is enabled
|
||||
const passkeyLoginEnabled = config?.data?.mfa?.passkey_login_enabled
|
||||
|
||||
const fetchPasskeys = async () => {
|
||||
try {
|
||||
const res = await api.mfa.list()
|
||||
if (res.status === 200 && res.data) {
|
||||
const authenticators = res.data as Authenticator[]
|
||||
setPasskeys(authenticators.filter((a): a is WebAuthnAuthenticator => a.type === 'webauthn'))
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - passkeys just won't show
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchPasskeys() }, [])
|
||||
|
||||
// Don't render if passkey login isn't enabled
|
||||
if (!passkeyLoginEnabled) return null
|
||||
if (loading) return null
|
||||
|
||||
const handleAdd = async () => {
|
||||
try {
|
||||
const { startRegistration } = await import('@simplewebauthn/browser')
|
||||
|
||||
// Request creation options - use passwordless=true for login passkeys
|
||||
const optionsRes = await api.webauthn.requestOptions.creation(true)
|
||||
|
||||
if (optionsRes.status !== 200) {
|
||||
return
|
||||
}
|
||||
|
||||
const publicKeyOptions = optionsRes.data?.creation_options?.publicKey
|
||||
if (!publicKeyOptions) throw new Error('Invalid options response')
|
||||
|
||||
// @simplewebauthn/browser v13+ expects { optionsJSON: ... }
|
||||
const credential = await startRegistration({ optionsJSON: publicKeyOptions as any })
|
||||
const name = prompt('Name this passkey:') || 'Passkey'
|
||||
|
||||
const res = await api.webauthn.add(name, credential)
|
||||
if (res.status === 200) {
|
||||
fetchPasskeys()
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'AbortError') {
|
||||
alert(e.message || 'Failed to add passkey')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (id: number) => {
|
||||
if (!confirm('Remove this passkey? You won\'t be able to use it to sign in anymore.')) return
|
||||
await api.webauthn.delete([id])
|
||||
fetchPasskeys()
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title="Passkeys">
|
||||
<p className={styles.settingsItemMeta} style={{ marginBottom: '1rem' }}>
|
||||
Passkeys let you sign in quickly using your device's biometrics or security key.
|
||||
No password needed.
|
||||
</p>
|
||||
|
||||
{passkeys.length > 0 && (
|
||||
<SettingsList>
|
||||
{passkeys.map(passkey => (
|
||||
<SettingsItem
|
||||
key={passkey.id}
|
||||
label={passkey.name}
|
||||
meta={`Added ${new Date(passkey.created_at * 1000).toLocaleDateString()}`}
|
||||
actions={
|
||||
<Button variant="danger" onClick={() => handleRemove(passkey.id)}>
|
||||
Remove
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
)}
|
||||
|
||||
<Button onClick={handleAdd}>
|
||||
{passkeys.length > 0 ? 'Add Another Passkey' : 'Set Up Passkey'}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
54
legacy/allauth/components/settings/PasswordSection.tsx
Normal file
54
legacy/allauth/components/settings/PasswordSection.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useDjangoFormCore } from 'mizan'
|
||||
import { useStyles } from '../../contexts/StylesContext'
|
||||
import { SettingsSection, Button } from './SettingsComponents'
|
||||
|
||||
export function PasswordSection() {
|
||||
const styles = useStyles()
|
||||
const form = useDjangoFormCore<Record<string, unknown>>({ name: 'change_password' })
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const result = await form.submit()
|
||||
if (result.success) {
|
||||
form.reset()
|
||||
alert('Password changed successfully!')
|
||||
}
|
||||
}
|
||||
|
||||
if (form.loading) return null
|
||||
|
||||
return (
|
||||
<SettingsSection title={form.schema?.title || 'Change Password'}>
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<div className={styles.fieldsContainer}>
|
||||
{form.schema?.fieldOrder.map(fieldName => {
|
||||
const field = form.schema!.fields[fieldName]
|
||||
return (
|
||||
<div key={fieldName} className={styles.field}>
|
||||
<label className={styles.fieldLabel}>{field.label}</label>
|
||||
<input
|
||||
type={field.type}
|
||||
value={(form.data[fieldName] as string) || ''}
|
||||
onChange={(e) => form.set(fieldName, e.target.value)}
|
||||
onBlur={() => form.touch(fieldName)}
|
||||
className={styles.fieldInput}
|
||||
required={field.required}
|
||||
/>
|
||||
{form.touchedFields.has(fieldName) &&
|
||||
form.getFieldErrors(fieldName).map((err, i) => (
|
||||
<p key={i} className={styles.fieldError}>{err.message}</p>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Button type="submit" disabled={form.submitting}>
|
||||
{form.submitting ? 'Changing...' : (form.schema?.submit_label || 'Change Password')}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
22
legacy/allauth/components/settings/ProfileSection.tsx
Normal file
22
legacy/allauth/components/settings/ProfileSection.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useUser } from '../../contexts/AuthContext'
|
||||
import { SettingsSection, SettingsItem, SettingsList } from './SettingsComponents'
|
||||
|
||||
export function ProfileSection() {
|
||||
const user = useUser()
|
||||
|
||||
return (
|
||||
<SettingsSection title="Profile">
|
||||
<SettingsList>
|
||||
<SettingsItem label="Email" meta={user?.email} />
|
||||
{user?.first_name && (
|
||||
<SettingsItem
|
||||
label="Name"
|
||||
meta={`${user.first_name} ${user.last_name || ''}`.trim()}
|
||||
/>
|
||||
)}
|
||||
</SettingsList>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
88
legacy/allauth/components/settings/SessionsSection.tsx
Normal file
88
legacy/allauth/components/settings/SessionsSection.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAllauthAPI } from '../../contexts/APIContext'
|
||||
import { SettingsSection, SettingsItem, SettingsList, Badge, Button } from './SettingsComponents'
|
||||
import type { Session } from '../../types'
|
||||
|
||||
function parseUserAgent(ua: string): string {
|
||||
if (ua.includes('Chrome')) return 'Chrome'
|
||||
if (ua.includes('Firefox')) return 'Firefox'
|
||||
if (ua.includes('Safari')) return 'Safari'
|
||||
if (ua.includes('Edge')) return 'Edge'
|
||||
return 'Unknown Browser'
|
||||
}
|
||||
|
||||
export function SessionsSection() {
|
||||
const api = useAllauthAPI()
|
||||
const [sessions, setSessions] = useState<Session[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [available, setAvailable] = useState(true)
|
||||
|
||||
const fetchSessions = async () => {
|
||||
try {
|
||||
const res = await api.session.list()
|
||||
if (res.status === 200 && res.data) {
|
||||
setSessions(res.data as Session[])
|
||||
} else {
|
||||
// Non-200 status means sessions feature not available
|
||||
setAvailable(false)
|
||||
}
|
||||
} catch {
|
||||
setAvailable(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchSessions() }, [])
|
||||
|
||||
const handleEnd = async (id: number) => {
|
||||
if (!confirm('End this session?')) return
|
||||
await api.session.remove([id])
|
||||
fetchSessions()
|
||||
}
|
||||
|
||||
const handleEndAllOthers = async () => {
|
||||
const otherIds = sessions.filter(s => !s.is_current).map(s => s.id)
|
||||
if (otherIds.length === 0) return
|
||||
if (!confirm(`End ${otherIds.length} other session(s)?`)) return
|
||||
await api.session.remove(otherIds)
|
||||
fetchSessions()
|
||||
}
|
||||
|
||||
if (loading || !available) return null
|
||||
|
||||
const otherSessions = sessions.filter(s => !s.is_current)
|
||||
|
||||
return (
|
||||
<SettingsSection title="Active Sessions">
|
||||
<SettingsList>
|
||||
{sessions.map(session => (
|
||||
<SettingsItem
|
||||
key={session.id}
|
||||
label={
|
||||
<>
|
||||
{parseUserAgent(session.user_agent)}
|
||||
{session.is_current && <Badge variant="success">Current</Badge>}
|
||||
</>
|
||||
}
|
||||
meta={`${session.ip} · ${session.last_seen_at ? new Date(session.last_seen_at * 1000).toLocaleString() : 'Unknown'}`}
|
||||
actions={
|
||||
!session.is_current && (
|
||||
<Button variant="danger" onClick={() => handleEnd(session.id)}>
|
||||
End
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
|
||||
{otherSessions.length > 0 && (
|
||||
<Button variant="danger" onClick={handleEndAllOthers}>
|
||||
End All Other Sessions
|
||||
</Button>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
76
legacy/allauth/components/settings/SettingsComponents.tsx
Normal file
76
legacy/allauth/components/settings/SettingsComponents.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { useStyles, cx } from '../../contexts/StylesContext'
|
||||
|
||||
interface SettingsSectionProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function SettingsSection({ title, children }: SettingsSectionProps) {
|
||||
const styles = useStyles()
|
||||
return (
|
||||
<section className={styles.settingsCard}>
|
||||
<h2 className={styles.settingsSectionTitle}>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsItemProps {
|
||||
label: React.ReactNode
|
||||
meta?: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
export function SettingsItem({ label, meta, actions }: SettingsItemProps) {
|
||||
const styles = useStyles()
|
||||
return (
|
||||
<div className={styles.settingsItem}>
|
||||
<div className={styles.settingsItemInfo}>
|
||||
<span className={styles.settingsItemLabel}>{label}</span>
|
||||
{meta && <span className={styles.settingsItemMeta}>{meta}</span>}
|
||||
</div>
|
||||
{actions && <div className={styles.settingsItemActions}>{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsList({ children }: { children: React.ReactNode }) {
|
||||
const styles = useStyles()
|
||||
return <div className={styles.settingsList}>{children}</div>
|
||||
}
|
||||
|
||||
type BadgeVariant = 'primary' | 'success' | 'warning' | 'danger'
|
||||
|
||||
export function Badge({ variant, children }: { variant: BadgeVariant, children: React.ReactNode }) {
|
||||
const styles = useStyles()
|
||||
const variantClass = {
|
||||
primary: styles.badgePrimary,
|
||||
success: styles.badgeSuccess,
|
||||
warning: styles.badgeUnverified,
|
||||
danger: styles.badgeDanger,
|
||||
}[variant]
|
||||
|
||||
return <span className={cx(styles.badge, variantClass)}>{children}</span>
|
||||
}
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger'
|
||||
size?: 'small' | 'normal'
|
||||
}
|
||||
|
||||
export function Button({ variant = 'primary', size = 'small', className, children, ...props }: ButtonProps) {
|
||||
const styles = useStyles()
|
||||
const variantClass = {
|
||||
primary: styles.smallButtonPrimary,
|
||||
secondary: styles.smallButtonSecondary,
|
||||
danger: styles.smallButtonDanger,
|
||||
}[variant]
|
||||
|
||||
return (
|
||||
<button className={cx(styles.smallButton, variantClass, className)} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
20
legacy/allauth/components/settings/index.ts
Normal file
20
legacy/allauth/components/settings/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Main settings component
|
||||
export { AuthSettings } from './AuthSettings'
|
||||
|
||||
// Individual sections (for custom layouts)
|
||||
export { ProfileSection } from './ProfileSection'
|
||||
export { EmailsSection } from './EmailsSection'
|
||||
export { PasswordSection } from './PasswordSection'
|
||||
export { PasskeysSection } from './PasskeysSection'
|
||||
export { ConnectionsSection } from './ConnectionsSection'
|
||||
export { MFASection } from './MFASection'
|
||||
export { SessionsSection } from './SessionsSection'
|
||||
|
||||
// Building blocks (for custom components)
|
||||
export {
|
||||
SettingsSection,
|
||||
SettingsItem,
|
||||
SettingsList,
|
||||
Badge,
|
||||
Button,
|
||||
} from './SettingsComponents'
|
||||
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'
|
||||
67
legacy/allauth/config.ts
Normal file
67
legacy/allauth/config.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Configuration for the allauth library.
|
||||
*
|
||||
* This config serves two purposes:
|
||||
* 1. Define the base path for Django-initiated routes (must match HEADLESS_FRONTEND_URLS)
|
||||
* 2. Define where to navigate for various auth events (developer controls these)
|
||||
*
|
||||
* For JWT-based API calls, use mizan/jwt separately.
|
||||
*/
|
||||
|
||||
export interface AllauthConfig {
|
||||
/**
|
||||
* Base path for Django-initiated routes (email verification, password reset, OAuth).
|
||||
* This must match the base path configured in Django's HEADLESS_FRONTEND_URLS.
|
||||
*
|
||||
* Example: '/auth' means Django sends users to '/auth/verify-email/{key}'
|
||||
*/
|
||||
basePath: string
|
||||
|
||||
/**
|
||||
* Navigation targets for auth events.
|
||||
* These are the URLs/paths the developer wants users sent to.
|
||||
*/
|
||||
routes: {
|
||||
/** Where to go after successful authentication */
|
||||
authenticated: string
|
||||
/** Where to go after logout */
|
||||
logout: string
|
||||
/** Where the login page is (for "Back to login" links) */
|
||||
login: string
|
||||
/** Where the signup page is (for "Create account" links) */
|
||||
signup: string
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultConfig: AllauthConfig = {
|
||||
basePath: '/auth',
|
||||
routes: {
|
||||
authenticated: '/dashboard',
|
||||
logout: '/',
|
||||
login: '/login',
|
||||
signup: '/signup',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a config by merging provided options with defaults.
|
||||
*/
|
||||
export function createAllauthConfig(config: Partial<AllauthConfig>): AllauthConfig {
|
||||
return {
|
||||
basePath: config.basePath ?? defaultConfig.basePath,
|
||||
routes: {
|
||||
...defaultConfig.routes,
|
||||
...config.routes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Django-initiated flow paths (relative to basePath).
|
||||
* These must match what's configured in Django's HEADLESS_FRONTEND_URLS.
|
||||
*/
|
||||
export const DjangoFlowPaths = {
|
||||
VERIFY_EMAIL: '/verify-email',
|
||||
RESET_PASSWORD: '/reset-password',
|
||||
OAUTH_ERROR: '/oauth/error',
|
||||
} as const
|
||||
72
legacy/allauth/contexts/APIContext.tsx
Normal file
72
legacy/allauth/contexts/APIContext.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useDjangoCSRClient, Auth } from 'mizan/client/react'
|
||||
import { useAuthContext } from './AuthContext'
|
||||
import { createAPI, AllauthAPI, BrowserFormAction } from '../api'
|
||||
|
||||
/**
|
||||
* Browser form action for OAuth redirects.
|
||||
* Creates and submits a form programmatically.
|
||||
*/
|
||||
const browserFormAction: BrowserFormAction = (action: string, data: Record<string, string>) => {
|
||||
const form = document.createElement('form')
|
||||
form.method = 'POST'
|
||||
form.action = action
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'hidden'
|
||||
input.name = key
|
||||
input.value = value
|
||||
form.appendChild(input)
|
||||
}
|
||||
|
||||
document.body.appendChild(form)
|
||||
form.submit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that returns the Allauth API with automatic auth refresh on relevant responses.
|
||||
*
|
||||
* Automatically triggers auth refresh when:
|
||||
* - 401 with flows (authentication required)
|
||||
* - 410 (session gone)
|
||||
* - 200 with is_authenticated (successful auth)
|
||||
*/
|
||||
export function useAllauthAPI(): AllauthAPI {
|
||||
const client = useDjangoCSRClient(Auth.SESSION)
|
||||
const { refresh } = useAuthContext()
|
||||
|
||||
return useMemo(() => {
|
||||
const authRequest = async (method: string, path: string, data?: any, headers?: Record<string, string>) => {
|
||||
const resp = await client.request(method, `/_allauth/browser/v1${path}`, data, headers)
|
||||
|
||||
if (resp.status >= 500) {
|
||||
throw new Error(`Allauth request failed: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
|
||||
try {
|
||||
return await resp.json()
|
||||
} catch {
|
||||
throw new Error(`Allauth request failed: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
return createAPI(
|
||||
async (method, path, data?, headers?) => {
|
||||
const resp = await authRequest(method, path, { ...(data as object), client: 'browser' }, headers)
|
||||
|
||||
// Auto-refresh auth state on relevant responses
|
||||
if (resp.status === 401 && resp.data?.flows) {
|
||||
refresh(resp)
|
||||
} else if ([401, 410].includes(resp.status) || (resp.status === 200 && resp.meta?.is_authenticated)) {
|
||||
refresh()
|
||||
}
|
||||
|
||||
return resp
|
||||
},
|
||||
browserFormAction
|
||||
)
|
||||
}, [client, refresh])
|
||||
}
|
||||
116
legacy/allauth/contexts/AllauthContext.tsx
Normal file
116
legacy/allauth/contexts/AllauthContext.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { useDjangoCSRClient, Auth } from 'mizan/client/react'
|
||||
import type { RouterAdapter } from '../adapters/router'
|
||||
import type { InitialAuth } from '../hydration'
|
||||
import { AuthContext } from './AuthContext'
|
||||
import { ConfigContext } from './ConfigContext'
|
||||
import { StylesContext } from './StylesContext'
|
||||
import { RouterContext } from './RouterContext'
|
||||
import { AllauthConfig } from '../config'
|
||||
import { AuthClassNames } from '../styles/types'
|
||||
import { createAPI } from '../api'
|
||||
|
||||
export interface AllauthContextProps {
|
||||
children: ReactNode
|
||||
|
||||
/** Router adapter for navigation */
|
||||
router: RouterAdapter
|
||||
|
||||
/** Optional initial auth state from getInitialAuth() - if not provided, fetches client-side */
|
||||
hydration?: InitialAuth
|
||||
|
||||
/** Library configuration (basePath, routes) */
|
||||
allauthConfig?: Partial<AllauthConfig>
|
||||
|
||||
/** CSS class names for styling components */
|
||||
classNames?: AuthClassNames
|
||||
}
|
||||
|
||||
/**
|
||||
* Core AllauthContext - sets up all contexts for the allauth library.
|
||||
*
|
||||
* IMPORTANT: AllauthContext must be wrapped by DjangoContext, which provides
|
||||
* user data via useUser(). The typical setup is:
|
||||
*
|
||||
* ```tsx
|
||||
* <DjangoContext client={client} hydration={djangoHydration}>
|
||||
* <AllauthContext hydration={allauthHydration}>
|
||||
* {children}
|
||||
* </AllauthContext>
|
||||
* </DjangoContext>
|
||||
* ```
|
||||
*
|
||||
* If hydration is provided (from SSR), uses it immediately.
|
||||
* If not provided, fetches initial auth state client-side using the CSR client.
|
||||
*
|
||||
* For Next.js apps, use NextAllauthContext instead which handles the router automatically.
|
||||
*/
|
||||
export function AllauthContext({
|
||||
children,
|
||||
router,
|
||||
hydration,
|
||||
allauthConfig,
|
||||
classNames,
|
||||
}: AllauthContextProps) {
|
||||
const client = useDjangoCSRClient(Auth.SESSION)
|
||||
const [initialAuth, setInitialAuth] = useState<InitialAuth | null>(hydration ?? null)
|
||||
const [loading, setLoading] = useState(!hydration)
|
||||
|
||||
useEffect(() => {
|
||||
if (hydration) return // Already have SSR hydration
|
||||
|
||||
const fetchInitialAuth = async () => {
|
||||
try {
|
||||
const authRequest = async (method: string, path: string, data?: any, headers?: Record<string, string>) => {
|
||||
const resp = await client.request(method, `/_allauth/browser/v1${path}`, data, headers)
|
||||
if (resp.status >= 500) {
|
||||
throw new Error(`Allauth request failed: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
const api = createAPI((method, path, data?, headers?) =>
|
||||
authRequest(method, path, { ...(data as object), client: 'browser' }, headers)
|
||||
)
|
||||
|
||||
const [config, auth] = await Promise.all([
|
||||
api.getConfig(),
|
||||
api.session.getStatus(),
|
||||
])
|
||||
|
||||
setInitialAuth({ config, auth })
|
||||
} catch (e) {
|
||||
console.error('[AllauthContext] Failed to fetch initial auth:', e)
|
||||
setInitialAuth({
|
||||
config: { status: 200, data: {} },
|
||||
auth: { status: 401, data: {} },
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchInitialAuth()
|
||||
}, [client, hydration])
|
||||
|
||||
if (loading || !initialAuth) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<RouterContext router={router}>
|
||||
<ConfigContext config={allauthConfig}>
|
||||
<StylesContext classNames={classNames}>
|
||||
<AuthContext
|
||||
config={initialAuth.config}
|
||||
auth={initialAuth.auth}
|
||||
>
|
||||
{children}
|
||||
</AuthContext>
|
||||
</StylesContext>
|
||||
</ConfigContext>
|
||||
</RouterContext>
|
||||
)
|
||||
}
|
||||
153
legacy/allauth/contexts/AuthContext.tsx
Normal file
153
legacy/allauth/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useDjangoCSRClient, Auth } from 'mizan/client/react'
|
||||
import { useMizan, useMizanContext } from 'mizan'
|
||||
import { getAuthDetails, createAPI } from '../api'
|
||||
import type { AllauthResponse } from '../types'
|
||||
import getAuthChangeEvent from '../events'
|
||||
|
||||
export interface AuthState {
|
||||
config: AllauthResponse
|
||||
auth: AllauthResponse
|
||||
event: string
|
||||
refresh: (newAuth?: AllauthResponse) => Promise<AllauthResponse>
|
||||
}
|
||||
|
||||
const Context = createContext<AuthState | null>(null)
|
||||
|
||||
export interface AuthContextProps {
|
||||
children: ReactNode
|
||||
/** Initial config from hydration */
|
||||
config: AllauthResponse
|
||||
/** Initial auth from hydration */
|
||||
auth: AllauthResponse
|
||||
}
|
||||
|
||||
export function AuthContext({
|
||||
children,
|
||||
config,
|
||||
auth: initialAuth,
|
||||
}: AuthContextProps) {
|
||||
const client = useDjangoCSRClient(Auth.SESSION)
|
||||
const { refreshAllContexts } = useMizan()
|
||||
const [auth, setAuth] = useState(initialAuth)
|
||||
const [event, setEvent] = useState('')
|
||||
const prevAuth = useRef(initialAuth)
|
||||
|
||||
// Create API for refresh operations
|
||||
const baseAPI = useMemo(() => {
|
||||
const authRequest = async (method: string, path: string, data?: any, headers?: Record<string, string>) => {
|
||||
const resp = await client.request(method, `/_allauth/browser/v1${path}`, data, headers)
|
||||
if (resp.status >= 500) {
|
||||
throw new Error(`Allauth request failed: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
return createAPI((method, path, data?, headers?) =>
|
||||
authRequest(method, path, { ...(data as object), client: 'browser' }, headers)
|
||||
)
|
||||
}, [client])
|
||||
|
||||
const refresh = useCallback(async (newAuth?: AllauthResponse): Promise<AllauthResponse> => {
|
||||
const authState = newAuth ?? await baseAPI.session.getStatus()
|
||||
setAuth(authState)
|
||||
|
||||
// Refresh all Django contexts (user data, permissions, etc.)
|
||||
await refreshAllContexts()
|
||||
|
||||
return authState
|
||||
}, [baseAPI, refreshAllContexts])
|
||||
|
||||
useEffect(() => {
|
||||
if (prevAuth.current && auth) {
|
||||
setEvent(getAuthChangeEvent(prevAuth.current, auth))
|
||||
}
|
||||
prevAuth.current = auth
|
||||
}, [auth])
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
config, auth, event, refresh
|
||||
}), [config, auth, event, refresh])
|
||||
|
||||
return (
|
||||
<Context value={contextValue}>
|
||||
{children}
|
||||
</Context>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuthContext(): AuthState {
|
||||
const ctx = useContext(Context)
|
||||
if (!ctx) throw new Error('useAuthContext must be used within AuthContext')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return getAuthDetails(useAuthContext().auth)
|
||||
}
|
||||
|
||||
/**
|
||||
* Base user interface expected by Allauth.
|
||||
* Products can extend this with additional fields.
|
||||
*/
|
||||
export interface AllauthUser {
|
||||
email?: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
is_staff?: boolean
|
||||
is_superuser?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user from MizanProvider.
|
||||
*
|
||||
* This uses the generic mizan hook to access the 'user' context.
|
||||
* The backend defines this context in lib/mizan/allauth/contexts.py:
|
||||
*
|
||||
* @client(context='global')
|
||||
* def user(request) -> UserOutput | None:
|
||||
* ...
|
||||
*
|
||||
* @typeParam T - User type (defaults to AllauthUser, products can use more specific types)
|
||||
*/
|
||||
export function useUser<T extends AllauthUser = AllauthUser>(): T {
|
||||
const user = useMizanContext<T>('user')
|
||||
// Return empty object cast to T if user is undefined (not loaded)
|
||||
// This matches the previous behavior and allows optional chaining
|
||||
return (user ?? {}) as T
|
||||
}
|
||||
|
||||
export function useConfig() {
|
||||
return useAuthContext().config
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access backend feature flags from the allauth configuration.
|
||||
*/
|
||||
export function useFeatures() {
|
||||
const config = useConfig()
|
||||
const data = config?.data as {
|
||||
account?: {
|
||||
is_open_for_signup?: boolean
|
||||
login_by_code_enabled?: boolean
|
||||
email_verification_by_code_enabled?: boolean
|
||||
}
|
||||
mfa?: {
|
||||
supported_types?: string[]
|
||||
}
|
||||
socialaccount?: {
|
||||
providers?: any[]
|
||||
}
|
||||
} | undefined
|
||||
|
||||
return {
|
||||
signupEnabled: data?.account?.is_open_for_signup ?? true,
|
||||
loginByCodeEnabled: data?.account?.login_by_code_enabled ?? false,
|
||||
emailVerificationByCodeEnabled: data?.account?.email_verification_by_code_enabled ?? false,
|
||||
mfaEnabled: (data?.mfa?.supported_types?.length ?? 0) > 0,
|
||||
mfaTypes: data?.mfa?.supported_types ?? [],
|
||||
socialLoginEnabled: (data?.socialaccount?.providers?.length ?? 0) > 0,
|
||||
socialProviders: data?.socialaccount?.providers ?? [],
|
||||
}
|
||||
}
|
||||
29
legacy/allauth/contexts/ConfigContext.tsx
Normal file
29
legacy/allauth/contexts/ConfigContext.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, ReactNode, useContext, useMemo } from 'react'
|
||||
import { AllauthConfig, defaultConfig, createAllauthConfig } from '../config'
|
||||
|
||||
const Context = createContext<AllauthConfig>(defaultConfig)
|
||||
|
||||
interface ConfigContextProps {
|
||||
children: ReactNode
|
||||
config?: Partial<AllauthConfig>
|
||||
}
|
||||
|
||||
export function ConfigContext({ children, config }: ConfigContextProps) {
|
||||
// Memoize the merged config to prevent creating new objects on every render
|
||||
const mergedConfig = useMemo(
|
||||
() => config ? createAllauthConfig(config) : defaultConfig,
|
||||
[config]
|
||||
)
|
||||
|
||||
return (
|
||||
<Context value={mergedConfig}>
|
||||
{children}
|
||||
</Context>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAllauthConfig(): AllauthConfig {
|
||||
return useContext(Context)
|
||||
}
|
||||
31
legacy/allauth/contexts/RouterContext.tsx
Normal file
31
legacy/allauth/contexts/RouterContext.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import type { RouterAdapter } from '../adapters/router'
|
||||
|
||||
const Context = createContext<RouterAdapter | null>(null)
|
||||
|
||||
interface RouterContextProps {
|
||||
children: ReactNode
|
||||
router: RouterAdapter
|
||||
}
|
||||
|
||||
export function RouterContext({ children, router }: RouterContextProps) {
|
||||
return (
|
||||
<Context value={router}>
|
||||
{children}
|
||||
</Context>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the router adapter.
|
||||
* Must be used within AllauthContext.
|
||||
*/
|
||||
export function useRouter(): RouterAdapter {
|
||||
const router = useContext(Context)
|
||||
if (!router) {
|
||||
throw new Error('useRouter must be used within AllauthContext')
|
||||
}
|
||||
return router
|
||||
}
|
||||
49
legacy/allauth/contexts/StylesContext.tsx
Normal file
49
legacy/allauth/contexts/StylesContext.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, ReactNode } from 'react'
|
||||
import { AuthClassNames, emptyClassNames } from '../styles/types'
|
||||
|
||||
const Context = createContext<AuthClassNames>(emptyClassNames)
|
||||
|
||||
interface StylesContextProps {
|
||||
children: ReactNode
|
||||
classNames?: AuthClassNames
|
||||
}
|
||||
|
||||
export function StylesContext({ children, classNames }: StylesContextProps) {
|
||||
return (
|
||||
<Context value={classNames ?? emptyClassNames}>
|
||||
{children}
|
||||
</Context>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access auth component class names.
|
||||
*
|
||||
* Returns the class names provided to AllauthProvider, or empty strings if none provided.
|
||||
* Use this to style custom components consistently with the auth UI.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyAuthComponent() {
|
||||
* const styles = useStyles()
|
||||
* return (
|
||||
* <div className={styles.card}>
|
||||
* <h1 className={styles.title}>Custom Auth View</h1>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useStyles(): AuthClassNames {
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to get a class name, returning empty string if undefined.
|
||||
* Useful for conditional class application.
|
||||
*/
|
||||
export function cx(...classNames: (string | undefined | false | null)[]): string {
|
||||
return classNames.filter(Boolean).join(' ')
|
||||
}
|
||||
6
legacy/allauth/contexts/index.ts
Normal file
6
legacy/allauth/contexts/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { AllauthContext, type AllauthContextProps } from './AllauthContext'
|
||||
export { AuthContext, useAuthContext, useAuth, useUser, useConfig, useFeatures } from './AuthContext'
|
||||
export { useAllauthAPI } from './APIContext'
|
||||
export { ConfigContext, useAllauthConfig } from './ConfigContext'
|
||||
export { StylesContext, useStyles, cx } from './StylesContext'
|
||||
export { RouterContext, useRouter } from './RouterContext'
|
||||
71
legacy/allauth/defines.ts
Normal file
71
legacy/allauth/defines.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export const OAuthProcess = {
|
||||
LOGIN: 'login',
|
||||
CONNECT: 'connect'
|
||||
}
|
||||
|
||||
export const AuthenticatorType = {
|
||||
TOTP: 'totp',
|
||||
RECOVERY_CODES: 'recovery_codes',
|
||||
WEBAUTHN: 'webauthn'
|
||||
}
|
||||
|
||||
export const Flows = {
|
||||
LOGIN: 'login',
|
||||
LOGIN_BY_CODE: 'login_by_code',
|
||||
MFA_AUTHENTICATE: 'mfa_authenticate',
|
||||
MFA_REAUTHENTICATE: 'mfa_reauthenticate',
|
||||
MFA_TRUST: 'mfa_trust',
|
||||
MFA_WEBAUTHN_SIGNUP: 'mfa_signup_webauthn',
|
||||
PASSWORD_RESET_BY_CODE: 'password_reset_by_code',
|
||||
PROVIDER_REDIRECT: 'provider_redirect',
|
||||
PROVIDER_SIGNUP: 'provider_signup',
|
||||
REAUTHENTICATE: 'reauthenticate',
|
||||
SIGNUP: 'signup',
|
||||
VERIFY_EMAIL: 'verify_email',
|
||||
}
|
||||
|
||||
export const apiURL = {
|
||||
// Meta
|
||||
CONFIG: '/config',
|
||||
|
||||
// Account management
|
||||
CHANGE_PASSWORD: '/account/password/change',
|
||||
EMAIL: '/account/email',
|
||||
PROVIDERS: '/account/providers',
|
||||
|
||||
// Account management: 2FA
|
||||
AUTHENTICATORS: '/account/authenticators',
|
||||
RECOVERY_CODES: '/account/authenticators/recovery-codes',
|
||||
TOTP_AUTHENTICATOR: '/account/authenticators/totp',
|
||||
|
||||
// Auth: Basics
|
||||
LOGIN: '/auth/login',
|
||||
REQUEST_LOGIN_CODE: '/auth/code/request',
|
||||
CONFIRM_LOGIN_CODE: '/auth/code/confirm',
|
||||
SESSION: '/auth/session',
|
||||
REAUTHENTICATE: '/auth/reauthenticate',
|
||||
REQUEST_PASSWORD_RESET: '/auth/password/request',
|
||||
RESET_PASSWORD: '/auth/password/reset',
|
||||
SIGNUP: '/auth/signup',
|
||||
VERIFY_EMAIL: '/auth/email/verify',
|
||||
|
||||
// Auth: 2FA
|
||||
MFA_AUTHENTICATE: '/auth/2fa/authenticate',
|
||||
MFA_REAUTHENTICATE: '/auth/2fa/reauthenticate',
|
||||
MFA_TRUST: '/auth/2fa/trust',
|
||||
|
||||
// Auth: Social
|
||||
PROVIDER_SIGNUP: '/auth/provider/signup',
|
||||
REDIRECT_TO_PROVIDER: '/auth/provider/redirect',
|
||||
PROVIDER_TOKEN: '/auth/provider/token',
|
||||
|
||||
// Auth: Sessions
|
||||
SESSIONS: '/auth/sessions',
|
||||
|
||||
// Auth: WebAuthn
|
||||
REAUTHENTICATE_WEBAUTHN: '/auth/webauthn/reauthenticate',
|
||||
AUTHENTICATE_WEBAUTHN: '/auth/webauthn/authenticate',
|
||||
LOGIN_WEBAUTHN: '/auth/webauthn/login',
|
||||
SIGNUP_WEBAUTHN: '/auth/webauthn/signup',
|
||||
WEBAUTHN_AUTHENTICATOR: '/account/authenticators/webauthn'
|
||||
}
|
||||
51
legacy/allauth/events.ts
Normal file
51
legacy/allauth/events.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { getAuthDetails } from './api'
|
||||
import type { AllauthResponse, AuthenticationMethod } from './types'
|
||||
|
||||
export const AuthChangeEvent = {
|
||||
LOGGED_OUT: 'LOGGED_OUT',
|
||||
LOGGED_IN: 'LOGGED_IN',
|
||||
REAUTHENTICATED: 'REAUTHENTICATED',
|
||||
REAUTHENTICATION_REQUIRED: 'REAUTHENTICATION_REQUIRED',
|
||||
FLOW_UPDATED: 'FLOW_UPDATED'
|
||||
}
|
||||
|
||||
export default function getAuthChangeEvent(fromAuth: AllauthResponse, toAuth: AllauthResponse): string {
|
||||
let before = getAuthDetails(fromAuth)
|
||||
const after = getAuthDetails(toAuth)
|
||||
|
||||
if (toAuth.status === 410) {
|
||||
return AuthChangeEvent.LOGGED_OUT
|
||||
}
|
||||
|
||||
const shouldReauth = () => {
|
||||
const fromMethods = (fromAuth.data?.methods as AuthenticationMethod[] | undefined) ?? []
|
||||
const toMethods = (toAuth.data?.methods as AuthenticationMethod[] | undefined) ?? []
|
||||
return (before.requiresReauthentication) || (fromMethods.length < toMethods.length)
|
||||
}
|
||||
|
||||
// Corner case: user ID change. Treat as if we're transitioning from anonymous state.
|
||||
if (before.user && after.user && before.user?.id !== after.user?.id) {
|
||||
before = { isAuthenticated: false, requiresReauthentication: false, user: null, pendingFlow: undefined }
|
||||
}
|
||||
|
||||
if (!before.isAuthenticated && after.isAuthenticated) {
|
||||
return AuthChangeEvent.LOGGED_IN
|
||||
} else if (before.isAuthenticated && !after.isAuthenticated) {
|
||||
return AuthChangeEvent.LOGGED_OUT
|
||||
} else if (before.isAuthenticated && after.isAuthenticated) {
|
||||
if (after.requiresReauthentication) {
|
||||
return AuthChangeEvent.REAUTHENTICATION_REQUIRED
|
||||
} else if (shouldReauth()) {
|
||||
return AuthChangeEvent.REAUTHENTICATED
|
||||
}
|
||||
} else if (!before.isAuthenticated && !after.isAuthenticated) {
|
||||
const fromFlow = before.pendingFlow
|
||||
const toFlow = after.pendingFlow
|
||||
if (toFlow?.id && fromFlow?.id !== toFlow.id) {
|
||||
return AuthChangeEvent.FLOW_UPDATED
|
||||
}
|
||||
}
|
||||
|
||||
// No change.
|
||||
return ''
|
||||
}
|
||||
48
legacy/allauth/hydration.ts
Normal file
48
legacy/allauth/hydration.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { DjangoHTTPClient } from 'mizan/client'
|
||||
import { createAPI } from './api'
|
||||
import type { AllauthResponse } from './types'
|
||||
|
||||
export interface InitialAuth {
|
||||
config: AllauthResponse
|
||||
auth: AllauthResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch initial allauth state using an SSR client.
|
||||
* Call this in a server component and pass the result to AllauthContext.
|
||||
*
|
||||
* Note: User data comes from DjangoContext (which should wrap AllauthContext).
|
||||
* Use getDjangoHydration() from generated.contexts for that.
|
||||
*
|
||||
* @param ssrClient - A server-side Django HTTP client (e.g., createDjangoSSRClient)
|
||||
*/
|
||||
export async function getInitialAuth(
|
||||
ssrClient: DjangoHTTPClient,
|
||||
): Promise<InitialAuth> {
|
||||
const authRequest = async (method: string, path: string, data?: any, headers?: Record<string, string>) => {
|
||||
const resp = await ssrClient.request(method, `/_allauth/browser/v1${path}`, data, headers)
|
||||
if (resp.status >= 500) {
|
||||
throw new Error(`Allauth request failed: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
const api = createAPI((method, path, data?, headers?) =>
|
||||
authRequest(method, path, { ...(data as object), client: 'browser' }, headers)
|
||||
)
|
||||
|
||||
try {
|
||||
const [config, auth] = await Promise.all([
|
||||
api.getConfig(),
|
||||
api.session.getStatus(),
|
||||
])
|
||||
|
||||
return { config, auth }
|
||||
} catch (e) {
|
||||
console.error('[getInitialAuth] Failed to fetch initial auth:', e)
|
||||
return {
|
||||
config: { status: 200, data: {} },
|
||||
auth: { status: 401, data: {} },
|
||||
}
|
||||
}
|
||||
}
|
||||
213
legacy/allauth/index.ts
Normal file
213
legacy/allauth/index.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* mizan/allauth
|
||||
*
|
||||
* React integration for django-allauth headless API.
|
||||
* Framework-agnostic - works with Next.js, Remix, React Router, etc.
|
||||
*
|
||||
* ## Quick Start (Next.js)
|
||||
*
|
||||
* ```tsx
|
||||
* // layout.tsx
|
||||
* import { cookies } from 'next/headers'
|
||||
* import { createDjangoSSRClient } from 'mizan/client'
|
||||
* import { getInitialAuth } from 'mizan/allauth'
|
||||
* import { NextAllauthContext } from 'mizan/allauth/nextjs'
|
||||
*
|
||||
* export default async function RootLayout({ children }) {
|
||||
* const ssrClient = createDjangoSSRClient({ cookies: await cookies() })
|
||||
* const hydration = await getInitialAuth(ssrClient)
|
||||
*
|
||||
* return (
|
||||
* <NextAllauthContext hydration={hydration}>
|
||||
* {children}
|
||||
* </NextAllauthContext>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## Without SSR (pure client-side)
|
||||
*
|
||||
* ```tsx
|
||||
* // Just omit hydration - AllauthContext will fetch client-side
|
||||
* <NextAllauthContext>
|
||||
* {children}
|
||||
* </NextAllauthContext>
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Configuration
|
||||
export { createAllauthConfig, defaultConfig, DjangoFlowPaths } from './config'
|
||||
export type { AllauthConfig } from './config'
|
||||
|
||||
// Hydration
|
||||
export { getInitialAuth } from './hydration'
|
||||
export type { InitialAuth } from './hydration'
|
||||
|
||||
// Providers
|
||||
export { AllauthContext } from './contexts/AllauthContext'
|
||||
export type { AllauthContextProps } from './contexts/AllauthContext'
|
||||
|
||||
// Router adapter
|
||||
export type { RouterAdapter } from './adapters/router'
|
||||
export { useRouter } from './contexts/RouterContext'
|
||||
|
||||
// Hooks
|
||||
export { useAuthContext, useAuth, useUser, useConfig, useFeatures } from './contexts/AuthContext'
|
||||
export { useAllauthAPI } from './contexts/APIContext'
|
||||
export { useAllauthConfig } from './contexts/ConfigContext'
|
||||
export { useStyles, cx } from './contexts/StylesContext'
|
||||
|
||||
// Styling
|
||||
export type { AuthClassNames } from './styles/types'
|
||||
|
||||
// Components
|
||||
export {
|
||||
// Main UI component (SPA - handles login, signup, MFA, settings, logout)
|
||||
AllauthUI,
|
||||
// Django-initiated flow handler (email verification, password reset links, OAuth)
|
||||
AllauthRouter,
|
||||
// Settings
|
||||
AuthSettings,
|
||||
ProfileSection,
|
||||
EmailsSection,
|
||||
PasswordSection,
|
||||
PasskeysSection,
|
||||
ConnectionsSection,
|
||||
MFASection,
|
||||
SessionsSection,
|
||||
SettingsSection,
|
||||
SettingsItem,
|
||||
SettingsList,
|
||||
Badge,
|
||||
Button,
|
||||
// Individual auth views
|
||||
LoginView,
|
||||
SignupView,
|
||||
MFAChooserView,
|
||||
MFAWebAuthnView,
|
||||
MFATOTPView,
|
||||
MFARecoveryCodesView,
|
||||
// Building blocks
|
||||
AuthCard,
|
||||
AuthFormPage,
|
||||
AuthDjangoForm,
|
||||
PasskeyLogin,
|
||||
ProviderList,
|
||||
// Form utilities
|
||||
useAuthForm,
|
||||
AuthField,
|
||||
} from './components'
|
||||
export type { AllauthUIView, AllauthUIMode } from './components'
|
||||
|
||||
// Routing guards
|
||||
export { UserRoute, StaffRoute, AnonymousRoute, FeatureRoute } from './routing'
|
||||
|
||||
// API
|
||||
export { createAPI, getAuthDetails } from './api'
|
||||
export type { AuthResponse, AuthDetails, AllauthAPI, BrowserFormAction } from './api'
|
||||
|
||||
// Types (re-exported from types.ts)
|
||||
export type {
|
||||
// Primitive types
|
||||
Timestamp,
|
||||
Email,
|
||||
Phone,
|
||||
Username,
|
||||
Password,
|
||||
Code,
|
||||
AuthenticatorCode,
|
||||
ProviderID,
|
||||
ProviderAccountID,
|
||||
AuthenticatorID,
|
||||
ClientID,
|
||||
// Enums
|
||||
AuthenticatorType as AuthenticatorTypeEnum,
|
||||
FlowID,
|
||||
LoginMethod,
|
||||
OAuthProcess,
|
||||
ProviderFlow,
|
||||
// User & Session
|
||||
User,
|
||||
Session,
|
||||
EmailAddress,
|
||||
PhoneNumber,
|
||||
// Authentication
|
||||
Flow,
|
||||
AuthenticationMethod,
|
||||
Authenticated,
|
||||
ReauthenticationRequired,
|
||||
// Provider
|
||||
Provider,
|
||||
ProviderAccount,
|
||||
// MFA / Authenticator
|
||||
BaseAuthenticator,
|
||||
TOTPAuthenticator,
|
||||
RecoveryCodesAuthenticator,
|
||||
SensitiveRecoveryCodesAuthenticator,
|
||||
WebAuthnAuthenticator,
|
||||
Authenticator,
|
||||
// Configuration
|
||||
AccountConfiguration,
|
||||
SocialAccountConfiguration,
|
||||
MFAConfiguration,
|
||||
UserSessionsConfiguration,
|
||||
AllauthConfiguration,
|
||||
// WebAuthn
|
||||
WebAuthnPublicKeyCredentialCreationOptions,
|
||||
WebAuthnPublicKeyCredentialRequestOptions,
|
||||
WebAuthnCreationOptions,
|
||||
WebAuthnRequestOptions,
|
||||
// TOTP
|
||||
TOTPStatus,
|
||||
// Meta
|
||||
BaseAuthenticationMeta,
|
||||
AuthenticationMeta,
|
||||
AuthenticatedMeta,
|
||||
// Response types
|
||||
AuthError,
|
||||
AllauthResponse,
|
||||
AuthenticatedResponse,
|
||||
ConfigurationResponse,
|
||||
EmailListResponse,
|
||||
SessionListResponse,
|
||||
AuthenticatorListResponse,
|
||||
ProviderAccountListResponse,
|
||||
TOTPStatusResponse,
|
||||
RecoveryCodesResponse,
|
||||
WebAuthnCreationOptionsResponse,
|
||||
WebAuthnRequestOptionsResponse,
|
||||
EmailVerificationInfoResponse,
|
||||
AuthenticationRequiredResponse,
|
||||
ReauthenticationRequiredResponse,
|
||||
ErrorResponse,
|
||||
ForbiddenResponse,
|
||||
ConflictResponse,
|
||||
SessionGoneResponse,
|
||||
// Request types
|
||||
LoginRequest,
|
||||
SignupRequest,
|
||||
ProviderSignupRequest,
|
||||
ReauthenticateRequest,
|
||||
RequestLoginCodeRequest,
|
||||
ConfirmLoginCodeRequest,
|
||||
MFAAuthenticateRequest,
|
||||
MFATrustRequest,
|
||||
RequestPasswordResetRequest,
|
||||
ResetPasswordRequest,
|
||||
VerifyEmailRequest,
|
||||
ChangePasswordRequest,
|
||||
AddEmailRequest,
|
||||
ProviderRedirectRequest,
|
||||
ProviderTokenRequest,
|
||||
WebAuthnAddRequest,
|
||||
WebAuthnAuthenticateRequest,
|
||||
WebAuthnUpdateRequest,
|
||||
WebAuthnDeleteRequest,
|
||||
EndSessionsRequest,
|
||||
// Union types
|
||||
AuthResponse as AuthResponseUnion,
|
||||
SessionStatusResponse,
|
||||
} from './types'
|
||||
|
||||
// Constants
|
||||
export { Flows, AuthenticatorType } from './defines'
|
||||
96
legacy/allauth/nextjs.tsx
Normal file
96
legacy/allauth/nextjs.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Next.js adapter for mizan/allauth.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* // In layout.tsx (server component)
|
||||
* import { createDjangoSSRClient } from 'mizan/client'
|
||||
* import { getInitialAuth } from 'mizan/allauth'
|
||||
* import { NextAllauthContext } from 'mizan/allauth/nextjs'
|
||||
*
|
||||
* export default async function RootLayout({ children }) {
|
||||
* const ssrClient = createDjangoSSRClient({ cookies: await cookies() })
|
||||
* const hydration = await getInitialAuth(ssrClient)
|
||||
*
|
||||
* return (
|
||||
* <NextAllauthContext hydration={hydration}>
|
||||
* {children}
|
||||
* </NextAllauthContext>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import { useRouter, usePathname, useSearchParams, useParams } from 'next/navigation'
|
||||
import type { RouterAdapter } from './adapters/router'
|
||||
import type { InitialAuth } from './hydration'
|
||||
import { AllauthContext } from './contexts/AllauthContext'
|
||||
import { AllauthConfig } from './config'
|
||||
import { AuthClassNames } from './styles/types'
|
||||
|
||||
/**
|
||||
* Create a RouterAdapter from Next.js App Router hooks.
|
||||
*/
|
||||
export function useNextRouter(): RouterAdapter {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const params = useParams()
|
||||
|
||||
return {
|
||||
push: (path: string) => router.push(path),
|
||||
replace: (path: string) => router.replace(path),
|
||||
pathname,
|
||||
searchParams: new URLSearchParams(searchParams.toString()),
|
||||
getParam: (name: string) => params[name] as string | string[] | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export interface NextAllauthContextProps {
|
||||
children: ReactNode
|
||||
|
||||
/** Optional initial auth state from getInitialAuth() - if not provided, fetches client-side */
|
||||
hydration?: InitialAuth
|
||||
|
||||
/** Library configuration (basePath, routes) */
|
||||
allauthConfig?: Partial<AllauthConfig>
|
||||
|
||||
/** CSS class names for styling components */
|
||||
classNames?: AuthClassNames
|
||||
}
|
||||
|
||||
/**
|
||||
* Next.js-specific AllauthContext that handles the router automatically.
|
||||
*
|
||||
* IMPORTANT: Must be wrapped by DjangoContext which provides user data.
|
||||
*
|
||||
* ```tsx
|
||||
* <DjangoContext client={client} hydration={djangoHydration}>
|
||||
* <NextAllauthContext hydration={allauthHydration}>
|
||||
* {children}
|
||||
* </NextAllauthContext>
|
||||
* </DjangoContext>
|
||||
* ```
|
||||
*/
|
||||
export function NextAllauthContext({
|
||||
children,
|
||||
hydration,
|
||||
allauthConfig,
|
||||
classNames,
|
||||
}: NextAllauthContextProps) {
|
||||
const router = useNextRouter()
|
||||
|
||||
return (
|
||||
<AllauthContext
|
||||
hydration={hydration}
|
||||
router={router}
|
||||
allauthConfig={allauthConfig}
|
||||
classNames={classNames}
|
||||
>
|
||||
{children}
|
||||
</AllauthContext>
|
||||
)
|
||||
}
|
||||
110
legacy/allauth/routing.tsx
Normal file
110
legacy/allauth/routing.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from './contexts/RouterContext'
|
||||
import { useAllauthConfig } from './contexts/ConfigContext'
|
||||
import { useAuth, useUser, useConfig } from './contexts/AuthContext'
|
||||
|
||||
/**
|
||||
* Route guard that only renders children if the user is authenticated.
|
||||
* Redirects to login page if not authenticated.
|
||||
*/
|
||||
export function UserRoute({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
const config = useAllauthConfig()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
const next = encodeURIComponent(router.pathname + router.searchParams.toString())
|
||||
router.replace(`${config.routes.login}?next=${next}`)
|
||||
}
|
||||
}, [isAuthenticated, router, config.routes.login])
|
||||
|
||||
if (!isAuthenticated) return null
|
||||
return children
|
||||
}
|
||||
|
||||
/**
|
||||
* Route guard that only renders children if the user is authenticated AND is staff.
|
||||
* Redirects to login if not authenticated, or to authenticated route if not staff.
|
||||
*/
|
||||
export function StaffRoute({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
const config = useAllauthConfig()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const user = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
const next = encodeURIComponent(router.pathname + router.searchParams.toString())
|
||||
router.replace(`${config.routes.login}?next=${next}`)
|
||||
} else if (!user.is_staff) {
|
||||
router.replace(config.routes.authenticated)
|
||||
}
|
||||
}, [isAuthenticated, user.is_staff, router, config.routes])
|
||||
|
||||
if (!isAuthenticated || !user.is_staff) return null
|
||||
return children
|
||||
}
|
||||
|
||||
/**
|
||||
* Route guard that only renders children if the user is NOT authenticated.
|
||||
* Redirects to authenticated route if already logged in.
|
||||
*/
|
||||
export function AnonymousRoute({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
const config = useAllauthConfig()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.replace(config.routes.authenticated)
|
||||
}
|
||||
}, [isAuthenticated, config.routes.authenticated, router])
|
||||
|
||||
if (isAuthenticated) return null
|
||||
return children
|
||||
}
|
||||
|
||||
/**
|
||||
* Route guard that checks if a feature is enabled in the allauth config.
|
||||
* Redirects to fallback if feature is disabled.
|
||||
*/
|
||||
type FeatureKey = 'signup' | 'login_by_code' | 'mfa' | 'socialaccount'
|
||||
|
||||
function isFeatureEnabled(config: any, feature: FeatureKey): boolean | undefined {
|
||||
if (!config?.data) return undefined
|
||||
switch (feature) {
|
||||
case 'signup': return config.data.account?.is_open_for_signup
|
||||
case 'login_by_code': return config.data.account?.login_by_code_enabled
|
||||
case 'mfa': return config.data.mfa !== undefined
|
||||
case 'socialaccount': return (config.data.socialaccount?.providers?.length ?? 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
export function FeatureRoute({
|
||||
children,
|
||||
feature,
|
||||
redirectTo,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
feature: FeatureKey
|
||||
redirectTo?: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const allauthConfig = useConfig()
|
||||
const config = useAllauthConfig()
|
||||
|
||||
const enabled = isFeatureEnabled(allauthConfig, feature)
|
||||
const fallback = redirectTo ?? config.routes.login
|
||||
|
||||
useEffect(() => {
|
||||
if (allauthConfig && enabled === false) {
|
||||
router.replace(fallback)
|
||||
}
|
||||
}, [allauthConfig, enabled, fallback, router])
|
||||
|
||||
if (!allauthConfig || enabled === false) return null
|
||||
return children
|
||||
}
|
||||
122
legacy/allauth/styles/types.ts
Normal file
122
legacy/allauth/styles/types.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Class names for styling the auth components.
|
||||
*
|
||||
* All properties are optional - components will use empty strings as defaults.
|
||||
* Pass your own CSS module or Tailwind classes to customize the appearance.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // With CSS Modules
|
||||
* import styles from './auth.module.css'
|
||||
* <AllauthProvider classNames={styles}>
|
||||
*
|
||||
* // With Tailwind
|
||||
* const classNames = {
|
||||
* container: 'max-w-md mx-auto p-4',
|
||||
* card: 'bg-white rounded-lg shadow-lg p-6',
|
||||
* title: 'text-2xl font-bold',
|
||||
* // ...
|
||||
* }
|
||||
* <AllauthProvider classNames={classNames}>
|
||||
* ```
|
||||
*/
|
||||
export interface AuthClassNames {
|
||||
// Layout
|
||||
container?: string
|
||||
card?: string
|
||||
|
||||
// Typography
|
||||
title?: string
|
||||
subtitle?: string
|
||||
|
||||
// Form elements
|
||||
form?: string
|
||||
fieldsContainer?: string
|
||||
field?: string
|
||||
fieldLabel?: string
|
||||
fieldInput?: string
|
||||
fieldHelp?: string
|
||||
fieldError?: string
|
||||
required?: string
|
||||
|
||||
// Buttons
|
||||
submit?: string
|
||||
link?: string
|
||||
smallButton?: string
|
||||
smallButtonPrimary?: string
|
||||
smallButtonSecondary?: string
|
||||
smallButtonDanger?: string
|
||||
|
||||
// Feedback
|
||||
error?: string
|
||||
success?: string
|
||||
loading?: string
|
||||
spinner?: string
|
||||
emptyState?: string
|
||||
|
||||
// Divider
|
||||
divider?: string
|
||||
dividerText?: string
|
||||
|
||||
// Footer
|
||||
footer?: string
|
||||
|
||||
// Code input (for TOTP/login codes)
|
||||
codeInput?: string
|
||||
|
||||
// OAuth providers
|
||||
providersContainer?: string
|
||||
providerButtons?: string
|
||||
providerButton?: string
|
||||
|
||||
// Passkey
|
||||
passkeyContainer?: string
|
||||
passkeyButton?: string
|
||||
|
||||
// Settings page
|
||||
settingsContainer?: string
|
||||
settingsPageTitle?: string
|
||||
settingsCard?: string
|
||||
settingsSection?: string
|
||||
settingsSectionTitle?: string
|
||||
settingsSubtitle?: string
|
||||
settingsList?: string
|
||||
settingsItem?: string
|
||||
settingsItemInfo?: string
|
||||
settingsItemLabel?: string
|
||||
settingsItemMeta?: string
|
||||
settingsItemActions?: string
|
||||
|
||||
// Badges
|
||||
badge?: string
|
||||
badgePrimary?: string
|
||||
badgeUnverified?: string
|
||||
badgeSuccess?: string
|
||||
badgeDanger?: string
|
||||
|
||||
// Inline form
|
||||
inlineForm?: string
|
||||
|
||||
// TOTP setup
|
||||
totpSetup?: string
|
||||
qrCode?: string
|
||||
|
||||
// Recovery codes
|
||||
recoveryCodes?: string
|
||||
|
||||
// Form controls
|
||||
checkbox?: string
|
||||
radioGroup?: string
|
||||
radioItem?: string
|
||||
|
||||
// Navigation
|
||||
navLinks?: string
|
||||
navLink?: string
|
||||
navLinkActive?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty class names - used as default when no styles provided.
|
||||
* Components will render without any styling classes.
|
||||
*/
|
||||
export const emptyClassNames: AuthClassNames = {}
|
||||
546
legacy/allauth/types.ts
Normal file
546
legacy/allauth/types.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* TypeScript types for django-allauth headless API
|
||||
* Generated from OpenAPI specification
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Primitive Types
|
||||
// =============================================================================
|
||||
|
||||
/** Epoch-based timestamp (use: new Date(value * 1000)) */
|
||||
export type Timestamp = number
|
||||
|
||||
/** Email address */
|
||||
export type Email = string
|
||||
|
||||
/** Phone number */
|
||||
export type Phone = string
|
||||
|
||||
/** Username */
|
||||
export type Username = string
|
||||
|
||||
/** Password */
|
||||
export type Password = string
|
||||
|
||||
/** One-time code */
|
||||
export type Code = string
|
||||
|
||||
/** Authenticator code (e.g., TOTP) */
|
||||
export type AuthenticatorCode = string
|
||||
|
||||
/** Provider ID (e.g., "google", "github") */
|
||||
export type ProviderID = string
|
||||
|
||||
/** Provider-specific account ID */
|
||||
export type ProviderAccountID = string
|
||||
|
||||
/** Authenticator ID */
|
||||
export type AuthenticatorID = number
|
||||
|
||||
/** OAuth client ID */
|
||||
export type ClientID = string
|
||||
|
||||
// =============================================================================
|
||||
// Enums
|
||||
// =============================================================================
|
||||
|
||||
export type AuthenticatorType = 'recovery_codes' | 'totp' | 'webauthn'
|
||||
|
||||
export type FlowID =
|
||||
| 'login'
|
||||
| 'login_by_code'
|
||||
| 'mfa_authenticate'
|
||||
| 'mfa_reauthenticate'
|
||||
| 'provider_redirect'
|
||||
| 'provider_signup'
|
||||
| 'provider_token'
|
||||
| 'reauthenticate'
|
||||
| 'signup'
|
||||
| 'verify_email'
|
||||
| 'verify_phone'
|
||||
|
||||
export type LoginMethod = 'email' | 'username'
|
||||
|
||||
export type OAuthProcess = 'login' | 'connect'
|
||||
|
||||
export type ProviderFlow = 'provider_redirect' | 'provider_token'
|
||||
|
||||
// =============================================================================
|
||||
// User & Session Types
|
||||
// =============================================================================
|
||||
|
||||
export interface User {
|
||||
id?: number
|
||||
display: string
|
||||
email?: string
|
||||
username?: string
|
||||
has_usable_password: boolean
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: number
|
||||
user_agent: string
|
||||
ip: string
|
||||
created_at: Timestamp
|
||||
last_seen_at?: Timestamp
|
||||
is_current: boolean
|
||||
}
|
||||
|
||||
export interface EmailAddress {
|
||||
email: Email
|
||||
primary: boolean
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
export interface PhoneNumber {
|
||||
phone: Phone
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Authentication Types
|
||||
// =============================================================================
|
||||
|
||||
export interface Flow {
|
||||
id: FlowID
|
||||
is_pending?: true
|
||||
provider?: Provider
|
||||
/** MFA types available (for mfa_authenticate/mfa_reauthenticate flows) */
|
||||
types?: AuthenticatorType[]
|
||||
}
|
||||
|
||||
export interface AuthenticationMethod {
|
||||
method: 'password' | 'password_reset' | 'code' | 'socialaccount' | 'mfa'
|
||||
at: Timestamp
|
||||
email?: Email
|
||||
phone?: Phone
|
||||
username?: Username
|
||||
provider?: ProviderID
|
||||
uid?: ProviderAccountID
|
||||
type?: AuthenticatorType
|
||||
reauthenticated?: boolean
|
||||
}
|
||||
|
||||
export interface Authenticated {
|
||||
user: User
|
||||
methods: AuthenticationMethod[]
|
||||
}
|
||||
|
||||
export interface ReauthenticationRequired {
|
||||
flows: Flow[]
|
||||
user: User
|
||||
methods: AuthenticationMethod[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Provider Types
|
||||
// =============================================================================
|
||||
|
||||
export interface Provider {
|
||||
id: ProviderID
|
||||
name: string
|
||||
client_id?: ClientID
|
||||
openid_configuration_url?: string
|
||||
flows: ProviderFlow[]
|
||||
}
|
||||
|
||||
export interface ProviderAccount {
|
||||
uid: ProviderAccountID
|
||||
display: string
|
||||
provider: Provider
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MFA / Authenticator Types
|
||||
// =============================================================================
|
||||
|
||||
export interface BaseAuthenticator {
|
||||
created_at: Timestamp
|
||||
last_used_at: Timestamp | null
|
||||
}
|
||||
|
||||
export interface TOTPAuthenticator extends BaseAuthenticator {
|
||||
type: 'totp'
|
||||
}
|
||||
|
||||
export interface RecoveryCodesAuthenticator extends BaseAuthenticator {
|
||||
type: 'recovery_codes'
|
||||
total_code_count: number
|
||||
unused_code_count: number
|
||||
}
|
||||
|
||||
export interface SensitiveRecoveryCodesAuthenticator extends RecoveryCodesAuthenticator {
|
||||
unused_codes: AuthenticatorCode[]
|
||||
}
|
||||
|
||||
export interface WebAuthnAuthenticator extends BaseAuthenticator {
|
||||
type: 'webauthn'
|
||||
id: AuthenticatorID
|
||||
name: string
|
||||
is_passwordless?: boolean
|
||||
}
|
||||
|
||||
export type Authenticator = TOTPAuthenticator | RecoveryCodesAuthenticator | WebAuthnAuthenticator
|
||||
|
||||
// =============================================================================
|
||||
// Configuration Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AccountConfiguration {
|
||||
login_methods?: LoginMethod[]
|
||||
is_open_for_signup: boolean
|
||||
email_verification_by_code_enabled: boolean
|
||||
login_by_code_enabled: boolean
|
||||
password_reset_by_code_enabled?: boolean
|
||||
}
|
||||
|
||||
export interface SocialAccountConfiguration {
|
||||
providers: Provider[]
|
||||
}
|
||||
|
||||
export interface MFAConfiguration {
|
||||
supported_types: AuthenticatorType[]
|
||||
passkey_login_enabled?: boolean
|
||||
}
|
||||
|
||||
export interface UserSessionsConfiguration {
|
||||
track_activity: boolean
|
||||
}
|
||||
|
||||
export interface AllauthConfiguration {
|
||||
account: AccountConfiguration
|
||||
socialaccount?: SocialAccountConfiguration
|
||||
mfa?: MFAConfiguration
|
||||
usersessions?: UserSessionsConfiguration
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// WebAuthn Types
|
||||
// =============================================================================
|
||||
|
||||
export interface WebAuthnPublicKeyCredentialCreationOptions {
|
||||
challenge: string
|
||||
rp: {
|
||||
name: string
|
||||
id: string
|
||||
}
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
displayName: string
|
||||
}
|
||||
pubKeyCredParams: Array<{
|
||||
type: 'public-key'
|
||||
alg: number
|
||||
}>
|
||||
timeout?: number
|
||||
excludeCredentials?: Array<{
|
||||
type: 'public-key'
|
||||
id: string
|
||||
}>
|
||||
authenticatorSelection?: {
|
||||
authenticatorAttachment?: 'platform' | 'cross-platform'
|
||||
requireResidentKey?: boolean
|
||||
residentKey?: 'discouraged' | 'preferred' | 'required'
|
||||
userVerification?: 'required' | 'preferred' | 'discouraged'
|
||||
}
|
||||
attestation?: 'none' | 'indirect' | 'direct' | 'enterprise'
|
||||
}
|
||||
|
||||
export interface WebAuthnPublicKeyCredentialRequestOptions {
|
||||
challenge: string
|
||||
rpId: string
|
||||
allowCredentials?: Array<{
|
||||
type: 'public-key'
|
||||
id: string
|
||||
}>
|
||||
userVerification?: 'required' | 'preferred' | 'discouraged'
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export interface WebAuthnCreationOptions {
|
||||
creation_options: {
|
||||
publicKey: WebAuthnPublicKeyCredentialCreationOptions
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebAuthnRequestOptions {
|
||||
request_options: {
|
||||
publicKey: WebAuthnPublicKeyCredentialRequestOptions
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TOTP Types
|
||||
// =============================================================================
|
||||
|
||||
export interface TOTPStatus {
|
||||
type: 'totp'
|
||||
created_at: Timestamp
|
||||
last_used_at: Timestamp | null
|
||||
/** Base32-encoded secret (only present when not yet activated) */
|
||||
secret?: string
|
||||
/** TOTP URI for QR code generation */
|
||||
totp_url?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Response Meta Types
|
||||
// =============================================================================
|
||||
|
||||
export interface BaseAuthenticationMeta {
|
||||
/** Session token (app clients only) */
|
||||
session_token?: string
|
||||
/** Access token (app clients only) */
|
||||
access_token?: string
|
||||
}
|
||||
|
||||
export interface AuthenticationMeta extends BaseAuthenticationMeta {
|
||||
is_authenticated: boolean
|
||||
}
|
||||
|
||||
export interface AuthenticatedMeta extends BaseAuthenticationMeta {
|
||||
is_authenticated: true
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Response Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AuthError {
|
||||
code: string
|
||||
message: string
|
||||
param?: string
|
||||
}
|
||||
|
||||
/** Base response structure - uses `any` for data/meta to maintain flexibility in generic use */
|
||||
export interface AllauthResponse<TData = any, TMeta = any> {
|
||||
status: number
|
||||
data?: TData
|
||||
meta?: TMeta
|
||||
errors?: AuthError[]
|
||||
}
|
||||
|
||||
/** 200 OK - Authenticated */
|
||||
export interface AuthenticatedResponse extends AllauthResponse<Authenticated, AuthenticationMeta> {
|
||||
status: 200
|
||||
data: Authenticated
|
||||
meta: AuthenticationMeta
|
||||
}
|
||||
|
||||
/** 200 OK - Configuration */
|
||||
export interface ConfigurationResponse extends AllauthResponse<AllauthConfiguration> {
|
||||
status: 200
|
||||
data: AllauthConfiguration
|
||||
}
|
||||
|
||||
/** 200 OK - Email list */
|
||||
export interface EmailListResponse extends AllauthResponse<EmailAddress[]> {
|
||||
status: 200
|
||||
data: EmailAddress[]
|
||||
}
|
||||
|
||||
/** 200 OK - Session list */
|
||||
export interface SessionListResponse extends AllauthResponse<Session[]> {
|
||||
status: 200
|
||||
data: Session[]
|
||||
}
|
||||
|
||||
/** 200 OK - Authenticator list */
|
||||
export interface AuthenticatorListResponse extends AllauthResponse<Authenticator[]> {
|
||||
status: 200
|
||||
data: Authenticator[]
|
||||
}
|
||||
|
||||
/** 200 OK - Provider account list */
|
||||
export interface ProviderAccountListResponse extends AllauthResponse<ProviderAccount[]> {
|
||||
status: 200
|
||||
data: ProviderAccount[]
|
||||
}
|
||||
|
||||
/** 200 OK - TOTP status */
|
||||
export interface TOTPStatusResponse extends AllauthResponse<TOTPStatus> {
|
||||
status: 200
|
||||
data: TOTPStatus
|
||||
}
|
||||
|
||||
/** 200 OK - Recovery codes */
|
||||
export interface RecoveryCodesResponse extends AllauthResponse<SensitiveRecoveryCodesAuthenticator> {
|
||||
status: 200
|
||||
data: SensitiveRecoveryCodesAuthenticator
|
||||
}
|
||||
|
||||
/** 200 OK - WebAuthn creation options */
|
||||
export interface WebAuthnCreationOptionsResponse extends AllauthResponse<WebAuthnCreationOptions> {
|
||||
status: 200
|
||||
data: WebAuthnCreationOptions
|
||||
}
|
||||
|
||||
/** 200 OK - WebAuthn request options */
|
||||
export interface WebAuthnRequestOptionsResponse extends AllauthResponse<WebAuthnRequestOptions> {
|
||||
status: 200
|
||||
data: WebAuthnRequestOptions
|
||||
}
|
||||
|
||||
/** 200 OK - Email verification info */
|
||||
export interface EmailVerificationInfoResponse extends AllauthResponse<{ email: Email; user: User }> {
|
||||
status: 200
|
||||
data: { email: Email; user: User }
|
||||
}
|
||||
|
||||
/** 401 - Authentication required (not authenticated) */
|
||||
export interface AuthenticationRequiredResponse extends AllauthResponse<{ flows: Flow[] }, AuthenticationMeta> {
|
||||
status: 401
|
||||
data: { flows: Flow[] }
|
||||
meta: AuthenticationMeta & { is_authenticated: false }
|
||||
}
|
||||
|
||||
/** 401 - Reauthentication required (authenticated but needs reauthentication) */
|
||||
export interface ReauthenticationRequiredResponse extends AllauthResponse<ReauthenticationRequired, AuthenticatedMeta> {
|
||||
status: 401
|
||||
data: ReauthenticationRequired
|
||||
meta: AuthenticatedMeta
|
||||
}
|
||||
|
||||
/** 400 - Bad request / validation error */
|
||||
export interface ErrorResponse extends AllauthResponse<never> {
|
||||
status: 400
|
||||
errors: AuthError[]
|
||||
}
|
||||
|
||||
/** 403 - Forbidden */
|
||||
export interface ForbiddenResponse extends AllauthResponse<never> {
|
||||
status: 403
|
||||
}
|
||||
|
||||
/** 409 - Conflict */
|
||||
export interface ConflictResponse extends AllauthResponse<never> {
|
||||
status: 409
|
||||
}
|
||||
|
||||
/** 410 - Session gone/expired */
|
||||
export interface SessionGoneResponse extends AllauthResponse<Record<string, never>, AuthenticationMeta> {
|
||||
status: 410
|
||||
data: Record<string, never>
|
||||
meta: AuthenticationMeta
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Request Types
|
||||
// =============================================================================
|
||||
|
||||
export interface LoginRequest {
|
||||
email?: Email
|
||||
username?: Username
|
||||
phone?: Phone
|
||||
password: Password
|
||||
}
|
||||
|
||||
export interface SignupRequest {
|
||||
email: Email
|
||||
password: Password
|
||||
[key: string]: unknown // Additional custom signup fields
|
||||
}
|
||||
|
||||
export interface ProviderSignupRequest {
|
||||
email: Email
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ReauthenticateRequest {
|
||||
password: Password
|
||||
}
|
||||
|
||||
export interface RequestLoginCodeRequest {
|
||||
email?: Email
|
||||
phone?: Phone
|
||||
}
|
||||
|
||||
export interface ConfirmLoginCodeRequest {
|
||||
code: Code
|
||||
}
|
||||
|
||||
export interface MFAAuthenticateRequest {
|
||||
code: AuthenticatorCode
|
||||
}
|
||||
|
||||
export interface MFATrustRequest {
|
||||
trust: boolean
|
||||
}
|
||||
|
||||
export interface RequestPasswordResetRequest {
|
||||
email: Email
|
||||
}
|
||||
|
||||
export interface ResetPasswordRequest {
|
||||
key: string
|
||||
password: Password
|
||||
}
|
||||
|
||||
export interface VerifyEmailRequest {
|
||||
key: string
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
current_password?: Password
|
||||
new_password: Password
|
||||
}
|
||||
|
||||
export interface AddEmailRequest {
|
||||
email: Email
|
||||
}
|
||||
|
||||
export interface ProviderRedirectRequest {
|
||||
provider: ProviderID
|
||||
process: OAuthProcess
|
||||
callback_url: string
|
||||
}
|
||||
|
||||
export interface ProviderTokenRequest {
|
||||
provider: ProviderID
|
||||
process: OAuthProcess
|
||||
token: {
|
||||
client_id: ClientID
|
||||
id_token?: string
|
||||
access_token?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebAuthnAddRequest {
|
||||
name: string
|
||||
credential: unknown // WebAuthn RegistrationResponseJSON
|
||||
}
|
||||
|
||||
export interface WebAuthnAuthenticateRequest {
|
||||
credential: unknown // WebAuthn AuthenticationResponseJSON
|
||||
}
|
||||
|
||||
export interface WebAuthnUpdateRequest {
|
||||
id: AuthenticatorID
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface WebAuthnDeleteRequest {
|
||||
authenticators: AuthenticatorID[]
|
||||
}
|
||||
|
||||
export interface EndSessionsRequest {
|
||||
sessions: number[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Union Types for Responses
|
||||
// =============================================================================
|
||||
|
||||
/** Possible responses from authentication endpoints */
|
||||
export type AuthResponse =
|
||||
| AuthenticatedResponse
|
||||
| AuthenticationRequiredResponse
|
||||
| ReauthenticationRequiredResponse
|
||||
| ErrorResponse
|
||||
|
||||
/** Possible responses from session status endpoint */
|
||||
export type SessionStatusResponse =
|
||||
| AuthenticatedResponse
|
||||
| AuthenticationRequiredResponse
|
||||
| SessionGoneResponse
|
||||
72
legacy/nextjs.tsx
Normal file
72
legacy/nextjs.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Next.js adapter for mizan/jwt.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* // In layout.tsx
|
||||
* import { NextAuthContext } from 'mizan/jwt/nextjs'
|
||||
*
|
||||
* export default function RootLayout({ children }) {
|
||||
* return (
|
||||
* <NextAuthContext user={user}>
|
||||
* {children}
|
||||
* </NextAuthContext>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { type ReactNode } from 'react'
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
|
||||
import type { RouterAdapter } from './RouterContext'
|
||||
import { RouterContext } from './RouterContext'
|
||||
import { AuthContext, type AuthContextProps } from './AuthContext'
|
||||
import type { BaseUser, AuthRoutes } from './types'
|
||||
|
||||
/**
|
||||
* Create a RouterAdapter from Next.js App Router hooks.
|
||||
*/
|
||||
export function useNextRouter(): RouterAdapter {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
return {
|
||||
push: (path: string) => router.push(path),
|
||||
replace: (path: string) => router.replace(path),
|
||||
pathname,
|
||||
searchParams: new URLSearchParams(searchParams.toString()),
|
||||
}
|
||||
}
|
||||
|
||||
export interface NextAuthContextProps<TUser extends BaseUser = BaseUser> {
|
||||
children: ReactNode
|
||||
/** Initial user from SSR hydration */
|
||||
user?: TUser | null
|
||||
/** API endpoint to fetch user data (default: '/api/auth/me/') */
|
||||
userEndpoint?: string
|
||||
/** Route configuration for guards */
|
||||
routes?: Partial<AuthRoutes>
|
||||
}
|
||||
|
||||
/**
|
||||
* Next.js-specific AuthContext that handles the router automatically.
|
||||
*/
|
||||
export function NextAuthContext<TUser extends BaseUser = BaseUser>({
|
||||
children,
|
||||
user,
|
||||
userEndpoint,
|
||||
routes,
|
||||
}: NextAuthContextProps<TUser>) {
|
||||
const router = useNextRouter()
|
||||
|
||||
return (
|
||||
<RouterContext router={router}>
|
||||
<AuthContext user={user} userEndpoint={userEndpoint} routes={routes}>
|
||||
{children}
|
||||
</AuthContext>
|
||||
</RouterContext>
|
||||
)
|
||||
}
|
||||
74
legacy/routing.tsx
Normal file
74
legacy/routing.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { useRouter } from './RouterContext'
|
||||
import { useAuth, useAuthRoutes } from './AuthContext'
|
||||
|
||||
/**
|
||||
* Route guard that only renders children if the user is authenticated.
|
||||
* Redirects to login page if not authenticated.
|
||||
*/
|
||||
export function UserRoute({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const routes = useAuthRoutes()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
const searchParams = router.searchParams.toString()
|
||||
const currentPath = searchParams
|
||||
? `${router.pathname}?${searchParams}`
|
||||
: router.pathname
|
||||
const next = encodeURIComponent(currentPath)
|
||||
router.replace(`${routes.login}?next=${next}`)
|
||||
}
|
||||
}, [isAuthenticated, router, routes.login])
|
||||
|
||||
if (!isAuthenticated) return null
|
||||
return children
|
||||
}
|
||||
|
||||
/**
|
||||
* Route guard that only renders children if the user is authenticated AND is staff.
|
||||
* Redirects to login if not authenticated, or to authenticated route if not staff.
|
||||
*/
|
||||
export function StaffRoute({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const routes = useAuthRoutes()
|
||||
const { isAuthenticated, isStaff } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
const searchParams = router.searchParams.toString()
|
||||
const currentPath = searchParams
|
||||
? `${router.pathname}?${searchParams}`
|
||||
: router.pathname
|
||||
const next = encodeURIComponent(currentPath)
|
||||
router.replace(`${routes.login}?next=${next}`)
|
||||
} else if (!isStaff) {
|
||||
router.replace(routes.authenticated)
|
||||
}
|
||||
}, [isAuthenticated, isStaff, router, routes])
|
||||
|
||||
if (!isAuthenticated || !isStaff) return null
|
||||
return children
|
||||
}
|
||||
|
||||
/**
|
||||
* Route guard that only renders children if the user is NOT authenticated.
|
||||
* Redirects to authenticated route if already logged in.
|
||||
*/
|
||||
export function AnonymousRoute({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const routes = useAuthRoutes()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.replace(routes.authenticated)
|
||||
}
|
||||
}, [isAuthenticated, routes.authenticated, router])
|
||||
|
||||
if (isAuthenticated) return null
|
||||
return children
|
||||
}
|
||||
Reference in New Issue
Block a user