feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -1,63 +1,313 @@
|
|||
// src/lib/auth-context.tsx — Auth provider using Supabase
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { User, Session } from '@supabase/supabase-js'
|
||||
import { Linking } from 'react-native'
|
||||
import type { AuthChangeEvent, User, Session } from '@supabase/supabase-js'
|
||||
import { supabase, isSupabaseConfigured } from './supabase'
|
||||
import { completeAuthRedirect, isAuthRedirectUrl } from './auth-redirect'
|
||||
import { clearAllSecureAuthStorage } from './secure-auth-storage'
|
||||
import { purgeAllAccountLocalData } from './account-local-data'
|
||||
|
||||
export type AuthPrivacyCleanupState = 'ready' | 'purging' | 'failed'
|
||||
|
||||
export class AuthPrivacyBoundaryError extends Error {
|
||||
readonly code = 'auth_privacy_boundary_failed'
|
||||
|
||||
constructor() {
|
||||
super('auth_privacy_boundary_failed')
|
||||
this.name = 'AuthPrivacyBoundaryError'
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
session: Session | null
|
||||
loading: boolean
|
||||
devBypass: () => void
|
||||
recoveryMode: boolean
|
||||
authError: 'callback_failed' | 'bootstrap_failed' | null
|
||||
privacyCleanupState: AuthPrivacyCleanupState
|
||||
finishPasswordRecovery: () => void
|
||||
purgeLocalSession: () => Promise<void>
|
||||
retryPrivacyCleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
type AuthTransitionEvent = AuthChangeEvent | 'BOOTSTRAP' | 'EXPLICIT'
|
||||
|
||||
interface PendingAuthTransition {
|
||||
session: Session | null
|
||||
event: AuthTransitionEvent
|
||||
forcePurge: boolean
|
||||
clearSecureAuth: boolean
|
||||
explicit: boolean
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
user: null,
|
||||
session: null,
|
||||
loading: false,
|
||||
devBypass: () => {},
|
||||
loading: true,
|
||||
recoveryMode: false,
|
||||
authError: null,
|
||||
privacyCleanupState: 'ready',
|
||||
finishPasswordRecovery: () => undefined,
|
||||
purgeLocalSession: async () => undefined,
|
||||
retryPrivacyCleanup: async () => undefined,
|
||||
})
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }): React.ReactElement {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [committedUser, setCommittedUser] = useState<User | null>(null)
|
||||
const [committedSession, setCommittedSession] = useState<Session | null>(null)
|
||||
const [bootstrapLoading, setBootstrapLoading] = useState(true)
|
||||
const [recoveryMode, setRecoveryMode] = useState(false)
|
||||
const [authError, setAuthError] = useState<AuthContextValue['authError']>(null)
|
||||
const [privacyCleanupState, setPrivacyCleanupState] = useState<AuthPrivacyCleanupState>('ready')
|
||||
const mountedRef = useRef(true)
|
||||
const committedSessionRef = useRef<Session | null>(null)
|
||||
const privacyCleanupStateRef = useRef<AuthPrivacyCleanupState>('ready')
|
||||
const transitionGenerationRef = useRef(0)
|
||||
const pendingTransitionRef = useRef<PendingAuthTransition | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSupabaseConfigured()) {
|
||||
setLoading(false)
|
||||
return
|
||||
const updatePrivacyCleanupState = useCallback((state: AuthPrivacyCleanupState): void => {
|
||||
privacyCleanupStateRef.current = state
|
||||
if (mountedRef.current) setPrivacyCleanupState(state)
|
||||
}, [])
|
||||
|
||||
const commitSession = useCallback((
|
||||
nextSession: Session | null,
|
||||
event: AuthTransitionEvent,
|
||||
): void => {
|
||||
const previousUserId = committedSessionRef.current?.user.id ?? null
|
||||
const nextUserId = nextSession?.user.id ?? null
|
||||
committedSessionRef.current = nextSession
|
||||
if (!mountedRef.current) return
|
||||
|
||||
setCommittedSession(nextSession)
|
||||
setCommittedUser(nextSession?.user ?? null)
|
||||
if (nextSession === null) {
|
||||
setRecoveryMode(false)
|
||||
} else if (event === 'PASSWORD_RECOVERY') {
|
||||
setRecoveryMode(true)
|
||||
} else if (previousUserId !== nextUserId) {
|
||||
setRecoveryMode(false)
|
||||
}
|
||||
if (event === 'SIGNED_IN' || event === 'PASSWORD_RECOVERY' || event === 'EXPLICIT') {
|
||||
setAuthError(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const runAuthTransition = useCallback(async (
|
||||
transition: PendingAuthTransition,
|
||||
): Promise<boolean> => {
|
||||
const generation = ++transitionGenerationRef.current
|
||||
const previousUserId = committedSessionRef.current?.user.id ?? null
|
||||
const nextUserId = transition.session?.user.id ?? null
|
||||
const needsPurge = transition.forcePurge
|
||||
|| privacyCleanupStateRef.current !== 'ready'
|
||||
|| (previousUserId !== null && previousUserId !== nextUserId)
|
||||
|
||||
if (!needsPurge) {
|
||||
pendingTransitionRef.current = null
|
||||
commitSession(transition.session, transition.event)
|
||||
updatePrivacyCleanupState('ready')
|
||||
return true
|
||||
}
|
||||
|
||||
pendingTransitionRef.current = transition
|
||||
updatePrivacyCleanupState('purging')
|
||||
|
||||
try {
|
||||
supabase.auth.getSession()
|
||||
.then(({ data: { session: s } }) => {
|
||||
setSession(s)
|
||||
setUser(s?.user ?? null)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
await purgeAllAccountLocalData()
|
||||
if (generation !== transitionGenerationRef.current) return false
|
||||
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, s) => {
|
||||
setSession(s)
|
||||
setUser(s?.user ?? null)
|
||||
})
|
||||
if (transition.clearSecureAuth) {
|
||||
if (!transition.explicit) {
|
||||
// A SIGNED_OUT notification can race with a direct A -> B account
|
||||
// replacement. Preserve a newer different-account session that the
|
||||
// auth client has already committed to secure storage.
|
||||
try {
|
||||
const { data } = await supabase.auth.getSession()
|
||||
if (generation !== transitionGenerationRef.current) return false
|
||||
const latestSession = data.session
|
||||
const latestUserId = latestSession?.user.id ?? null
|
||||
if (latestSession !== null && latestUserId !== previousUserId) {
|
||||
pendingTransitionRef.current = null
|
||||
commitSession(latestSession, 'SIGNED_IN')
|
||||
updatePrivacyCleanupState('ready')
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// If the auth client cannot prove that a newer session exists,
|
||||
// fail closed by removing the signed-out account's secure data.
|
||||
}
|
||||
}
|
||||
|
||||
return () => subscription?.unsubscribe?.()
|
||||
try {
|
||||
await supabase.auth.stopAutoRefresh()
|
||||
} catch {
|
||||
// Secure deletion is still required when refresh shutdown fails.
|
||||
}
|
||||
try {
|
||||
await clearAllSecureAuthStorage()
|
||||
} finally {
|
||||
void supabase.auth.startAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
if (generation !== transitionGenerationRef.current) return false
|
||||
pendingTransitionRef.current = null
|
||||
commitSession(transition.session, transition.event)
|
||||
updatePrivacyCleanupState('ready')
|
||||
return true
|
||||
} catch {
|
||||
setLoading(false)
|
||||
if (generation !== transitionGenerationRef.current) return false
|
||||
pendingTransitionRef.current = transition
|
||||
updatePrivacyCleanupState('failed')
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
}, [commitSession, updatePrivacyCleanupState])
|
||||
|
||||
const devBypass = useCallback(() => {
|
||||
setUser({ id: 'dev-user', email: 'dev@d3ro.local' } as User)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
let authSubscription: { unsubscribe: () => void } | null = null
|
||||
|
||||
const handleUrl = async (url: string): Promise<void> => {
|
||||
if (!isAuthRedirectUrl(url)) return
|
||||
try {
|
||||
const redirectType = await completeAuthRedirect(url)
|
||||
if (!mountedRef.current) return
|
||||
if (redirectType === 'recovery') setRecoveryMode(true)
|
||||
if (redirectType === 'recovery' || redirectType === 'signed-in' || redirectType === 'cancelled') {
|
||||
setAuthError(null)
|
||||
}
|
||||
} catch {
|
||||
if (mountedRef.current) setAuthError('callback_failed')
|
||||
}
|
||||
}
|
||||
|
||||
const linkSubscription = Linking.addEventListener('url', ({ url }) => {
|
||||
void handleUrl(url)
|
||||
})
|
||||
|
||||
const subscribeToAuthChanges = (): void => {
|
||||
if (!mountedRef.current || authSubscription !== null) return
|
||||
authSubscription = supabase.auth.onAuthStateChange((event, nextSession) => {
|
||||
if (!mountedRef.current) return
|
||||
const previousUserId = committedSessionRef.current?.user.id ?? null
|
||||
const nextUserId = nextSession?.user.id ?? null
|
||||
void runAuthTransition({
|
||||
session: nextSession,
|
||||
event,
|
||||
forcePurge: false,
|
||||
clearSecureAuth: nextSession === null && previousUserId !== null,
|
||||
explicit: false,
|
||||
})
|
||||
if (event === 'SIGNED_IN' || event === 'PASSWORD_RECOVERY') setAuthError(null)
|
||||
if (event === 'PASSWORD_RECOVERY' && previousUserId === nextUserId) {
|
||||
setRecoveryMode(true)
|
||||
}
|
||||
}).data.subscription
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
if (!isSupabaseConfigured()) {
|
||||
await runAuthTransition({
|
||||
session: null,
|
||||
event: 'BOOTSTRAP',
|
||||
forcePurge: true,
|
||||
clearSecureAuth: false,
|
||||
explicit: false,
|
||||
})
|
||||
if (mountedRef.current) setBootstrapLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const initialUrl = await Linking.getInitialURL()
|
||||
if (initialUrl !== null) await handleUrl(initialUrl)
|
||||
const { data: { session: restoredSession }, error } = await supabase.auth.getSession()
|
||||
if (error !== null) throw error
|
||||
|
||||
// A restored account owns its existing local caches. A cold boot with
|
||||
// no session has no trustworthy owner and must purge before Login.
|
||||
await runAuthTransition({
|
||||
session: restoredSession,
|
||||
event: 'BOOTSTRAP',
|
||||
forcePurge: restoredSession === null,
|
||||
clearSecureAuth: false,
|
||||
explicit: false,
|
||||
})
|
||||
} catch {
|
||||
if (mountedRef.current) setAuthError('bootstrap_failed')
|
||||
await runAuthTransition({
|
||||
session: null,
|
||||
event: 'BOOTSTRAP',
|
||||
forcePurge: true,
|
||||
clearSecureAuth: false,
|
||||
explicit: false,
|
||||
})
|
||||
} finally {
|
||||
subscribeToAuthChanges()
|
||||
if (mountedRef.current) setBootstrapLoading(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
transitionGenerationRef.current += 1
|
||||
linkSubscription.remove()
|
||||
authSubscription?.unsubscribe()
|
||||
}
|
||||
}, [runAuthTransition])
|
||||
|
||||
const finishPasswordRecovery = (): void => {
|
||||
setRecoveryMode(false)
|
||||
}
|
||||
|
||||
const purgeLocalSession = useCallback(async (): Promise<void> => {
|
||||
const succeeded = await runAuthTransition({
|
||||
session: null,
|
||||
event: 'EXPLICIT',
|
||||
forcePurge: true,
|
||||
clearSecureAuth: true,
|
||||
explicit: true,
|
||||
})
|
||||
if (!succeeded && privacyCleanupStateRef.current === 'failed') {
|
||||
throw new AuthPrivacyBoundaryError()
|
||||
}
|
||||
}, [runAuthTransition])
|
||||
|
||||
const retryPrivacyCleanup = useCallback(async (): Promise<void> => {
|
||||
const pending = pendingTransitionRef.current
|
||||
if (pending === null) {
|
||||
updatePrivacyCleanupState('ready')
|
||||
return
|
||||
}
|
||||
await runAuthTransition({ ...pending, forcePurge: true })
|
||||
}, [runAuthTransition, updatePrivacyCleanupState])
|
||||
|
||||
const authBoundaryReady = privacyCleanupState === 'ready'
|
||||
const loading = bootstrapLoading || !authBoundaryReady
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, session, loading, devBypass }}>
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user: authBoundaryReady ? committedUser : null,
|
||||
session: authBoundaryReady ? committedSession : null,
|
||||
loading,
|
||||
recoveryMode: authBoundaryReady ? recoveryMode : false,
|
||||
authError,
|
||||
privacyCleanupState,
|
||||
finishPasswordRecovery,
|
||||
purgeLocalSession,
|
||||
retryPrivacyCleanup,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
|
@ -66,4 +316,3 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React
|
|||
export function useAuth(): AuthContextValue {
|
||||
return useContext(AuthContext)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue