refactor(portal): move composables, types, schemas; drop duplicates

Composables (apps/portal/src/composables → apps/app/src/composables/):
  - useFormDraft, publicFormInjection → composables/ (root, used by
    shared/public-form components)
  - api/usePublicForm, api/usePublicFormSections,
    api/usePublicFormTimeSlots → composables/api/ (no collisions)
  - api/usePortalShifts, api/usePortalProfile, api/useVolunteerRegistration
    → composables/api/portal/ (subfolder per WS-3 PR-B1 charter to
    leave room for organizer-side namesakes without clashes)
  - api/useMfa → DELETED (apps/app version is a strict superset
    with extra invalidateQueries calls and the admin-reset mutation)

Types (apps/portal/src/types → apps/app/src/types/):
  - api, portal-shift, portal, registration → moved
  - mfa → DELETED (byte-identical to apps/app/src/types/mfa.ts)

Schemas:
  - apps/portal/src/schemas/registrationSchema.ts → apps/app/src/schemas/

Utils:
  - deviceFingerprint, paginationMeta → DELETED (byte-identical
    duplicates already in apps/app/src/utils/)

Lib:
  - apps/portal/src/lib/{axios,query-client}.ts → DELETED. apps/app's
    callback-bound axios (post-PR-A) and query-client are the
    canonical versions. Portal pages currently importing
    `@/lib/axios#apiClient` resolve to apps/app's apiClient with no
    behavioral change for cookie-based requests.

Tests: 4 composable specs (useFormDraft x2, usePublicFormSections,
usePublicFormTimeSlots) moved into __tests__/ subdirs alongside
their composables.

@form-schema imports inside the moved files rewritten to
@/composables/forms/*.

Vitest now: 23 files / 162 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 19:08:53 +02:00
parent 4fe1a0c517
commit 7282861a7e
25 changed files with 0 additions and 373 deletions

View File

@@ -1,188 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { apiClient } from '@/lib/axios'
import type {
MfaConfirmResponse,
MfaStatus,
MfaTotpSetup,
MfaVerifyPayload,
TrustedDevice,
} from '@/types/mfa'
interface ApiResponse<T> {
success: boolean
data: T
message: string
}
// ─── Setup ───
export function useSetupTotp() {
return useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<ApiResponse<MfaTotpSetup>>('/auth/mfa/setup/totp')
return data.data
},
})
}
export function useConfirmTotp() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (code: string) => {
const { data } = await apiClient.post<ApiResponse<MfaConfirmResponse>>('/auth/mfa/setup/totp/confirm', { code })
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
},
})
}
export function useSetupEmail() {
return useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<ApiResponse<null>>('/auth/mfa/setup/email')
return data
},
})
}
export function useConfirmEmail() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (code: string) => {
const { data } = await apiClient.post<ApiResponse<MfaConfirmResponse>>('/auth/mfa/setup/email/confirm', { code })
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
},
})
}
// ─── Management ───
export function useDisableMfa() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: { code: string; method: string }) => {
const { data } = await apiClient.post<ApiResponse<null>>('/auth/mfa/disable', payload)
return data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
},
})
}
export function useRegenerateBackupCodes() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: { code: string }) => {
const { data } = await apiClient.post<ApiResponse<{ backup_codes: string[] }>>('/auth/mfa/backup-codes', payload)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
},
})
}
export function useMfaStatus() {
return useQuery({
queryKey: ['mfa-status'],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<MfaStatus>>('/auth/mfa/status')
return data.data
},
})
}
export function useSetPreferredMethod() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (method: 'totp' | 'email') => {
const { data } = await apiClient.put<ApiResponse<{ method: string }>>('/auth/mfa/preferred-method', { method })
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
},
})
}
// ─── Trusted devices ───
export function useTrustedDevices() {
return useQuery({
queryKey: ['trusted-devices'],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<TrustedDevice[]>>('/auth/trusted-devices')
return data.data
},
})
}
export function useRevokeDevice() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/auth/trusted-devices/${id}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trusted-devices'] })
},
})
}
export function useRevokeAllDevices() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () => {
await apiClient.delete('/auth/trusted-devices')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trusted-devices'] })
},
})
}
// ─── Login flow (no auth needed — uses session token) ───
export function useVerifyMfa() {
return useMutation({
mutationFn: async (payload: MfaVerifyPayload) => {
const { data } = await apiClient.post('/auth/mfa/verify', payload)
return data
},
})
}
export function useSendMfaEmailCode() {
return useMutation({
mutationFn: async (mfaSessionToken: string) => {
const { data } = await apiClient.post('/auth/mfa/email/send', {
mfa_session_token: mfaSessionToken,
})
return data
},
})
}

View File

@@ -1,55 +0,0 @@
import axios from 'axios'
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
timeout: 30000,
})
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
if (import.meta.env.DEV) {
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
}
return config
},
error => Promise.reject(error),
)
apiClient.interceptors.response.use(
response => {
if (import.meta.env.DEV) {
console.log(`${response.status} ${response.config.url}`, response.data)
}
return response
},
error => {
if (import.meta.env.DEV) {
console.error(
`${error.response?.status} ${error.config?.url}`,
error.response?.data,
)
}
if (error.response?.status === 401) {
// Lazy import to avoid circular dependency
import('@/stores/useAuthStore').then(({ useAuthStore }) => {
const authStore = useAuthStore()
if (authStore.isInitialized) {
authStore.handleUnauthorized()
}
})
}
return Promise.reject(error)
},
)
export { apiClient }

View File

@@ -1,12 +0,0 @@
import type { VueQueryPluginOptions } from '@tanstack/vue-query'
export const queryClientConfig: VueQueryPluginOptions = {
queryClientConfig: {
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
},
}

View File

@@ -1,63 +0,0 @@
export const MfaMethod = {
TOTP: 'totp',
EMAIL: 'email',
BACKUP_CODE: 'backup_code',
} as const
export type MfaMethod = typeof MfaMethod[keyof typeof MfaMethod]
export interface MfaStatus {
enabled: boolean
method: 'totp' | 'email' | null
confirmed_at: string | null
backup_codes_remaining: number
is_required: boolean
totp_configured: boolean
email_configured: boolean
}
export interface MfaUserStatus {
enabled: boolean
method: 'totp' | 'email' | null
confirmed_at: string | null
setup_required: boolean
}
export interface MfaTotpSetup {
secret: string
qr_code_url: string
provisioning_uri: string
}
export interface MfaSessionResponse {
success: true
mfa_required: true
mfa_session_token: string
methods: MfaMethod[]
preferred_method: 'totp' | 'email'
expires_in: number
}
export interface MfaConfirmResponse {
mfa_enabled: boolean
method: 'totp' | 'email'
backup_codes: string[]
}
export interface TrustedDevice {
id: string
device_name: string | null
ip_address: string
trusted_until: string
last_used_at: string | null
created_at: string
}
export interface MfaVerifyPayload {
mfa_session_token: string
code: string
method: MfaMethod
trust_device?: boolean
device_fingerprint?: string
device_name?: string
}

View File

@@ -1,49 +0,0 @@
export function generateDeviceFingerprint(): string {
const components = [
navigator.userAgent,
`${screen.width}x${screen.height}`,
Intl.DateTimeFormat().resolvedOptions().timeZone,
navigator.language,
navigator.hardwareConcurrency?.toString() ?? '',
]
const str = components.join('|')
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash
}
return Math.abs(hash).toString(36)
}
export function getDeviceName(): string {
const ua = navigator.userAgent
let browser = 'Onbekend'
let os = 'Onbekend'
if (ua.includes('Chrome') && !ua.includes('Edg'))
browser = 'Chrome'
else if (ua.includes('Firefox'))
browser = 'Firefox'
else if (ua.includes('Safari') && !ua.includes('Chrome'))
browser = 'Safari'
else if (ua.includes('Edg'))
browser = 'Edge'
if (ua.includes('Mac OS'))
os = 'macOS'
else if (ua.includes('Windows'))
os = 'Windows'
else if (ua.includes('Linux'))
os = 'Linux'
else if (ua.includes('Android'))
os = 'Android'
else if (ua.includes('iPhone') || ua.includes('iPad'))
os = 'iOS'
return `${browser} op ${os}`
}

View File

@@ -1,6 +0,0 @@
export const paginationMeta = <T extends { page: number; itemsPerPage: number }>(options: T, total: number) => {
const start = (options.page - 1) * options.itemsPerPage + 1
const end = Math.min(options.page * options.itemsPerPage, total)
return `Showing ${total === 0 ? 0 : start} to ${end} of ${total} entries`
}