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
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { AuthUser } from '~~/shared/auth'
|
||||
import { needsUserOnboarding, type AuthUser } from '~~/shared/auth'
|
||||
|
||||
export function useAuth() {
|
||||
const user = useState<AuthUser | null>('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) {
|
||||
|
||||
@@ -305,6 +305,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { getErrorMessage } from '../utils/errors'
|
||||
|
||||
interface SystemMenuItem {
|
||||
label: string
|
||||
to: string
|
||||
@@ -405,7 +407,7 @@ async function logout() {
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Logout failed',
|
||||
description: error?.data?.statusMessage || 'Unable to end the current session.',
|
||||
description: getErrorMessage(error, 'Unable to end the current session.'),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { needsUserOnboarding } from '~~/shared/auth'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuth()
|
||||
await auth.fetchSession()
|
||||
@@ -6,7 +8,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
if (to.path !== '/security' && auth.needsOnboarding.value) {
|
||||
if (to.path !== '/security' && needsUserOnboarding(auth.user.value)) {
|
||||
return navigateTo('/security')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getDefaultAuthenticatedPath } from '~~/shared/auth'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async () => {
|
||||
const auth = useAuth()
|
||||
await auth.fetchSession()
|
||||
@@ -6,9 +8,5 @@ export default defineNuxtRouteMiddleware(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (auth.needsOnboarding.value) {
|
||||
return navigateTo('/security')
|
||||
}
|
||||
|
||||
return navigateTo(auth.isSuperAdmin.value ? '/management/users' : '/security')
|
||||
return navigateTo(getDefaultAuthenticatedPath(auth.user.value))
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { needsUserOnboarding } from '~~/shared/auth'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async () => {
|
||||
const auth = useAuth()
|
||||
await auth.fetchSession()
|
||||
@@ -6,7 +8,7 @@ export default defineNuxtRouteMiddleware(async () => {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
if (auth.needsOnboarding.value) {
|
||||
if (needsUserOnboarding(auth.user.value)) {
|
||||
return navigateTo('/security')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
await navigateTo('/', { redirectCode: 301 })
|
||||
</script>
|
||||
@@ -69,6 +69,10 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
||||
|
||||
import { getDefaultAuthenticatedPath } from '~~/shared/auth'
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'guest'
|
||||
})
|
||||
@@ -105,13 +109,7 @@ async function finishLogin(user: Awaited<ReturnType<typeof auth.fetchSession>>)
|
||||
return
|
||||
}
|
||||
|
||||
const target = user.mustChangePassword || user.needsPasskeySetup
|
||||
? '/security'
|
||||
: user.role === 'super_admin'
|
||||
? '/management/users'
|
||||
: '/security'
|
||||
|
||||
await router.push(target)
|
||||
await router.push(getDefaultAuthenticatedPath(user))
|
||||
}
|
||||
|
||||
async function onSubmit(event: FormSubmitEvent<typeof form>) {
|
||||
@@ -138,7 +136,7 @@ async function onSubmit(event: FormSubmitEvent<typeof form>) {
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Login failed',
|
||||
description: error?.data?.statusMessage || 'Unable to sign in with username and password.',
|
||||
description: getErrorMessage(error, 'Unable to sign in with username and password.'),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
@@ -184,7 +182,7 @@ async function loginWithPasskey() {
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Passkey login failed',
|
||||
description: error?.data?.statusMessage || error?.message || 'Unable to complete passkey login.',
|
||||
description: getErrorMessage(error, 'Unable to complete passkey login.'),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
|
||||
<template #lastLoginAt-cell="{ row }">
|
||||
<span class="text-xs text-muted sm:text-sm">
|
||||
{{ formatDate(row.original.lastLoginAt) }}
|
||||
{{ formatDateTime(row.original.lastLoginAt, 'Never') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -208,13 +208,19 @@
|
||||
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
||||
|
||||
import {
|
||||
USERNAME_PATTERN,
|
||||
hasValidFullName,
|
||||
isValidPhoneNumber,
|
||||
isValidUsername,
|
||||
normalizeFullName,
|
||||
normalizePhoneNumber,
|
||||
normalizeUsername,
|
||||
type ManagedUser,
|
||||
type UserRole
|
||||
} from '~~/shared/auth'
|
||||
|
||||
import { getErrorMessage } from '../../../utils/errors'
|
||||
import { formatDateTime } from '../../../utils/formatters'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'super-admin'
|
||||
})
|
||||
@@ -314,11 +320,11 @@ function closeEditor() {
|
||||
function validateUserForm(state: typeof userForm): FormError[] {
|
||||
const errors: FormError[] = []
|
||||
|
||||
if (state.fullName.trim().length < 2) {
|
||||
if (!hasValidFullName(state.fullName)) {
|
||||
errors.push({ name: 'fullName', message: 'Enter a display name with at least 2 characters.' })
|
||||
}
|
||||
|
||||
if (!isEditMode.value && !USERNAME_PATTERN.test(state.username.trim().toLowerCase())) {
|
||||
if (!isEditMode.value && !isValidUsername(state.username)) {
|
||||
errors.push({ name: 'username', message: 'Use 3 to 32 lowercase letters, numbers, dot, dash, or underscore.' })
|
||||
}
|
||||
|
||||
@@ -342,7 +348,7 @@ async function refreshUsers() {
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Unable to load users',
|
||||
description: error?.data?.statusMessage || 'The user list could not be loaded.',
|
||||
description: getErrorMessage(error, 'The user list could not be loaded.'),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
@@ -365,7 +371,7 @@ async function saveUser(event: FormSubmitEvent<typeof userForm>) {
|
||||
await apiClient(`/api/admin/users/${editingUserId.value}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
fullName: userForm.fullName.trim(),
|
||||
fullName: normalizeFullName(userForm.fullName),
|
||||
phoneNumber: normalizePhoneNumber(userForm.phoneNumber),
|
||||
role: userForm.role
|
||||
}
|
||||
@@ -377,7 +383,7 @@ async function saveUser(event: FormSubmitEvent<typeof userForm>) {
|
||||
|
||||
toast.add({
|
||||
title: 'User updated',
|
||||
description: `${userForm.fullName.trim()} has been updated.`,
|
||||
description: `${normalizeFullName(userForm.fullName)} has been updated.`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-check-circle-2'
|
||||
})
|
||||
@@ -388,8 +394,8 @@ async function saveUser(event: FormSubmitEvent<typeof userForm>) {
|
||||
}>('/api/admin/users', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
fullName: userForm.fullName.trim(),
|
||||
username: userForm.username.trim().toLowerCase(),
|
||||
fullName: normalizeFullName(userForm.fullName),
|
||||
username: normalizeUsername(userForm.username),
|
||||
phoneNumber: normalizePhoneNumber(userForm.phoneNumber),
|
||||
role: userForm.role
|
||||
}
|
||||
@@ -410,7 +416,10 @@ async function saveUser(event: FormSubmitEvent<typeof userForm>) {
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: isEditMode.value ? 'Update failed' : 'User creation failed',
|
||||
description: error?.data?.statusMessage || (isEditMode.value ? 'Unable to update this user.' : 'Unable to create the new user.'),
|
||||
description: getErrorMessage(
|
||||
error,
|
||||
isEditMode.value ? 'Unable to update this user.' : 'Unable to create the new user.'
|
||||
),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
@@ -447,7 +456,7 @@ async function resetPassword(user: ManagedUser) {
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Reset failed',
|
||||
description: error?.data?.statusMessage || 'Unable to reset this password.',
|
||||
description: getErrorMessage(error, 'Unable to reset this password.'),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
@@ -455,15 +464,4 @@ async function resetPassword(user: ManagedUser) {
|
||||
resettingUserId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) {
|
||||
return 'Never'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-MY', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(new Date(value))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
{{ passkey.label }}
|
||||
</div>
|
||||
<div class="text-sm text-muted">
|
||||
Added {{ formatDate(passkey.createdAt) }}
|
||||
Added {{ formatDateTime(passkey.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -105,7 +105,10 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
||||
|
||||
import { MIN_PASSWORD_LENGTH, type PasskeySummary } from '~~/shared/auth'
|
||||
import { getDefaultAuthenticatedPath, MIN_PASSWORD_LENGTH, type PasskeySummary } from '~~/shared/auth'
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors'
|
||||
import { formatDateTime } from '../../utils/formatters'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
@@ -152,8 +155,8 @@ async function fetchPasskeys() {
|
||||
}
|
||||
|
||||
function maybeRedirectAfterOnboarding(previouslyRequired: boolean) {
|
||||
if (previouslyRequired && !auth.needsOnboarding.value) {
|
||||
router.push(auth.isSuperAdmin.value ? '/management/users' : '/security')
|
||||
if (previouslyRequired && auth.user.value && !auth.needsOnboarding.value) {
|
||||
router.push(getDefaultAuthenticatedPath(auth.user.value))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +195,7 @@ async function changePassword(event: FormSubmitEvent<typeof passwordForm>) {
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Password update failed',
|
||||
description: error?.data?.statusMessage || 'Unable to update your password.',
|
||||
description: getErrorMessage(error, 'Unable to update your password.'),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
@@ -246,7 +249,7 @@ async function registerPasskey() {
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Passkey registration failed',
|
||||
description: error?.data?.statusMessage || error?.message || 'Unable to register a passkey.',
|
||||
description: getErrorMessage(error, 'Unable to register a passkey.'),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
@@ -254,15 +257,4 @@ async function registerPasskey() {
|
||||
passkeyPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) {
|
||||
return 'Not available'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-MY', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(new Date(value))
|
||||
}
|
||||
</script>
|
||||
|
||||
3
app/utils/errors.ts
Normal file
3
app/utils/errors.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function getErrorMessage(error: any, fallback: string) {
|
||||
return error?.data?.statusMessage || error?.message || fallback
|
||||
}
|
||||
10
app/utils/formatters.ts
Normal file
10
app/utils/formatters.ts
Normal file
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user