From 07e5d420059e5126ed2b190433ce3f03e9305bfc Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sun, 12 Apr 2026 20:29:39 +0800 Subject: [PATCH] refactor: centralize validation, error handling, and formatting logic Extract shared auth logic and validation rules to shared/auth.ts Introduce utility functions for HTTP errors and user input parsing Standardize error messages and date formatting across the app --- app/composables/useAuth.ts | 6 +- app/layouts/default.vue | 4 +- app/middleware/auth.ts | 4 +- app/middleware/guest.ts | 8 +- app/middleware/super-admin.ts | 4 +- app/pages/index/index.vue | 3 - app/pages/login/index.vue | 16 ++-- app/pages/management/users/index.vue | 42 +++++------ app/pages/security/index.vue | 26 +++---- app/utils/errors.ts | 3 + app/utils/formatters.ts | 10 +++ server/api/admin/users.post.ts | 55 ++++---------- server/api/admin/users/[id].patch.ts | 58 ++------------ .../admin/users/[id]/reset-password.post.ts | 23 ++---- server/api/auth/change-password.post.ts | 29 +++---- server/api/auth/login.post.ts | 23 ++---- server/api/auth/passkey/login/verify.post.ts | 38 +++------- .../api/auth/passkey/register/verify.post.ts | 35 +++------ server/utils/auth.ts | 6 +- server/utils/http.ts | 46 ++++++++++++ server/utils/user-repository.ts | 10 +-- server/utils/users.ts | 75 +++++++++++++++++++ shared/auth.ts | 37 +++++++++ 23 files changed, 294 insertions(+), 267 deletions(-) delete mode 100644 app/pages/index/index.vue create mode 100644 app/utils/errors.ts create mode 100644 app/utils/formatters.ts create mode 100644 server/utils/http.ts create mode 100644 server/utils/users.ts diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts index ef96f08..6917c41 100644 --- a/app/composables/useAuth.ts +++ b/app/composables/useAuth.ts @@ -1,4 +1,4 @@ -import type { AuthUser } from '~~/shared/auth' +import { needsUserOnboarding, type AuthUser } from '~~/shared/auth' export function useAuth() { const user = useState('auth:user', () => null) @@ -8,9 +8,7 @@ export function useAuth() { const isAuthenticated = computed(() => Boolean(user.value)) const isSuperAdmin = computed(() => user.value?.role === 'super_admin') - const needsOnboarding = computed(() => { - return Boolean(user.value && (user.value.mustChangePassword || user.value.needsPasskeySetup)) - }) + const needsOnboarding = computed(() => needsUserOnboarding(user.value)) async function fetchSession(force = false) { if (loaded.value && !force) { diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 533431f..01c7703 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -305,6 +305,8 @@ diff --git a/app/pages/login/index.vue b/app/pages/login/index.vue index 8a9336d..328c6b9 100644 --- a/app/pages/login/index.vue +++ b/app/pages/login/index.vue @@ -69,6 +69,10 @@ diff --git a/app/pages/security/index.vue b/app/pages/security/index.vue index c8cf9ba..a2c80df 100644 --- a/app/pages/security/index.vue +++ b/app/pages/security/index.vue @@ -87,7 +87,7 @@ {{ passkey.label }}
- Added {{ formatDate(passkey.createdAt) }} + Added {{ formatDateTime(passkey.createdAt) }}
@@ -105,7 +105,10 @@ diff --git a/app/utils/errors.ts b/app/utils/errors.ts new file mode 100644 index 0000000..81ae38f --- /dev/null +++ b/app/utils/errors.ts @@ -0,0 +1,3 @@ +export function getErrorMessage(error: any, fallback: string) { + return error?.data?.statusMessage || error?.message || fallback +} diff --git a/app/utils/formatters.ts b/app/utils/formatters.ts new file mode 100644 index 0000000..5d29aca --- /dev/null +++ b/app/utils/formatters.ts @@ -0,0 +1,10 @@ +export function formatDateTime(value: string | null, fallback = 'Not available') { + if (!value) { + return fallback + } + + return new Intl.DateTimeFormat('en-MY', { + dateStyle: 'medium', + timeStyle: 'short' + }).format(new Date(value)) +} diff --git a/server/api/admin/users.post.ts b/server/api/admin/users.post.ts index c36cee6..d02729e 100644 --- a/server/api/admin/users.post.ts +++ b/server/api/admin/users.post.ts @@ -1,14 +1,10 @@ -import { - DEFAULT_USER_PASSWORD, - USERNAME_PATTERN, - isValidPhoneNumber, - normalizePhoneNumber, - type UserRole -} from '~~/shared/auth' +import { DEFAULT_USER_PASSWORD, type UserRole } from '~~/shared/auth' -import { normalizeUsername, requireRole } from '../../utils/auth' +import { requireRole } from '../../utils/auth' +import { mapDatabaseError } from '../../utils/http' import { hashPassword } from '../../utils/password' import { createUser } from '../../utils/user-repository' +import { parseCreateUserInput } from '../../utils/users' export default defineEventHandler(async (event) => { const auth = await requireRole(event, 'super_admin') @@ -19,31 +15,12 @@ export default defineEventHandler(async (event) => { role?: UserRole }>(event) - const username = normalizeUsername(body.username || '') - const fullName = body.fullName?.trim() || '' - const phoneNumber = normalizePhoneNumber(body.phoneNumber || '') - const role = body.role === 'super_admin' ? 'super_admin' : 'staff' - - if (!USERNAME_PATTERN.test(username)) { - throw createError({ - statusCode: 400, - statusMessage: 'Username must be 3 to 32 characters using lowercase letters, numbers, dot, dash, or underscore' - }) - } - - if (fullName.length < 2) { - throw createError({ - statusCode: 400, - statusMessage: 'Full name must be at least 2 characters' - }) - } - - if (!isValidPhoneNumber(phoneNumber)) { - throw createError({ - statusCode: 400, - statusMessage: 'Phone number must contain 8 to 15 digits' - }) - } + const { + username, + fullName, + phoneNumber, + role + } = parseCreateUserInput(body) const passwordHash = await hashPassword(DEFAULT_USER_PASSWORD) @@ -61,14 +38,12 @@ export default defineEventHandler(async (event) => { user, defaultPassword: DEFAULT_USER_PASSWORD } - } catch (error: any) { - if (error?.code === '23505') { - throw createError({ + } catch (error) { + mapDatabaseError(error, { + '23505': { statusCode: 409, statusMessage: 'Username already exists' - }) - } - - throw error + } + }) } }) diff --git a/server/api/admin/users/[id].patch.ts b/server/api/admin/users/[id].patch.ts index 976fa8d..4564f19 100644 --- a/server/api/admin/users/[id].patch.ts +++ b/server/api/admin/users/[id].patch.ts @@ -1,22 +1,13 @@ -import { - isValidPhoneNumber, - normalizePhoneNumber, - type UserRole -} from '~~/shared/auth' +import type { UserRole } from '~~/shared/auth' import { requireRole } from '../../../utils/auth' -import { getUserById, updateUserProfile } from '../../../utils/user-repository' +import { updateUserProfile } from '../../../utils/user-repository' +import { httpError } from '../../../utils/http' +import { parseUserProfileInput, requireExistingUser, requireUserIdParam } from '../../../utils/users' export default defineEventHandler(async (event) => { const auth = await requireRole(event, 'super_admin') - const userId = getRouterParam(event, 'id') - - if (!userId) { - throw createError({ - statusCode: 400, - statusMessage: 'User id is required' - }) - } + const userId = requireUserIdParam(event) const body = await readBody<{ fullName?: string @@ -24,45 +15,12 @@ export default defineEventHandler(async (event) => { role?: UserRole }>(event) - const fullName = body.fullName?.trim() || '' - const phoneNumber = normalizePhoneNumber(body.phoneNumber || '') - const role = body.role + const { fullName, phoneNumber, role } = parseUserProfileInput(body) - if (fullName.length < 2) { - throw createError({ - statusCode: 400, - statusMessage: 'Display name must be at least 2 characters' - }) - } - - if (!isValidPhoneNumber(phoneNumber)) { - throw createError({ - statusCode: 400, - statusMessage: 'Phone number must contain 8 to 15 digits' - }) - } - - if (role !== 'super_admin' && role !== 'staff') { - throw createError({ - statusCode: 400, - statusMessage: 'Role is invalid' - }) - } - - const user = await getUserById(userId) - - if (!user) { - throw createError({ - statusCode: 404, - statusMessage: 'User not found' - }) - } + await requireExistingUser(userId) if (auth.user.id === userId && role !== 'super_admin') { - throw createError({ - statusCode: 400, - statusMessage: 'You cannot remove your own super admin access' - }) + httpError(400, 'You cannot remove your own super admin access') } const updatedUser = await updateUserProfile({ diff --git a/server/api/admin/users/[id]/reset-password.post.ts b/server/api/admin/users/[id]/reset-password.post.ts index b17c847..59c4b1b 100644 --- a/server/api/admin/users/[id]/reset-password.post.ts +++ b/server/api/admin/users/[id]/reset-password.post.ts @@ -2,28 +2,15 @@ import { DEFAULT_USER_PASSWORD } from '~~/shared/auth' import { requireRole } from '../../../../utils/auth' import { hashPassword } from '../../../../utils/password' -import { getUserById, updateUserPassword } from '../../../../utils/user-repository' +import { updateUserPassword } from '../../../../utils/user-repository' +import { requireExistingUser, requireUserIdParam } from '../../../../utils/users' export default defineEventHandler(async (event) => { await requireRole(event, 'super_admin') - const userId = getRouterParam(event, 'id') + const userId = requireUserIdParam(event) - if (!userId) { - throw createError({ - statusCode: 400, - statusMessage: 'User id is required' - }) - } - - const user = await getUserById(userId) - - if (!user) { - throw createError({ - statusCode: 404, - statusMessage: 'User not found' - }) - } + await requireExistingUser(userId) const passwordHash = await hashPassword(DEFAULT_USER_PASSWORD) @@ -33,7 +20,7 @@ export default defineEventHandler(async (event) => { mustChangePassword: true }) - const updatedUser = await getUserById(userId) + const updatedUser = await requireExistingUser(userId, 'Unable to load updated user') return { user: updatedUser, diff --git a/server/api/auth/change-password.post.ts b/server/api/auth/change-password.post.ts index 988127b..09a7315 100644 --- a/server/api/auth/change-password.post.ts +++ b/server/api/auth/change-password.post.ts @@ -1,8 +1,10 @@ import { MIN_PASSWORD_LENGTH } from '~~/shared/auth' +import { assertBadRequest, httpError } from '../../utils/http' import { requireAuth } from '../../utils/auth' import { hashPassword, verifyPassword } from '../../utils/password' -import { getUserById, updateUserPassword } from '../../utils/user-repository' +import { updateUserPassword } from '../../utils/user-repository' +import { requireExistingUser } from '../../utils/users' export default defineEventHandler(async (event) => { const auth = await requireAuth(event) @@ -14,34 +16,21 @@ export default defineEventHandler(async (event) => { const currentPassword = body.currentPassword?.trim() || '' const newPassword = body.newPassword?.trim() || '' - if (!currentPassword || !newPassword) { - throw createError({ - statusCode: 400, - statusMessage: 'Current password and new password are required' - }) - } + assertBadRequest(currentPassword, 'Current password and new password are required') + assertBadRequest(newPassword, 'Current password and new password are required') if (newPassword.length < MIN_PASSWORD_LENGTH) { - throw createError({ - statusCode: 400, - statusMessage: `New password must be at least ${MIN_PASSWORD_LENGTH} characters` - }) + httpError(400, `New password must be at least ${MIN_PASSWORD_LENGTH} characters`) } if (currentPassword === newPassword) { - throw createError({ - statusCode: 400, - statusMessage: 'New password must be different from the current password' - }) + httpError(400, 'New password must be different from the current password') } const currentPasswordMatches = await verifyPassword(currentPassword, auth.user.passwordHash) if (!currentPasswordMatches) { - throw createError({ - statusCode: 400, - statusMessage: 'Current password is incorrect' - }) + httpError(400, 'Current password is incorrect') } const passwordHash = await hashPassword(newPassword) @@ -52,7 +41,7 @@ export default defineEventHandler(async (event) => { mustChangePassword: false }) - const updatedUser = await getUserById(auth.user.id) + const updatedUser = await requireExistingUser(auth.user.id, 'Unable to load updated user') return { user: updatedUser diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts index 06dc653..91c3158 100644 --- a/server/api/auth/login.post.ts +++ b/server/api/auth/login.post.ts @@ -1,5 +1,8 @@ +import { normalizeUsername } from '~~/shared/auth' + +import { signInUser } from '../../utils/auth' +import { assertBadRequest, httpError } from '../../utils/http' import { verifyPassword } from '../../utils/password' -import { normalizeUsername, signInUser } from '../../utils/auth' import { getUserByUsername } from '../../utils/user-repository' export default defineEventHandler(async (event) => { @@ -13,29 +16,19 @@ export default defineEventHandler(async (event) => { const password = body.password?.trim() || '' const remember = body.remember !== false - if (!username || !password) { - throw createError({ - statusCode: 400, - statusMessage: 'Username and password are required' - }) - } + assertBadRequest(username, 'Username and password are required') + assertBadRequest(password, 'Username and password are required') const user = await getUserByUsername(username) if (!user || !user.isActive) { - throw createError({ - statusCode: 401, - statusMessage: 'Invalid username or password' - }) + httpError(401, 'Invalid username or password') } const passwordMatches = await verifyPassword(password, user.passwordHash) if (!passwordMatches) { - throw createError({ - statusCode: 401, - statusMessage: 'Invalid username or password' - }) + httpError(401, 'Invalid username or password') } const authenticatedUser = await signInUser(event, user, remember) diff --git a/server/api/auth/passkey/login/verify.post.ts b/server/api/auth/passkey/login/verify.post.ts index 5242832..7a733e9 100644 --- a/server/api/auth/passkey/login/verify.post.ts +++ b/server/api/auth/passkey/login/verify.post.ts @@ -1,8 +1,10 @@ import { verifyAuthenticationResponse, type AuthenticationResponseJSON } from '@simplewebauthn/server' -import { getUserById, getCredentialForVerification, updateCredentialCounter } from '../../../../utils/user-repository' -import { consumeLoginChallenge, getWebAuthnConfig, toWebAuthnCredential } from '../../../../utils/webauthn' import { signInUser } from '../../../../utils/auth' +import { assertBadRequest, httpError } from '../../../../utils/http' +import { getCredentialForVerification, updateCredentialCounter } from '../../../../utils/user-repository' +import { requireExistingUser } from '../../../../utils/users' +import { consumeLoginChallenge, getWebAuthnConfig, toWebAuthnCredential } from '../../../../utils/webauthn' export default defineEventHandler(async (event) => { const body = await readBody<{ @@ -15,29 +17,19 @@ export default defineEventHandler(async (event) => { const challengeToken = body.challengeToken?.trim() || '' const remember = body.remember !== false - if (!response || !challengeToken) { - throw createError({ - statusCode: 400, - statusMessage: 'Passkey login payload is incomplete' - }) - } + assertBadRequest(response, 'Passkey login payload is incomplete') + assertBadRequest(challengeToken, 'Passkey login payload is incomplete') const expectedChallenge = await consumeLoginChallenge(challengeToken) if (!expectedChallenge) { - throw createError({ - statusCode: 400, - statusMessage: 'Passkey login challenge expired. Try again.' - }) + httpError(400, 'Passkey login challenge expired. Try again.') } const storedCredential = await getCredentialForVerification(response.id) if (!storedCredential) { - throw createError({ - statusCode: 401, - statusMessage: 'Passkey is not recognized' - }) + httpError(401, 'Passkey is not recognized') } const config = getWebAuthnConfig(event) @@ -50,19 +42,13 @@ export default defineEventHandler(async (event) => { }) if (!verification.verified) { - throw createError({ - statusCode: 401, - statusMessage: 'Passkey authentication failed' - }) + httpError(401, 'Passkey authentication failed') } - const user = await getUserById(storedCredential.userId) + const user = await requireExistingUser(storedCredential.userId, 'User account is not available') - if (!user || !user.isActive) { - throw createError({ - statusCode: 401, - statusMessage: 'User account is not available' - }) + if (!user.isActive) { + httpError(401, 'User account is not available') } await updateCredentialCounter({ diff --git a/server/api/auth/passkey/register/verify.post.ts b/server/api/auth/passkey/register/verify.post.ts index f352ba3..8a7cdc1 100644 --- a/server/api/auth/passkey/register/verify.post.ts +++ b/server/api/auth/passkey/register/verify.post.ts @@ -1,7 +1,9 @@ import { verifyRegistrationResponse, type RegistrationResponseJSON } from '@simplewebauthn/server' import { requireAuth } from '../../../../utils/auth' -import { createUserPasskey, getUserById, listUserPasskeys } from '../../../../utils/user-repository' +import { assertBadRequest, httpError, mapDatabaseError } from '../../../../utils/http' +import { createUserPasskey, listUserPasskeys } from '../../../../utils/user-repository' +import { requireExistingUser } from '../../../../utils/users' import { buildPasskeyLabel, consumeRegistrationChallenge, getWebAuthnConfig } from '../../../../utils/webauthn' export default defineEventHandler(async (event) => { @@ -10,20 +12,12 @@ export default defineEventHandler(async (event) => { response?: RegistrationResponseJSON }>(event) - if (!body.response) { - throw createError({ - statusCode: 400, - statusMessage: 'Passkey registration response is required' - }) - } + assertBadRequest(body.response, 'Passkey registration response is required') const expectedChallenge = await consumeRegistrationChallenge(auth.user.id) if (!expectedChallenge) { - throw createError({ - statusCode: 400, - statusMessage: 'Passkey registration challenge expired. Try again.' - }) + httpError(400, 'Passkey registration challenge expired. Try again.') } const config = getWebAuthnConfig(event) @@ -35,10 +29,7 @@ export default defineEventHandler(async (event) => { }) if (!verification.verified || !verification.registrationInfo) { - throw createError({ - statusCode: 400, - statusMessage: 'Passkey registration could not be verified' - }) + httpError(400, 'Passkey registration could not be verified') } try { @@ -52,18 +43,16 @@ export default defineEventHandler(async (event) => { transports: body.response.response.transports || [], label: buildPasskeyLabel() }) - } catch (error: any) { - if (error?.code === '23505') { - throw createError({ + } catch (error) { + mapDatabaseError(error, { + '23505': { statusCode: 409, statusMessage: 'This passkey is already registered' - }) - } - - throw error + } + }) } - const updatedUser = await getUserById(auth.user.id) + const updatedUser = await requireExistingUser(auth.user.id, 'Unable to load updated user') const passkeys = await listUserPasskeys(auth.user.id) return { diff --git a/server/utils/auth.ts b/server/utils/auth.ts index ac0964b..2d61ac5 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -1,14 +1,10 @@ import type { H3Event } from 'h3' -import type { UserRole } from '~~/shared/auth' +import { normalizeUsername, type UserRole } from '~~/shared/auth' import { createUserSession, destroyUserSession, getUserSession } from './session' import { getUserById, updateLastLogin, type UserAuthRecord } from './user-repository' -export function normalizeUsername(value: string) { - return value.trim().toLowerCase() -} - export async function getAuthContext(event: H3Event): Promise<{ session: Awaited> user: UserAuthRecord diff --git a/server/utils/http.ts b/server/utils/http.ts new file mode 100644 index 0000000..99cfd21 --- /dev/null +++ b/server/utils/http.ts @@ -0,0 +1,46 @@ +import type { H3Event } from 'h3' + +import { getRouterParam } from 'h3' + +export function httpError(statusCode: number, statusMessage: string): never { + throw createError({ + statusCode, + statusMessage + }) +} + +export function assertHttp( + condition: unknown, + statusCode: number, + statusMessage: string +): asserts condition { + if (!condition) { + httpError(statusCode, statusMessage) + } +} + +export function assertBadRequest(condition: unknown, statusMessage: string): asserts condition { + assertHttp(condition, 400, statusMessage) +} + +export function getRequiredRouteParam(event: H3Event, name: string, label = name) { + const value = getRouterParam(event, name) + + assertBadRequest(value, `${label} is required`) + + return value +} + +export function mapDatabaseError( + error: unknown, + handlers: Partial> +): never { + const code = (error as { code?: string } | null)?.code + const handler = code ? handlers[code] : undefined + + if (handler) { + httpError(handler.statusCode, handler.statusMessage) + } + + throw error +} diff --git a/server/utils/user-repository.ts b/server/utils/user-repository.ts index e8aaec3..e3b61c2 100644 --- a/server/utils/user-repository.ts +++ b/server/utils/user-repository.ts @@ -56,11 +56,7 @@ export interface PasskeyRecord { lastUsedAt: string | null } -function parseCount(value: number | string): number { - return typeof value === 'number' ? value : Number.parseInt(value, 10) -} - -function parseCounter(value: number | string): number { +function parseInteger(value: number | string): number { return typeof value === 'number' ? value : Number.parseInt(value, 10) } @@ -78,7 +74,7 @@ function parseTransports(value: AuthenticatorTransportFuture[] | string): Authen } function mapAuthUser(row: DbUserRow): AuthUser { - const passkeyCount = parseCount(row.passkey_count) + const passkeyCount = parseInteger(row.passkey_count) return { id: row.id, @@ -116,7 +112,7 @@ function mapPasskeyRecord(row: DbPasskeyRow): PasskeyRecord { userId: row.user_id, credentialId: row.credential_id, publicKey: row.public_key, - counter: parseCounter(row.counter), + counter: parseInteger(row.counter), deviceType: row.device_type, backedUp: row.backed_up, transports: parseTransports(row.transports), diff --git a/server/utils/users.ts b/server/utils/users.ts new file mode 100644 index 0000000..aab31e7 --- /dev/null +++ b/server/utils/users.ts @@ -0,0 +1,75 @@ +import type { H3Event } from 'h3' + +import { + hasValidFullName, + isUserRole, + isValidPhoneNumber, + isValidUsername, + normalizeFullName, + normalizePhoneNumber, + normalizeUsername, + type UserRole +} from '~~/shared/auth' + +import { assertBadRequest, getRequiredRouteParam, httpError } from './http' +import { getUserById } from './user-repository' + +export function requireUserIdParam(event: H3Event) { + return getRequiredRouteParam(event, 'id', 'User id') +} + +export async function requireExistingUser(userId: string, statusMessage = 'User not found') { + const user = await getUserById(userId) + + if (!user) { + httpError(404, statusMessage) + } + + return user +} + +export function parseCreateUserInput(body: { + username?: string + fullName?: string + phoneNumber?: string + role?: UserRole +}) { + const username = normalizeUsername(body.username || '') + const fullName = normalizeFullName(body.fullName || '') + const phoneNumber = normalizePhoneNumber(body.phoneNumber || '') + const role = body.role === 'super_admin' ? 'super_admin' : 'staff' + + assertBadRequest( + isValidUsername(username), + 'Username must be 3 to 32 characters using lowercase letters, numbers, dot, dash, or underscore' + ) + assertBadRequest(hasValidFullName(fullName), 'Full name must be at least 2 characters') + assertBadRequest(isValidPhoneNumber(phoneNumber), 'Phone number must contain 8 to 15 digits') + + return { + username, + fullName, + phoneNumber, + role + } +} + +export function parseUserProfileInput(body: { + fullName?: string + phoneNumber?: string + role?: UserRole +}) { + const fullName = normalizeFullName(body.fullName || '') + const phoneNumber = normalizePhoneNumber(body.phoneNumber || '') + const role = body.role + + assertBadRequest(hasValidFullName(fullName), 'Display name must be at least 2 characters') + assertBadRequest(isValidPhoneNumber(phoneNumber), 'Phone number must contain 8 to 15 digits') + assertBadRequest(isUserRole(role), 'Role is invalid') + + return { + fullName, + phoneNumber, + role + } +} diff --git a/shared/auth.ts b/shared/auth.ts index cf02cf0..d07977a 100644 --- a/shared/auth.ts +++ b/shared/auth.ts @@ -1,10 +1,19 @@ export const DEFAULT_USER_PASSWORD = '123456' export const MIN_PASSWORD_LENGTH = 8 +export const MIN_FULL_NAME_LENGTH = 2 export const USERNAME_PATTERN = /^[a-z0-9._-]{3,32}$/ export const PHONE_NUMBER_PATTERN = /^\+?\d{8,15}$/ export type UserRole = 'super_admin' | 'staff' +export function normalizeUsername(value: string) { + return value.trim().toLowerCase() +} + +export function normalizeFullName(value: string) { + return value.trim() +} + export function normalizePhoneNumber(value: string) { const trimmed = value.trim() @@ -22,6 +31,18 @@ export function isValidPhoneNumber(value: string) { return PHONE_NUMBER_PATTERN.test(normalizePhoneNumber(value)) } +export function isValidUsername(value: string) { + return USERNAME_PATTERN.test(normalizeUsername(value)) +} + +export function hasValidFullName(value: string) { + return normalizeFullName(value).length >= MIN_FULL_NAME_LENGTH +} + +export function isUserRole(value: string | null | undefined): value is UserRole { + return value === 'super_admin' || value === 'staff' +} + export interface AuthUser { id: string username: string @@ -55,3 +76,19 @@ export interface PasskeySummary { deviceType: 'singleDevice' | 'multiDevice' backedUp: boolean } + +export function needsUserOnboarding( + user: Pick | null | undefined +) { + return Boolean(user && (user.mustChangePassword || user.needsPasskeySetup)) +} + +export function getDefaultAuthenticatedPath( + user: Pick +) { + if (needsUserOnboarding(user)) { + return '/security' + } + + return user.role === 'super_admin' ? '/management/users' : '/security' +}