accounts/dashboard/lib/userStore.tsx

298 lines
7.4 KiB
TypeScript

'use client'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
} from 'react'
import useSWR from 'swr'
import { create } from 'zustand'
export type UserRole = 'guest' | 'user' | 'operator' | 'admin'
export type TenantMembership = {
id: string
name?: string
role?: UserRole
}
type User = {
id: string
uuid: string
email: string
name?: string
username: string
mfaEnabled: boolean
mfaPending: boolean
role: UserRole
groups: string[]
permissions: string[]
isGuest: boolean
isUser: boolean
isOperator: boolean
isAdmin: boolean
tenantId?: string
tenants?: TenantMembership[]
mfa?: {
totpEnabled?: boolean
totpPending?: boolean
totpSecretIssuedAt?: string
totpConfirmedAt?: string
totpLockedUntil?: string
}
}
export type SessionUser = User | null
type UserContextValue = {
user: User | null
isLoading: boolean
login: () => Promise<void>
logout: () => Promise<void>
refresh: () => Promise<void>
}
type UserStore = {
user: User | null
setUser: (user: User | null) => void
}
const sessionStore = create<UserStore>((set) => ({
user: null,
setUser: (user) => set({ user }),
}))
const UserContext = createContext<UserContextValue | undefined>(undefined)
const SESSION_CACHE_KEY = 'account_session'
const KNOWN_ROLE_MAP: Record<string, UserRole> = {
admin: 'admin',
administrator: 'admin',
operator: 'operator',
ops: 'operator',
user: 'user',
member: 'user',
}
function normalizeRole(input?: string | null): UserRole {
if (!input || typeof input !== 'string') {
return 'guest'
}
const normalized = input.trim().toLowerCase()
if (!normalized) {
return 'guest'
}
return KNOWN_ROLE_MAP[normalized] ?? 'guest'
}
async function fetchSessionUser(): Promise<User | null> {
try {
const response = await fetch('/api/auth/session', {
credentials: 'include',
cache: 'no-store',
headers: {
Accept: 'application/json',
},
})
if (!response.ok) {
return null
}
const payload = (await response.json()) as {
user?: {
id?: string
uuid?: string
email: string
name?: string
username?: string
mfaEnabled?: boolean
mfaPending?: boolean
role?: string
groups?: string[]
permissions?: string[]
tenantId?: string
tenants?: TenantMembership[]
mfa?: {
totpEnabled?: boolean
totpPending?: boolean
totpSecretIssuedAt?: string
totpConfirmedAt?: string
totpLockedUntil?: string
}
} | null
}
const sessionUser = payload?.user
if (!sessionUser) {
return null
}
const { id, uuid, email, name, username, mfaEnabled, mfa, mfaPending, role, groups, permissions } = sessionUser
const identifier =
typeof uuid === 'string' && uuid.trim().length > 0
? uuid.trim()
: typeof id === 'string'
? id.trim()
: ''
if (!identifier) {
return null
}
const normalizedName = typeof name === 'string' && name.trim().length > 0 ? name.trim() : undefined
const normalizedUsername =
typeof username === 'string' && username.trim().length > 0 ? username.trim() : normalizedName
const normalizedMfa = mfa
? {
...mfa,
totpEnabled: Boolean(mfa.totpEnabled ?? mfaEnabled),
totpPending: Boolean(mfa.totpPending ?? mfaPending) && !Boolean(mfa.totpEnabled ?? mfaEnabled),
}
: {
totpEnabled: Boolean(mfaEnabled),
totpPending: Boolean(mfaPending) && !Boolean(mfaEnabled),
}
const normalizedRole = normalizeRole(role)
const normalizedGroups = Array.isArray(groups)
? groups
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
: []
const normalizedPermissions = Array.isArray(permissions)
? permissions
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
: []
const normalizedTenantId =
typeof sessionUser.tenantId === 'string' && sessionUser.tenantId.trim().length > 0
? sessionUser.tenantId.trim()
: undefined
const normalizedTenants = Array.isArray(sessionUser.tenants)
? sessionUser.tenants
.map((tenant) => {
if (!tenant || typeof tenant !== 'object') {
return null
}
const identifier =
typeof tenant.id === 'string' && tenant.id.trim().length > 0
? tenant.id.trim()
: undefined
if (!identifier) {
return null
}
const normalizedTenant: TenantMembership = {
id: identifier,
}
if (typeof tenant.name === 'string' && tenant.name.trim().length > 0) {
normalizedTenant.name = tenant.name.trim()
}
if (typeof tenant.role === 'string' && tenant.role.trim().length > 0) {
normalizedTenant.role = normalizeRole(tenant.role)
}
return normalizedTenant
})
.filter((tenant): tenant is TenantMembership => Boolean(tenant))
: undefined
return {
id: identifier,
uuid: identifier,
email,
name: normalizedName,
username: normalizedUsername ?? email,
mfaEnabled: Boolean(mfaEnabled ?? mfa?.totpEnabled),
mfaPending: Boolean(mfaPending ?? mfa?.totpPending) && !Boolean(mfaEnabled ?? mfa?.totpEnabled),
mfa: normalizedMfa,
role: normalizedRole,
groups: normalizedGroups,
permissions: normalizedPermissions,
isGuest: normalizedRole === 'guest',
isUser: normalizedRole === 'user',
isOperator: normalizedRole === 'operator',
isAdmin: normalizedRole === 'admin',
tenantId: normalizedTenantId,
tenants: normalizedTenants,
}
} catch (error) {
console.warn('Failed to resolve user session', error)
return null
}
}
export function UserProvider({ children }: { children: React.ReactNode }) {
const user = sessionStore((state) => state.user)
const setUser = sessionStore((state) => state.setUser)
const {
data,
isLoading,
mutate,
} = useSWR<User | null>(SESSION_CACHE_KEY, fetchSessionUser, {
refreshInterval: 60_000,
revalidateOnFocus: true,
shouldRetryOnError: true,
})
useEffect(() => {
if (data === undefined) {
return
}
setUser(data)
}, [data, setUser])
const refresh = useCallback(async () => {
const nextUser = await mutate()
setUser(nextUser ?? null)
}, [mutate, setUser])
const login = useCallback(async () => {
await refresh()
}, [refresh])
const logout = useCallback(async () => {
try {
await fetch('/api/auth/session', {
method: 'DELETE',
credentials: 'include',
})
} catch (error) {
console.warn('Failed to clear user session', error)
}
await refresh()
}, [refresh])
const value = useMemo(
() => ({
user,
isLoading,
login,
logout,
refresh,
}),
[user, isLoading, login, logout, refresh],
)
return <UserContext.Provider value={value}>{children}</UserContext.Provider>
}
export function useUser() {
const context = useContext(UserContext)
if (!context) {
throw new Error('useUser must be used within a UserProvider')
}
return context
}