Connect business and customer login pages to login-otp and reset-password, and document the auth flows in project context. Co-authored-by: Cursor <cursoragent@cursor.com>
158 lines
4.1 KiB
TypeScript
158 lines
4.1 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
type ReactNode,
|
|
} from 'react'
|
|
import { ApiError, getAccessToken, isAbortError } from '../lib/api'
|
|
import {
|
|
setActiveBusinessById,
|
|
} from '../lib/businessContext'
|
|
import { getBusinessDomain } from '../lib/config'
|
|
import {
|
|
fetchCurrentUser,
|
|
login as loginRequest,
|
|
loginWithOtp as loginWithOtpRequest,
|
|
logout as logoutRequest,
|
|
} from '../services/authService'
|
|
import { resolveTenantByDomain } from '../services/tenantService'
|
|
import type { AuthUser } from '../types/auth'
|
|
|
|
interface AuthContextValue {
|
|
user: AuthUser | null
|
|
isLoading: boolean
|
|
login: (cellNumber: string, password: string) => Promise<void>
|
|
loginWithOtp: (cellNumber: string, code: string) => Promise<void>
|
|
logout: () => void
|
|
setUser: (user: AuthUser) => void
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
|
|
|
const BUSINESS_ACCESS_MESSAGE =
|
|
'This account cannot access the business dashboard. Only business owners and staff can sign in here.'
|
|
|
|
function isSuperAdminUser(user: AuthUser) {
|
|
return user.isSuperAdmin === true || user.roles.includes('super_admin')
|
|
}
|
|
|
|
function canAccessBusinessDashboard(user: AuthUser) {
|
|
if (isSuperAdminUser(user)) {
|
|
return true
|
|
}
|
|
|
|
return user.dashboard === 'business' && user.businesses.length > 0
|
|
}
|
|
|
|
async function prepareBusinessContext(user: AuthUser) {
|
|
const domain = getBusinessDomain()
|
|
|
|
if (isSuperAdminUser(user)) {
|
|
const tenant = await resolveTenantByDomain(domain)
|
|
setActiveBusinessById(String(tenant.id), tenant.domain)
|
|
return
|
|
}
|
|
|
|
const membership =
|
|
user.businesses.find((business) => business.slug === tenantSlugFromDomain(domain)) ??
|
|
user.businesses[0]
|
|
|
|
if (!membership) {
|
|
throw new ApiError('No business membership found for this account.', 403)
|
|
}
|
|
|
|
setActiveBusinessById(String(membership.id), domain)
|
|
}
|
|
|
|
function tenantSlugFromDomain(domain: string) {
|
|
return domain.split('.')[0]?.toLowerCase() ?? domain
|
|
}
|
|
|
|
async function assertBusinessAccess(user: AuthUser) {
|
|
if (!canAccessBusinessDashboard(user)) {
|
|
logoutRequest()
|
|
throw new ApiError(BUSINESS_ACCESS_MESSAGE, 403)
|
|
}
|
|
|
|
await prepareBusinessContext(user)
|
|
}
|
|
|
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
const [user, setUser] = useState<AuthUser | null>(null)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
|
|
const logout = useCallback(() => {
|
|
logoutRequest()
|
|
setUser(null)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
|
|
async function init() {
|
|
if (!getAccessToken()) {
|
|
if (!controller.signal.aborted) setIsLoading(false)
|
|
return
|
|
}
|
|
|
|
try {
|
|
const { user: currentUser } = await fetchCurrentUser(controller.signal)
|
|
if (controller.signal.aborted) return
|
|
|
|
if (!canAccessBusinessDashboard(currentUser)) {
|
|
logout()
|
|
return
|
|
}
|
|
|
|
await prepareBusinessContext(currentUser)
|
|
if (controller.signal.aborted) return
|
|
|
|
setUser(currentUser)
|
|
} catch (err) {
|
|
if (isAbortError(err) || controller.signal.aborted) return
|
|
logout()
|
|
} finally {
|
|
if (!controller.signal.aborted) setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
void init()
|
|
|
|
return () => {
|
|
controller.abort()
|
|
}
|
|
}, [logout])
|
|
|
|
const login = useCallback(async (cellNumber: string, password: string) => {
|
|
const data = await loginRequest(cellNumber, password)
|
|
await assertBusinessAccess(data.user)
|
|
setUser(data.user)
|
|
}, [])
|
|
|
|
const loginWithOtp = useCallback(async (cellNumber: string, code: string) => {
|
|
const data = await loginWithOtpRequest(cellNumber, code)
|
|
await assertBusinessAccess(data.user)
|
|
setUser(data.user)
|
|
}, [])
|
|
|
|
const value = useMemo(
|
|
() => ({ user, isLoading, login, loginWithOtp, logout, setUser }),
|
|
[user, isLoading, login, loginWithOtp, logout],
|
|
)
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
|
}
|
|
|
|
export function useAuth() {
|
|
const context = useContext(AuthContext)
|
|
if (!context) {
|
|
throw new Error('useAuth must be used within AuthProvider')
|
|
}
|
|
return context
|
|
}
|
|
|
|
export { BUSINESS_ACCESS_MESSAGE }
|