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() {
|
export function useAuth() {
|
||||||
const user = useState<AuthUser | null>('auth:user', () => null)
|
const user = useState<AuthUser | null>('auth:user', () => null)
|
||||||
@@ -8,9 +8,7 @@ export function useAuth() {
|
|||||||
|
|
||||||
const isAuthenticated = computed(() => Boolean(user.value))
|
const isAuthenticated = computed(() => Boolean(user.value))
|
||||||
const isSuperAdmin = computed(() => user.value?.role === 'super_admin')
|
const isSuperAdmin = computed(() => user.value?.role === 'super_admin')
|
||||||
const needsOnboarding = computed(() => {
|
const needsOnboarding = computed(() => needsUserOnboarding(user.value))
|
||||||
return Boolean(user.value && (user.value.mustChangePassword || user.value.needsPasskeySetup))
|
|
||||||
})
|
|
||||||
|
|
||||||
async function fetchSession(force = false) {
|
async function fetchSession(force = false) {
|
||||||
if (loaded.value && !force) {
|
if (loaded.value && !force) {
|
||||||
|
|||||||
@@ -305,6 +305,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { getErrorMessage } from '../utils/errors'
|
||||||
|
|
||||||
interface SystemMenuItem {
|
interface SystemMenuItem {
|
||||||
label: string
|
label: string
|
||||||
to: string
|
to: string
|
||||||
@@ -405,7 +407,7 @@ async function logout() {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'Logout failed',
|
title: 'Logout failed',
|
||||||
description: error?.data?.statusMessage || 'Unable to end the current session.',
|
description: getErrorMessage(error, 'Unable to end the current session.'),
|
||||||
color: 'error',
|
color: 'error',
|
||||||
icon: 'i-lucide-circle-alert'
|
icon: 'i-lucide-circle-alert'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { needsUserOnboarding } from '~~/shared/auth'
|
||||||
|
|
||||||
export default defineNuxtRouteMiddleware(async (to) => {
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
await auth.fetchSession()
|
await auth.fetchSession()
|
||||||
@@ -6,7 +8,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||||||
return navigateTo('/login')
|
return navigateTo('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.path !== '/security' && auth.needsOnboarding.value) {
|
if (to.path !== '/security' && needsUserOnboarding(auth.user.value)) {
|
||||||
return navigateTo('/security')
|
return navigateTo('/security')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getDefaultAuthenticatedPath } from '~~/shared/auth'
|
||||||
|
|
||||||
export default defineNuxtRouteMiddleware(async () => {
|
export default defineNuxtRouteMiddleware(async () => {
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
await auth.fetchSession()
|
await auth.fetchSession()
|
||||||
@@ -6,9 +8,5 @@ export default defineNuxtRouteMiddleware(async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth.needsOnboarding.value) {
|
return navigateTo(getDefaultAuthenticatedPath(auth.user.value))
|
||||||
return navigateTo('/security')
|
|
||||||
}
|
|
||||||
|
|
||||||
return navigateTo(auth.isSuperAdmin.value ? '/management/users' : '/security')
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { needsUserOnboarding } from '~~/shared/auth'
|
||||||
|
|
||||||
export default defineNuxtRouteMiddleware(async () => {
|
export default defineNuxtRouteMiddleware(async () => {
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
await auth.fetchSession()
|
await auth.fetchSession()
|
||||||
@@ -6,7 +8,7 @@ export default defineNuxtRouteMiddleware(async () => {
|
|||||||
return navigateTo('/login')
|
return navigateTo('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth.needsOnboarding.value) {
|
if (needsUserOnboarding(auth.user.value)) {
|
||||||
return navigateTo('/security')
|
return navigateTo('/security')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
await navigateTo('/', { redirectCode: 301 })
|
|
||||||
</script>
|
|
||||||
@@ -69,6 +69,10 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
||||||
|
|
||||||
|
import { getDefaultAuthenticatedPath } from '~~/shared/auth'
|
||||||
|
|
||||||
|
import { getErrorMessage } from '../../utils/errors'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: 'guest'
|
middleware: 'guest'
|
||||||
})
|
})
|
||||||
@@ -105,13 +109,7 @@ async function finishLogin(user: Awaited<ReturnType<typeof auth.fetchSession>>)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = user.mustChangePassword || user.needsPasskeySetup
|
await router.push(getDefaultAuthenticatedPath(user))
|
||||||
? '/security'
|
|
||||||
: user.role === 'super_admin'
|
|
||||||
? '/management/users'
|
|
||||||
: '/security'
|
|
||||||
|
|
||||||
await router.push(target)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmit(event: FormSubmitEvent<typeof form>) {
|
async function onSubmit(event: FormSubmitEvent<typeof form>) {
|
||||||
@@ -138,7 +136,7 @@ async function onSubmit(event: FormSubmitEvent<typeof form>) {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'Login failed',
|
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',
|
color: 'error',
|
||||||
icon: 'i-lucide-circle-alert'
|
icon: 'i-lucide-circle-alert'
|
||||||
})
|
})
|
||||||
@@ -184,7 +182,7 @@ async function loginWithPasskey() {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'Passkey login failed',
|
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',
|
color: 'error',
|
||||||
icon: 'i-lucide-circle-alert'
|
icon: 'i-lucide-circle-alert'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -108,7 +108,7 @@
|
|||||||
|
|
||||||
<template #lastLoginAt-cell="{ row }">
|
<template #lastLoginAt-cell="{ row }">
|
||||||
<span class="text-xs text-muted sm:text-sm">
|
<span class="text-xs text-muted sm:text-sm">
|
||||||
{{ formatDate(row.original.lastLoginAt) }}
|
{{ formatDateTime(row.original.lastLoginAt, 'Never') }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -208,13 +208,19 @@
|
|||||||
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
USERNAME_PATTERN,
|
hasValidFullName,
|
||||||
isValidPhoneNumber,
|
isValidPhoneNumber,
|
||||||
|
isValidUsername,
|
||||||
|
normalizeFullName,
|
||||||
normalizePhoneNumber,
|
normalizePhoneNumber,
|
||||||
|
normalizeUsername,
|
||||||
type ManagedUser,
|
type ManagedUser,
|
||||||
type UserRole
|
type UserRole
|
||||||
} from '~~/shared/auth'
|
} from '~~/shared/auth'
|
||||||
|
|
||||||
|
import { getErrorMessage } from '../../../utils/errors'
|
||||||
|
import { formatDateTime } from '../../../utils/formatters'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: 'super-admin'
|
middleware: 'super-admin'
|
||||||
})
|
})
|
||||||
@@ -314,11 +320,11 @@ function closeEditor() {
|
|||||||
function validateUserForm(state: typeof userForm): FormError[] {
|
function validateUserForm(state: typeof userForm): FormError[] {
|
||||||
const errors: 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.' })
|
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.' })
|
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) {
|
} catch (error: any) {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'Unable to load users',
|
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',
|
color: 'error',
|
||||||
icon: 'i-lucide-circle-alert'
|
icon: 'i-lucide-circle-alert'
|
||||||
})
|
})
|
||||||
@@ -365,7 +371,7 @@ async function saveUser(event: FormSubmitEvent<typeof userForm>) {
|
|||||||
await apiClient(`/api/admin/users/${editingUserId.value}`, {
|
await apiClient(`/api/admin/users/${editingUserId.value}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: {
|
body: {
|
||||||
fullName: userForm.fullName.trim(),
|
fullName: normalizeFullName(userForm.fullName),
|
||||||
phoneNumber: normalizePhoneNumber(userForm.phoneNumber),
|
phoneNumber: normalizePhoneNumber(userForm.phoneNumber),
|
||||||
role: userForm.role
|
role: userForm.role
|
||||||
}
|
}
|
||||||
@@ -377,7 +383,7 @@ async function saveUser(event: FormSubmitEvent<typeof userForm>) {
|
|||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'User updated',
|
title: 'User updated',
|
||||||
description: `${userForm.fullName.trim()} has been updated.`,
|
description: `${normalizeFullName(userForm.fullName)} has been updated.`,
|
||||||
color: 'success',
|
color: 'success',
|
||||||
icon: 'i-lucide-check-circle-2'
|
icon: 'i-lucide-check-circle-2'
|
||||||
})
|
})
|
||||||
@@ -388,8 +394,8 @@ async function saveUser(event: FormSubmitEvent<typeof userForm>) {
|
|||||||
}>('/api/admin/users', {
|
}>('/api/admin/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
fullName: userForm.fullName.trim(),
|
fullName: normalizeFullName(userForm.fullName),
|
||||||
username: userForm.username.trim().toLowerCase(),
|
username: normalizeUsername(userForm.username),
|
||||||
phoneNumber: normalizePhoneNumber(userForm.phoneNumber),
|
phoneNumber: normalizePhoneNumber(userForm.phoneNumber),
|
||||||
role: userForm.role
|
role: userForm.role
|
||||||
}
|
}
|
||||||
@@ -410,7 +416,10 @@ async function saveUser(event: FormSubmitEvent<typeof userForm>) {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: isEditMode.value ? 'Update failed' : 'User creation failed',
|
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',
|
color: 'error',
|
||||||
icon: 'i-lucide-circle-alert'
|
icon: 'i-lucide-circle-alert'
|
||||||
})
|
})
|
||||||
@@ -447,7 +456,7 @@ async function resetPassword(user: ManagedUser) {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'Reset failed',
|
title: 'Reset failed',
|
||||||
description: error?.data?.statusMessage || 'Unable to reset this password.',
|
description: getErrorMessage(error, 'Unable to reset this password.'),
|
||||||
color: 'error',
|
color: 'error',
|
||||||
icon: 'i-lucide-circle-alert'
|
icon: 'i-lucide-circle-alert'
|
||||||
})
|
})
|
||||||
@@ -455,15 +464,4 @@ async function resetPassword(user: ManagedUser) {
|
|||||||
resettingUserId.value = null
|
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>
|
</script>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
{{ passkey.label }}
|
{{ passkey.label }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-muted">
|
<div class="text-sm text-muted">
|
||||||
Added {{ formatDate(passkey.createdAt) }}
|
Added {{ formatDateTime(passkey.createdAt) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,7 +105,10 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
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({
|
definePageMeta({
|
||||||
middleware: 'auth'
|
middleware: 'auth'
|
||||||
@@ -152,8 +155,8 @@ async function fetchPasskeys() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function maybeRedirectAfterOnboarding(previouslyRequired: boolean) {
|
function maybeRedirectAfterOnboarding(previouslyRequired: boolean) {
|
||||||
if (previouslyRequired && !auth.needsOnboarding.value) {
|
if (previouslyRequired && auth.user.value && !auth.needsOnboarding.value) {
|
||||||
router.push(auth.isSuperAdmin.value ? '/management/users' : '/security')
|
router.push(getDefaultAuthenticatedPath(auth.user.value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +195,7 @@ async function changePassword(event: FormSubmitEvent<typeof passwordForm>) {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'Password update failed',
|
title: 'Password update failed',
|
||||||
description: error?.data?.statusMessage || 'Unable to update your password.',
|
description: getErrorMessage(error, 'Unable to update your password.'),
|
||||||
color: 'error',
|
color: 'error',
|
||||||
icon: 'i-lucide-circle-alert'
|
icon: 'i-lucide-circle-alert'
|
||||||
})
|
})
|
||||||
@@ -246,7 +249,7 @@ async function registerPasskey() {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'Passkey registration failed',
|
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',
|
color: 'error',
|
||||||
icon: 'i-lucide-circle-alert'
|
icon: 'i-lucide-circle-alert'
|
||||||
})
|
})
|
||||||
@@ -254,15 +257,4 @@ async function registerPasskey() {
|
|||||||
passkeyPending.value = false
|
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>
|
</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))
|
||||||
|
}
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import {
|
import { DEFAULT_USER_PASSWORD, type UserRole } from '~~/shared/auth'
|
||||||
DEFAULT_USER_PASSWORD,
|
|
||||||
USERNAME_PATTERN,
|
|
||||||
isValidPhoneNumber,
|
|
||||||
normalizePhoneNumber,
|
|
||||||
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 { hashPassword } from '../../utils/password'
|
||||||
import { createUser } from '../../utils/user-repository'
|
import { createUser } from '../../utils/user-repository'
|
||||||
|
import { parseCreateUserInput } from '../../utils/users'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const auth = await requireRole(event, 'super_admin')
|
const auth = await requireRole(event, 'super_admin')
|
||||||
@@ -19,31 +15,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
role?: UserRole
|
role?: UserRole
|
||||||
}>(event)
|
}>(event)
|
||||||
|
|
||||||
const username = normalizeUsername(body.username || '')
|
const {
|
||||||
const fullName = body.fullName?.trim() || ''
|
username,
|
||||||
const phoneNumber = normalizePhoneNumber(body.phoneNumber || '')
|
fullName,
|
||||||
const role = body.role === 'super_admin' ? 'super_admin' : 'staff'
|
phoneNumber,
|
||||||
|
role
|
||||||
if (!USERNAME_PATTERN.test(username)) {
|
} = parseCreateUserInput(body)
|
||||||
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 passwordHash = await hashPassword(DEFAULT_USER_PASSWORD)
|
const passwordHash = await hashPassword(DEFAULT_USER_PASSWORD)
|
||||||
|
|
||||||
@@ -61,14 +38,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
user,
|
user,
|
||||||
defaultPassword: DEFAULT_USER_PASSWORD
|
defaultPassword: DEFAULT_USER_PASSWORD
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
if (error?.code === '23505') {
|
mapDatabaseError(error, {
|
||||||
throw createError({
|
'23505': {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
statusMessage: 'Username already exists'
|
statusMessage: 'Username already exists'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
import {
|
import type { UserRole } from '~~/shared/auth'
|
||||||
isValidPhoneNumber,
|
|
||||||
normalizePhoneNumber,
|
|
||||||
type UserRole
|
|
||||||
} from '~~/shared/auth'
|
|
||||||
|
|
||||||
import { requireRole } from '../../../utils/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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const auth = await requireRole(event, 'super_admin')
|
const auth = 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 body = await readBody<{
|
const body = await readBody<{
|
||||||
fullName?: string
|
fullName?: string
|
||||||
@@ -24,45 +15,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
role?: UserRole
|
role?: UserRole
|
||||||
}>(event)
|
}>(event)
|
||||||
|
|
||||||
const fullName = body.fullName?.trim() || ''
|
const { fullName, phoneNumber, role } = parseUserProfileInput(body)
|
||||||
const phoneNumber = normalizePhoneNumber(body.phoneNumber || '')
|
|
||||||
const role = body.role
|
|
||||||
|
|
||||||
if (fullName.length < 2) {
|
await requireExistingUser(userId)
|
||||||
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'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth.user.id === userId && role !== 'super_admin') {
|
if (auth.user.id === userId && role !== 'super_admin') {
|
||||||
throw createError({
|
httpError(400, 'You cannot remove your own super admin access')
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'You cannot remove your own super admin access'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUser = await updateUserProfile({
|
const updatedUser = await updateUserProfile({
|
||||||
|
|||||||
@@ -2,28 +2,15 @@ import { DEFAULT_USER_PASSWORD } from '~~/shared/auth'
|
|||||||
|
|
||||||
import { requireRole } from '../../../../utils/auth'
|
import { requireRole } from '../../../../utils/auth'
|
||||||
import { hashPassword } from '../../../../utils/password'
|
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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requireRole(event, 'super_admin')
|
await requireRole(event, 'super_admin')
|
||||||
|
|
||||||
const userId = getRouterParam(event, 'id')
|
const userId = requireUserIdParam(event)
|
||||||
|
|
||||||
if (!userId) {
|
await requireExistingUser(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'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await hashPassword(DEFAULT_USER_PASSWORD)
|
const passwordHash = await hashPassword(DEFAULT_USER_PASSWORD)
|
||||||
|
|
||||||
@@ -33,7 +20,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
mustChangePassword: true
|
mustChangePassword: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const updatedUser = await getUserById(userId)
|
const updatedUser = await requireExistingUser(userId, 'Unable to load updated user')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: updatedUser,
|
user: updatedUser,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { MIN_PASSWORD_LENGTH } from '~~/shared/auth'
|
import { MIN_PASSWORD_LENGTH } from '~~/shared/auth'
|
||||||
|
|
||||||
|
import { assertBadRequest, httpError } from '../../utils/http'
|
||||||
import { requireAuth } from '../../utils/auth'
|
import { requireAuth } from '../../utils/auth'
|
||||||
import { hashPassword, verifyPassword } from '../../utils/password'
|
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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const auth = await requireAuth(event)
|
const auth = await requireAuth(event)
|
||||||
@@ -14,34 +16,21 @@ export default defineEventHandler(async (event) => {
|
|||||||
const currentPassword = body.currentPassword?.trim() || ''
|
const currentPassword = body.currentPassword?.trim() || ''
|
||||||
const newPassword = body.newPassword?.trim() || ''
|
const newPassword = body.newPassword?.trim() || ''
|
||||||
|
|
||||||
if (!currentPassword || !newPassword) {
|
assertBadRequest(currentPassword, 'Current password and new password are required')
|
||||||
throw createError({
|
assertBadRequest(newPassword, 'Current password and new password are required')
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Current password and new password are required'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||||
throw createError({
|
httpError(400, `New password must be at least ${MIN_PASSWORD_LENGTH} characters`)
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: `New password must be at least ${MIN_PASSWORD_LENGTH} characters`
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentPassword === newPassword) {
|
if (currentPassword === newPassword) {
|
||||||
throw createError({
|
httpError(400, 'New password must be different from the current password')
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'New password must be different from the current password'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentPasswordMatches = await verifyPassword(currentPassword, auth.user.passwordHash)
|
const currentPasswordMatches = await verifyPassword(currentPassword, auth.user.passwordHash)
|
||||||
|
|
||||||
if (!currentPasswordMatches) {
|
if (!currentPasswordMatches) {
|
||||||
throw createError({
|
httpError(400, 'Current password is incorrect')
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Current password is incorrect'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await hashPassword(newPassword)
|
const passwordHash = await hashPassword(newPassword)
|
||||||
@@ -52,7 +41,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
mustChangePassword: false
|
mustChangePassword: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const updatedUser = await getUserById(auth.user.id)
|
const updatedUser = await requireExistingUser(auth.user.id, 'Unable to load updated user')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: updatedUser
|
user: updatedUser
|
||||||
|
|||||||
@@ -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 { verifyPassword } from '../../utils/password'
|
||||||
import { normalizeUsername, signInUser } from '../../utils/auth'
|
|
||||||
import { getUserByUsername } from '../../utils/user-repository'
|
import { getUserByUsername } from '../../utils/user-repository'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -13,29 +16,19 @@ export default defineEventHandler(async (event) => {
|
|||||||
const password = body.password?.trim() || ''
|
const password = body.password?.trim() || ''
|
||||||
const remember = body.remember !== false
|
const remember = body.remember !== false
|
||||||
|
|
||||||
if (!username || !password) {
|
assertBadRequest(username, 'Username and password are required')
|
||||||
throw createError({
|
assertBadRequest(password, 'Username and password are required')
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Username and password are required'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await getUserByUsername(username)
|
const user = await getUserByUsername(username)
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
throw createError({
|
httpError(401, 'Invalid username or password')
|
||||||
statusCode: 401,
|
|
||||||
statusMessage: 'Invalid username or password'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordMatches = await verifyPassword(password, user.passwordHash)
|
const passwordMatches = await verifyPassword(password, user.passwordHash)
|
||||||
|
|
||||||
if (!passwordMatches) {
|
if (!passwordMatches) {
|
||||||
throw createError({
|
httpError(401, 'Invalid username or password')
|
||||||
statusCode: 401,
|
|
||||||
statusMessage: 'Invalid username or password'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const authenticatedUser = await signInUser(event, user, remember)
|
const authenticatedUser = await signInUser(event, user, remember)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { verifyAuthenticationResponse, type AuthenticationResponseJSON } from '@simplewebauthn/server'
|
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 { 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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody<{
|
const body = await readBody<{
|
||||||
@@ -15,29 +17,19 @@ export default defineEventHandler(async (event) => {
|
|||||||
const challengeToken = body.challengeToken?.trim() || ''
|
const challengeToken = body.challengeToken?.trim() || ''
|
||||||
const remember = body.remember !== false
|
const remember = body.remember !== false
|
||||||
|
|
||||||
if (!response || !challengeToken) {
|
assertBadRequest(response, 'Passkey login payload is incomplete')
|
||||||
throw createError({
|
assertBadRequest(challengeToken, 'Passkey login payload is incomplete')
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Passkey login payload is incomplete'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectedChallenge = await consumeLoginChallenge(challengeToken)
|
const expectedChallenge = await consumeLoginChallenge(challengeToken)
|
||||||
|
|
||||||
if (!expectedChallenge) {
|
if (!expectedChallenge) {
|
||||||
throw createError({
|
httpError(400, 'Passkey login challenge expired. Try again.')
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Passkey login challenge expired. Try again.'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedCredential = await getCredentialForVerification(response.id)
|
const storedCredential = await getCredentialForVerification(response.id)
|
||||||
|
|
||||||
if (!storedCredential) {
|
if (!storedCredential) {
|
||||||
throw createError({
|
httpError(401, 'Passkey is not recognized')
|
||||||
statusCode: 401,
|
|
||||||
statusMessage: 'Passkey is not recognized'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = getWebAuthnConfig(event)
|
const config = getWebAuthnConfig(event)
|
||||||
@@ -50,19 +42,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!verification.verified) {
|
if (!verification.verified) {
|
||||||
throw createError({
|
httpError(401, 'Passkey authentication failed')
|
||||||
statusCode: 401,
|
|
||||||
statusMessage: 'Passkey authentication failed'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUserById(storedCredential.userId)
|
const user = await requireExistingUser(storedCredential.userId, 'User account is not available')
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user.isActive) {
|
||||||
throw createError({
|
httpError(401, 'User account is not available')
|
||||||
statusCode: 401,
|
|
||||||
statusMessage: 'User account is not available'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateCredentialCounter({
|
await updateCredentialCounter({
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { verifyRegistrationResponse, type RegistrationResponseJSON } from '@simplewebauthn/server'
|
import { verifyRegistrationResponse, type RegistrationResponseJSON } from '@simplewebauthn/server'
|
||||||
|
|
||||||
import { requireAuth } from '../../../../utils/auth'
|
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'
|
import { buildPasskeyLabel, consumeRegistrationChallenge, getWebAuthnConfig } from '../../../../utils/webauthn'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -10,20 +12,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
response?: RegistrationResponseJSON
|
response?: RegistrationResponseJSON
|
||||||
}>(event)
|
}>(event)
|
||||||
|
|
||||||
if (!body.response) {
|
assertBadRequest(body.response, 'Passkey registration response is required')
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Passkey registration response is required'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectedChallenge = await consumeRegistrationChallenge(auth.user.id)
|
const expectedChallenge = await consumeRegistrationChallenge(auth.user.id)
|
||||||
|
|
||||||
if (!expectedChallenge) {
|
if (!expectedChallenge) {
|
||||||
throw createError({
|
httpError(400, 'Passkey registration challenge expired. Try again.')
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Passkey registration challenge expired. Try again.'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = getWebAuthnConfig(event)
|
const config = getWebAuthnConfig(event)
|
||||||
@@ -35,10 +29,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!verification.verified || !verification.registrationInfo) {
|
if (!verification.verified || !verification.registrationInfo) {
|
||||||
throw createError({
|
httpError(400, 'Passkey registration could not be verified')
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Passkey registration could not be verified'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -52,18 +43,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
transports: body.response.response.transports || [],
|
transports: body.response.response.transports || [],
|
||||||
label: buildPasskeyLabel()
|
label: buildPasskeyLabel()
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
if (error?.code === '23505') {
|
mapDatabaseError(error, {
|
||||||
throw createError({
|
'23505': {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
statusMessage: 'This passkey is already registered'
|
statusMessage: 'This passkey is already registered'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error
|
const updatedUser = await requireExistingUser(auth.user.id, 'Unable to load updated user')
|
||||||
}
|
|
||||||
|
|
||||||
const updatedUser = await getUserById(auth.user.id)
|
|
||||||
const passkeys = await listUserPasskeys(auth.user.id)
|
const passkeys = await listUserPasskeys(auth.user.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import type { H3Event } from 'h3'
|
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 { createUserSession, destroyUserSession, getUserSession } from './session'
|
||||||
import { getUserById, updateLastLogin, type UserAuthRecord } from './user-repository'
|
import { getUserById, updateLastLogin, type UserAuthRecord } from './user-repository'
|
||||||
|
|
||||||
export function normalizeUsername(value: string) {
|
|
||||||
return value.trim().toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAuthContext(event: H3Event): Promise<{
|
export async function getAuthContext(event: H3Event): Promise<{
|
||||||
session: Awaited<ReturnType<typeof getUserSession>>
|
session: Awaited<ReturnType<typeof getUserSession>>
|
||||||
user: UserAuthRecord
|
user: UserAuthRecord
|
||||||
|
|||||||
46
server/utils/http.ts
Normal file
46
server/utils/http.ts
Normal file
@@ -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<Record<string, { statusCode: number, statusMessage: string }>>
|
||||||
|
): never {
|
||||||
|
const code = (error as { code?: string } | null)?.code
|
||||||
|
const handler = code ? handlers[code] : undefined
|
||||||
|
|
||||||
|
if (handler) {
|
||||||
|
httpError(handler.statusCode, handler.statusMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
@@ -56,11 +56,7 @@ export interface PasskeyRecord {
|
|||||||
lastUsedAt: string | null
|
lastUsedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCount(value: number | string): number {
|
function parseInteger(value: number | string): number {
|
||||||
return typeof value === 'number' ? value : Number.parseInt(value, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCounter(value: number | string): number {
|
|
||||||
return typeof value === 'number' ? value : Number.parseInt(value, 10)
|
return typeof value === 'number' ? value : Number.parseInt(value, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +74,7 @@ function parseTransports(value: AuthenticatorTransportFuture[] | string): Authen
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapAuthUser(row: DbUserRow): AuthUser {
|
function mapAuthUser(row: DbUserRow): AuthUser {
|
||||||
const passkeyCount = parseCount(row.passkey_count)
|
const passkeyCount = parseInteger(row.passkey_count)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -116,7 +112,7 @@ function mapPasskeyRecord(row: DbPasskeyRow): PasskeyRecord {
|
|||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
credentialId: row.credential_id,
|
credentialId: row.credential_id,
|
||||||
publicKey: row.public_key,
|
publicKey: row.public_key,
|
||||||
counter: parseCounter(row.counter),
|
counter: parseInteger(row.counter),
|
||||||
deviceType: row.device_type,
|
deviceType: row.device_type,
|
||||||
backedUp: row.backed_up,
|
backedUp: row.backed_up,
|
||||||
transports: parseTransports(row.transports),
|
transports: parseTransports(row.transports),
|
||||||
|
|||||||
75
server/utils/users.ts
Normal file
75
server/utils/users.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
export const DEFAULT_USER_PASSWORD = '123456'
|
export const DEFAULT_USER_PASSWORD = '123456'
|
||||||
export const MIN_PASSWORD_LENGTH = 8
|
export const MIN_PASSWORD_LENGTH = 8
|
||||||
|
export const MIN_FULL_NAME_LENGTH = 2
|
||||||
export const USERNAME_PATTERN = /^[a-z0-9._-]{3,32}$/
|
export const USERNAME_PATTERN = /^[a-z0-9._-]{3,32}$/
|
||||||
export const PHONE_NUMBER_PATTERN = /^\+?\d{8,15}$/
|
export const PHONE_NUMBER_PATTERN = /^\+?\d{8,15}$/
|
||||||
|
|
||||||
export type UserRole = 'super_admin' | 'staff'
|
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) {
|
export function normalizePhoneNumber(value: string) {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
|
|
||||||
@@ -22,6 +31,18 @@ export function isValidPhoneNumber(value: string) {
|
|||||||
return PHONE_NUMBER_PATTERN.test(normalizePhoneNumber(value))
|
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 {
|
export interface AuthUser {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
@@ -55,3 +76,19 @@ export interface PasskeySummary {
|
|||||||
deviceType: 'singleDevice' | 'multiDevice'
|
deviceType: 'singleDevice' | 'multiDevice'
|
||||||
backedUp: boolean
|
backedUp: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function needsUserOnboarding(
|
||||||
|
user: Pick<AuthUser, 'mustChangePassword' | 'needsPasskeySetup'> | null | undefined
|
||||||
|
) {
|
||||||
|
return Boolean(user && (user.mustChangePassword || user.needsPasskeySetup))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultAuthenticatedPath(
|
||||||
|
user: Pick<AuthUser, 'role' | 'mustChangePassword' | 'needsPasskeySetup'>
|
||||||
|
) {
|
||||||
|
if (needsUserOnboarding(user)) {
|
||||||
|
return '/security'
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.role === 'super_admin' ? '/management/users' : '/security'
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user