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:
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
Reference in New Issue
Block a user