Admins and supervisors without 2FA are sent a code at their next login, and verifying it turns 2FA on. They can change the channel but not turn it off. Older sessions without 2FA are rejected with TWO_FACTOR_REQUIRED, and promoting a user ends their sessions so they enrol at next login. The requirement is skipped when no email or WhatsApp channel can deliver a code, and TWO_FACTOR_REQUIRED_ROLES=false switches it off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
60 lines
2.1 KiB
TypeScript
60 lines
2.1 KiB
TypeScript
export type AuthUser = {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
role?: "admin" | "supervisor" | "staff" | "user";
|
|
phoneNumber?: string | null;
|
|
notificationPreference?: "email" | "whatsapp" | "both";
|
|
twoFactorEnabled?: boolean;
|
|
twoFactorMethod?: TwoFactorMethod | null;
|
|
// True for roles that must use 2FA (admin, supervisor) — it can't be turned off.
|
|
twoFactorRequired?: boolean;
|
|
};
|
|
|
|
export type AuthResponse = AuthUser & {
|
|
token: string;
|
|
// Only present when the user ticked "Trust this device" on the 2FA step.
|
|
deviceToken?: string;
|
|
};
|
|
|
|
export type TwoFactorMethod = "email" | "whatsapp";
|
|
|
|
// What the login endpoint returns instead of a token when the account has 2FA on:
|
|
// a code was sent to `destination` (masked) and must be exchanged for the token.
|
|
export type TwoFactorChallenge = {
|
|
twoFactorRequired?: true;
|
|
challengeId: string;
|
|
method: TwoFactorMethod;
|
|
destination: string;
|
|
// The role requires 2FA and it wasn't on yet: verifying this code turns it on.
|
|
enrolling?: boolean;
|
|
};
|
|
|
|
export function isTwoFactorChallenge(res: unknown): res is TwoFactorChallenge & { twoFactorRequired: true } {
|
|
return !!res && typeof res === "object" && (res as TwoFactorChallenge).twoFactorRequired === true;
|
|
}
|
|
|
|
const TOKEN_KEY = "hope_events_token";
|
|
// Remembers this browser after a 2FA code so the next login can skip it. Kept on
|
|
// logout on purpose — it identifies the device, not the session.
|
|
const DEVICE_KEY = "hope_events_device";
|
|
|
|
export function saveDeviceToken(token: string) {
|
|
if (typeof window !== "undefined") localStorage.setItem(DEVICE_KEY, token);
|
|
}
|
|
export function getDeviceToken(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
return localStorage.getItem(DEVICE_KEY);
|
|
}
|
|
|
|
export function saveToken(token: string) {
|
|
if (typeof window !== "undefined") localStorage.setItem(TOKEN_KEY, token);
|
|
}
|
|
export function getToken(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
return localStorage.getItem(TOKEN_KEY);
|
|
}
|
|
export function clearToken() {
|
|
if (typeof window !== "undefined") localStorage.removeItem(TOKEN_KEY);
|
|
}
|