refactor(portal): move components to shared/public-form and portal/{event,*}

- public-form/** (18 files + 7 component tests) → shared/public-form/**
  This is the runtime form-renderer; goes into shared/ because it will
  be reused by the organizer-app Form Builder preview (S3b).
- event/{Claimen,Informatie,Overzicht,Rooster}Tab.vue → portal/event/**
- portal/{StatusCard,EventCard,UserAvatarMenu}.vue → portal/** (no
  path change — both apps had a portal/ subfolder).
- AppLoadingIndicator.vue, auth/{PasswordRequirements,MfaChallengeCard}.vue,
  settings/Mfa{Disable,Email,Totp}SetupDialog.vue: portal copies
  deleted as duplicates of pre-existing apps/app components (diffs
  were trivial formatting only).

Inside the moved files: rewrote @form-schema/* → @/composables/forms/*
and @/components/{public-form,event/[Tab]} → new sub-zone paths.

Updated apps/app/tsconfig.json to drop the @form-schema path alias
and the packages/form-schema include path. Updated formSchema.ts to
import from @/composables/forms/types/formBuilder. Carried the
crypto polyfill from apps/portal/tests/setup.ts into
apps/app/tests/setup.ts (needed by useFormDraft tests landing in C.4).

NOTE: Some moved tests still fail because they reference portal
composables (usePublicFormSections, useFormDraft) that move in C.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 19:04:49 +02:00
parent 4cfcd5306a
commit 98ec51fcbd
50 changed files with 84 additions and 1153 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PublicFormSubmissionDuplicate } from '@form-schema/types/formBuilder' import type { PublicFormSubmissionDuplicate } from '@/composables/forms/types/formBuilder'
const props = defineProps<{ const props = defineProps<{
data: PublicFormSubmissionDuplicate | null data: PublicFormSubmissionDuplicate | null

View File

@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots' import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
import { usePublicFormToken } from '@/composables/publicFormInjection' import { usePublicFormToken } from '@/composables/publicFormInjection'
import type { PublicFormField, PublicFormTimeSlot } from '@form-schema/types/formBuilder' import type { PublicFormField, PublicFormTimeSlot } from '@/composables/forms/types/formBuilder'
import { getValidatorsForField, runValidators } from '@form-schema/utils/formValidation' import { getValidatorsForField, runValidators } from '@/composables/forms/utils/formValidation'
const props = defineProps<{ const props = defineProps<{
field: PublicFormField field: PublicFormField

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PublicFormField } from '@form-schema/types/formBuilder' import type { PublicFormField } from '@/composables/forms/types/formBuilder'
const props = defineProps<{ const props = defineProps<{
field: PublicFormField field: PublicFormField

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { resolveOptionLabel } from '@form-schema/types/formBuilder' import { resolveOptionLabel } from '@/composables/forms/types/formBuilder'
import type { OptionSpec, PublicFormField } from '@form-schema/types/formBuilder' import type { OptionSpec, PublicFormField } from '@/composables/forms/types/formBuilder'
import { getValidatorsForField, runValidators } from '@form-schema/utils/formValidation' import { getValidatorsForField, runValidators } from '@/composables/forms/utils/formValidation'
import { usePublicFormLocale } from '@/composables/publicFormInjection' import { usePublicFormLocale } from '@/composables/publicFormInjection'
const props = defineProps<{ const props = defineProps<{

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PublicFormField } from '@form-schema/types/formBuilder' import type { PublicFormField } from '@/composables/forms/types/formBuilder'
import { getValidatorsForField } from '@form-schema/utils/formValidation' import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
const props = defineProps<{ const props = defineProps<{
field: PublicFormField field: PublicFormField

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PublicFormField } from '@form-schema/types/formBuilder' import type { PublicFormField } from '@/composables/forms/types/formBuilder'
import { getValidatorsForField } from '@form-schema/utils/formValidation' import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
const props = defineProps<{ const props = defineProps<{
field: PublicFormField field: PublicFormField

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PublicFormField } from '@form-schema/types/formBuilder' import type { PublicFormField } from '@/composables/forms/types/formBuilder'
defineProps<{ defineProps<{
field: PublicFormField field: PublicFormField

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { resolveOptionLabel } from '@form-schema/types/formBuilder' import { resolveOptionLabel } from '@/composables/forms/types/formBuilder'
import type { OptionSpec, PublicFormField } from '@form-schema/types/formBuilder' import type { OptionSpec, PublicFormField } from '@/composables/forms/types/formBuilder'
import { getValidatorsForField } from '@form-schema/utils/formValidation' import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
import { usePublicFormLocale } from '@/composables/publicFormInjection' import { usePublicFormLocale } from '@/composables/publicFormInjection'
const props = defineProps<{ const props = defineProps<{

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PublicFormField } from '@form-schema/types/formBuilder' import type { PublicFormField } from '@/composables/forms/types/formBuilder'
import { getValidatorsForField } from '@form-schema/utils/formValidation' import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
const props = defineProps<{ const props = defineProps<{
field: PublicFormField field: PublicFormField

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PublicFormField } from '@form-schema/types/formBuilder' import type { PublicFormField } from '@/composables/forms/types/formBuilder'
defineProps<{ defineProps<{
field: PublicFormField field: PublicFormField

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PublicFormField } from '@form-schema/types/formBuilder' import type { PublicFormField } from '@/composables/forms/types/formBuilder'
import { getValidatorsForField } from '@form-schema/utils/formValidation' import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
const props = defineProps<{ const props = defineProps<{
field: PublicFormField field: PublicFormField

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { resolveOptionLabel } from '@form-schema/types/formBuilder' import { resolveOptionLabel } from '@/composables/forms/types/formBuilder'
import type { OptionSpec, PublicFormField } from '@form-schema/types/formBuilder' import type { OptionSpec, PublicFormField } from '@/composables/forms/types/formBuilder'
import { getValidatorsForField } from '@form-schema/utils/formValidation' import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
import { usePublicFormLocale } from '@/composables/publicFormInjection' import { usePublicFormLocale } from '@/composables/publicFormInjection'
const props = defineProps<{ const props = defineProps<{

View File

@@ -16,9 +16,9 @@ import FieldTagPicker from './FieldTagPicker.vue'
import FieldText from './FieldText.vue' import FieldText from './FieldText.vue'
import FieldTextarea from './FieldTextarea.vue' import FieldTextarea from './FieldTextarea.vue'
import FieldUrl from './FieldUrl.vue' import FieldUrl from './FieldUrl.vue'
import { evaluateConditionalLogic } from '@form-schema/composables/useConditionalLogic' import { evaluateConditionalLogic } from '@/composables/forms/composables/useConditionalLogic'
import { FormFieldType } from '@form-schema/types/formBuilder' import { FormFieldType } from '@/composables/forms/types/formBuilder'
import type { FormFieldDisplayWidth, FormValues, PublicFormField } from '@form-schema/types/formBuilder' import type { FormFieldDisplayWidth, FormValues, PublicFormField } from '@/composables/forms/types/formBuilder'
const props = defineProps<{ const props = defineProps<{
field: PublicFormField field: PublicFormField

View File

@@ -3,8 +3,8 @@ import draggable from 'vuedraggable'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { usePublicFormSections } from '@/composables/api/usePublicFormSections' import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
import { usePublicFormToken } from '@/composables/publicFormInjection' import { usePublicFormToken } from '@/composables/publicFormInjection'
import type { PublicFormField, PublicFormSectionOption, SectionPriorityValue } from '@form-schema/types/formBuilder' import type { PublicFormField, PublicFormSectionOption, SectionPriorityValue } from '@/composables/forms/types/formBuilder'
import { getValidatorsForField, runValidators } from '@form-schema/utils/formValidation' import { getValidatorsForField, runValidators } from '@/composables/forms/utils/formValidation'
const props = defineProps<{ const props = defineProps<{
field: PublicFormField field: PublicFormField

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { resolveOptionLabel } from '@form-schema/types/formBuilder' import { resolveOptionLabel } from '@/composables/forms/types/formBuilder'
import type { OptionSpec, PublicFormField } from '@form-schema/types/formBuilder' import type { OptionSpec, PublicFormField } from '@/composables/forms/types/formBuilder'
import { getValidatorsForField } from '@form-schema/utils/formValidation' import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
import { usePublicFormLocale } from '@/composables/publicFormInjection' import { usePublicFormLocale } from '@/composables/publicFormInjection'
const props = defineProps<{ const props = defineProps<{

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AvailableTag, PublicFormField } from '@form-schema/types/formBuilder' import type { AvailableTag, PublicFormField } from '@/composables/forms/types/formBuilder'
import { getValidatorsForField } from '@form-schema/utils/formValidation' import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
const props = defineProps<{ const props = defineProps<{
field: PublicFormField field: PublicFormField

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PublicFormField } from '@form-schema/types/formBuilder' import type { PublicFormField } from '@/composables/forms/types/formBuilder'
import { getValidatorsForField } from '@form-schema/utils/formValidation' import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
const props = defineProps<{ const props = defineProps<{
field: PublicFormField field: PublicFormField

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PublicFormField } from '@form-schema/types/formBuilder' import type { PublicFormField } from '@/composables/forms/types/formBuilder'
import { getValidatorsForField } from '@form-schema/utils/formValidation' import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
const props = defineProps<{ const props = defineProps<{
field: PublicFormField field: PublicFormField

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PublicFormField } from '@form-schema/types/formBuilder' import type { PublicFormField } from '@/composables/forms/types/formBuilder'
import { getValidatorsForField } from '@form-schema/utils/formValidation' import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
const props = defineProps<{ const props = defineProps<{
field: PublicFormField field: PublicFormField

View File

@@ -3,16 +3,16 @@ import DuplicateSubmissionHint from './DuplicateSubmissionHint.vue'
import IdentityMatchBanner from './IdentityMatchBanner.vue' import IdentityMatchBanner from './IdentityMatchBanner.vue'
import { usePublicFormSections } from '@/composables/api/usePublicFormSections' import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots' import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
import { formatFieldValue } from '@form-schema/composables/formatFieldValue' import { formatFieldValue } from '@/composables/forms/composables/formatFieldValue'
import type { FormStep } from '@form-schema/composables/useFormSteps' import type { FormStep } from '@/composables/forms/composables/useFormSteps'
import { usePublicFormToken } from '@/composables/publicFormInjection' import { usePublicFormToken } from '@/composables/publicFormInjection'
import { FormFieldType } from '@form-schema/types/formBuilder' import { FormFieldType } from '@/composables/forms/types/formBuilder'
import type { import type {
FormValues, FormValues,
PublicFormField, PublicFormField,
PublicFormSubmissionDuplicate, PublicFormSubmissionDuplicate,
PublicFormSubmissionIdentityMatch, PublicFormSubmissionIdentityMatch,
} from '@form-schema/types/formBuilder' } from '@/composables/forms/types/formBuilder'
const props = defineProps<{ const props = defineProps<{
steps: FormStep[] steps: FormStep[]

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FormErrorCode } from '@form-schema/types/formBuilder' import type { FormErrorCode } from '@/composables/forms/types/formBuilder'
const props = defineProps<{ const props = defineProps<{
errorCode?: FormErrorCode | string errorCode?: FormErrorCode | string

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import type { FormStep } from '@form-schema/composables/useFormSteps' import type { FormStep } from '@/composables/forms/composables/useFormSteps'
const props = defineProps<{ const props = defineProps<{
steps: FormStep[] steps: FormStep[]

View File

@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import DuplicateSubmissionHint from '@/components/public-form/DuplicateSubmissionHint.vue' import DuplicateSubmissionHint from '@/components/shared/public-form/DuplicateSubmissionHint.vue'
import type { PublicFormSubmissionDuplicate } from '@form-schema/types/formBuilder' import type { PublicFormSubmissionDuplicate } from '@/composables/forms/types/formBuilder'
function mountHint(data: PublicFormSubmissionDuplicate | null) { function mountHint(data: PublicFormSubmissionDuplicate | null) {
return mount(DuplicateSubmissionHint, { return mount(DuplicateSubmissionHint, {

View File

@@ -20,9 +20,9 @@ vi.mock('@/composables/publicFormInjection', () => ({
providePublicFormToken: () => {}, providePublicFormToken: () => {},
})) }))
import FieldAvailabilityPicker from '@/components/public-form/FieldAvailabilityPicker.vue' import FieldAvailabilityPicker from '@/components/shared/public-form/FieldAvailabilityPicker.vue'
import { FormFieldType } from '@form-schema/types/formBuilder' import { FormFieldType } from '@/composables/forms/types/formBuilder'
import type { PublicFormField, PublicFormTimeSlot } from '@form-schema/types/formBuilder' import type { PublicFormField, PublicFormTimeSlot } from '@/composables/forms/types/formBuilder'
function field(partial: Partial<PublicFormField> = {}): PublicFormField { function field(partial: Partial<PublicFormField> = {}): PublicFormField {
return { return {

View File

@@ -1,13 +1,13 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { computed, defineComponent, h, ref } from 'vue' import { computed, defineComponent, h, ref } from 'vue'
import FieldCheckboxList from '@/components/public-form/FieldCheckboxList.vue' import FieldCheckboxList from '@/components/shared/public-form/FieldCheckboxList.vue'
import FieldMultiselect from '@/components/public-form/FieldMultiselect.vue' import FieldMultiselect from '@/components/shared/public-form/FieldMultiselect.vue'
import FieldRadio from '@/components/public-form/FieldRadio.vue' import FieldRadio from '@/components/shared/public-form/FieldRadio.vue'
import FieldSelect from '@/components/public-form/FieldSelect.vue' import FieldSelect from '@/components/shared/public-form/FieldSelect.vue'
import { providePublicFormLocale } from '@/composables/publicFormInjection' import { providePublicFormLocale } from '@/composables/publicFormInjection'
import { FormFieldType } from '@form-schema/types/formBuilder' import { FormFieldType } from '@/composables/forms/types/formBuilder'
import type { OptionSpec, PublicFormField } from '@form-schema/types/formBuilder' import type { OptionSpec, PublicFormField } from '@/composables/forms/types/formBuilder'
const stubs = { const stubs = {
VRadioGroup: { name: 'VRadioGroup', template: '<div class="v-radio-group-stub"><slot/></div>' }, VRadioGroup: { name: 'VRadioGroup', template: '<div class="v-radio-group-stub"><slot/></div>' },

View File

@@ -1,8 +1,8 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import FieldRenderer from '@/components/public-form/FieldRenderer.vue' import FieldRenderer from '@/components/shared/public-form/FieldRenderer.vue'
import { FormFieldType } from '@form-schema/types/formBuilder' import { FormFieldType } from '@/composables/forms/types/formBuilder'
import type { PublicFormField } from '@form-schema/types/formBuilder' import type { PublicFormField } from '@/composables/forms/types/formBuilder'
function makeField(partial: Partial<PublicFormField>): PublicFormField { function makeField(partial: Partial<PublicFormField>): PublicFormField {
return { return {

View File

@@ -35,9 +35,9 @@ vi.mock('vuedraggable', () => ({
}, },
})) }))
import FieldSectionPriority from '@/components/public-form/FieldSectionPriority.vue' import FieldSectionPriority from '@/components/shared/public-form/FieldSectionPriority.vue'
import { FormFieldType } from '@form-schema/types/formBuilder' import { FormFieldType } from '@/composables/forms/types/formBuilder'
import type { PublicFormField, PublicFormSectionOption } from '@form-schema/types/formBuilder' import type { PublicFormField, PublicFormSectionOption } from '@/composables/forms/types/formBuilder'
function field(partial: Partial<PublicFormField> = {}): PublicFormField { function field(partial: Partial<PublicFormField> = {}): PublicFormField {
return { return {

View File

@@ -1,8 +1,8 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import FieldTagPicker from '@/components/public-form/FieldTagPicker.vue' import FieldTagPicker from '@/components/shared/public-form/FieldTagPicker.vue'
import { FormFieldType } from '@form-schema/types/formBuilder' import { FormFieldType } from '@/composables/forms/types/formBuilder'
import type { AvailableTag, PublicFormField } from '@form-schema/types/formBuilder' import type { AvailableTag, PublicFormField } from '@/composables/forms/types/formBuilder'
function field(partial: Partial<PublicFormField> = {}): PublicFormField { function field(partial: Partial<PublicFormField> = {}): PublicFormField {
return { return {

View File

@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import IdentityMatchBanner from '@/components/public-form/IdentityMatchBanner.vue' import IdentityMatchBanner from '@/components/shared/public-form/IdentityMatchBanner.vue'
function mountBanner(props: { status: 'pending' | 'matched' | 'none' | null; message?: string | null }) { function mountBanner(props: { status: 'pending' | 'matched' | 'none' | null; message?: string | null }) {
return mount(IdentityMatchBanner, { return mount(IdentityMatchBanner, {

View File

@@ -3,19 +3,19 @@
// the enums at api/app/Enums/FormBuilder/. // the enums at api/app/Enums/FormBuilder/.
// //
// Shared schema types (FormFieldType, FormFieldDisplayWidth, etc.) are // Shared schema types (FormFieldType, FormFieldDisplayWidth, etc.) are
// imported from @form-schema so portal and app stay in sync on the // imported from the inlined form-schema source at composables/forms so
// submit-side contract. Organizer-only types (lifecycle, payloads, // portal and app stay in sync on the submit-side contract. Organizer-only
// purpose/submission_mode enums) live here. // types (lifecycle, payloads, purpose/submission_mode enums) live here.
import type { import type {
ConditionalLogic, ConditionalLogic,
FormFieldDisplayWidth, FormFieldDisplayWidth,
FormFieldType, FormFieldType,
FormFieldValidationRules, FormFieldValidationRules,
} from '@form-schema/types/formBuilder' } from '@/composables/forms/types/formBuilder'
// Re-export shared field primitives so consumers of this module don't // Re-export shared field primitives so consumers of this module don't
// need to reach into @form-schema directly. // need to reach into the forms package directly.
export type { ConditionalLogic, FormFieldDisplayWidth, FormFieldType, FormFieldValidationRules } export type { ConditionalLogic, FormFieldDisplayWidth, FormFieldType, FormFieldValidationRules }
// Mirrors api/app/Enums/FormBuilder/FormPurpose.php // Mirrors api/app/Enums/FormBuilder/FormPurpose.php

View File

@@ -1,5 +1,17 @@
import { vi } from 'vitest' import { vi } from 'vitest'
// Deterministic idempotency-key generation for useFormDraft tests.
if (!globalThis.crypto) {
;(globalThis as { crypto: Crypto }).crypto = {
randomUUID: () => '00000000-0000-4000-8000-000000000000',
getRandomValues: (buf: Uint8Array) => {
for (let i = 0; i < buf.length; i++) buf[i] = 0
return buf
},
} as unknown as Crypto
}
// Default vue-router mock — individual tests can override with their own mock. // Default vue-router mock — individual tests can override with their own mock.
// Page-level tests that exercise the actual router should not import this. // Page-level tests that exercise the actual router should not import this.
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({

View File

@@ -40,9 +40,6 @@
"@validators": [ "@validators": [
"./src/@core/utils/validators" "./src/@core/utils/validators"
], ],
"@form-schema/*": [
"../../packages/form-schema/src/*"
],
"vue": [ "vue": [
"./node_modules/vue" "./node_modules/vue"
] ]
@@ -69,8 +66,7 @@
"./src/**/*.vue", "./src/**/*.vue",
"./themeConfig.ts", "./themeConfig.ts",
"./auto-imports.d.ts", "./auto-imports.d.ts",
"./components.d.ts", "./components.d.ts"
"../../packages/form-schema/src/**/*"
], ],
"exclude": [ "exclude": [
"./dist", "./dist",

View File

@@ -1,63 +0,0 @@
<script setup lang="ts">
const bufferValue = ref(20)
const progressValue = ref(10)
const isFallbackState = ref(false)
const interval = ref<ReturnType<typeof setInterval>>()
const showProgress = ref(false)
watch([progressValue, isFallbackState], () => {
if (progressValue.value > 80 && isFallbackState.value)
progressValue.value = 82
startBuffer()
})
function startBuffer() {
clearInterval(interval.value)
interval.value = setInterval(() => {
progressValue.value += Math.random() * (15 - 5) + 5
bufferValue.value += Math.random() * (15 - 5) + 6
}, 800)
}
const fallbackHandle = () => {
showProgress.value = true
progressValue.value = 10
isFallbackState.value = true
startBuffer()
}
const resolveHandle = () => {
isFallbackState.value = false
progressValue.value = 100
setTimeout(() => {
clearInterval(interval.value)
progressValue.value = 0
bufferValue.value = 20
showProgress.value = false
}, 300)
}
defineExpose({
fallbackHandle,
resolveHandle,
})
</script>
<template>
<!-- loading state via #fallback slot -->
<div
v-if="showProgress"
class="position-fixed"
style="z-index: 9999; inset-block-start: 0; inset-inline: 0 0;"
>
<VProgressLinear
v-model="progressValue"
:buffer-value="bufferValue"
color="primary"
height="2"
bg-color="background"
/>
</div>
</template>

View File

@@ -1,283 +0,0 @@
<script setup lang="ts">
import { useVerifyMfa, useSendMfaEmailCode } from '@/composables/api/useMfa'
import { generateDeviceFingerprint, getDeviceName } from '@/utils/deviceFingerprint'
import type { MfaMethod } from '@/types/mfa'
const props = defineProps<{
mfaSessionToken: string
methods: MfaMethod[]
preferredMethod: string
expiresIn: number
}>()
const emit = defineEmits<{
verified: [data: unknown]
cancelled: []
}>()
const verifyMutation = useVerifyMfa()
const sendEmailMutation = useSendMfaEmailCode()
const selectedMethod = ref<string>(props.preferredMethod)
const otpCode = ref('')
const backupCode = ref('')
const trustDevice = ref(false)
const errorMessage = ref('')
const timeLeft = ref(props.expiresIn)
const isBackupMethod = computed(() => selectedMethod.value === 'backup_code')
// Countdown timer
const countdownInterval = setInterval(() => {
timeLeft.value--
if (timeLeft.value <= 0) {
clearInterval(countdownInterval)
errorMessage.value = 'MFA-sessie verlopen. Log opnieuw in.'
}
}, 1000)
onUnmounted(() => {
clearInterval(countdownInterval)
})
const formattedTimeLeft = computed(() => {
const minutes = Math.floor(timeLeft.value / 60)
const seconds = timeLeft.value % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
})
const methodTabs = computed(() => {
const tabs: Array<{ value: string; title: string; icon: string }> = []
if (props.methods.includes('totp' as MfaMethod))
tabs.push({ value: 'totp', title: 'Authenticator', icon: 'tabler-device-mobile' })
if (props.methods.includes('email' as MfaMethod))
tabs.push({ value: 'email', title: 'E-mailcode', icon: 'tabler-mail' })
if (props.methods.includes('backup_code' as MfaMethod))
tabs.push({ value: 'backup_code', title: 'Backup code', icon: 'tabler-key' })
return tabs
})
function onMethodChange(method: string) {
errorMessage.value = ''
otpCode.value = ''
backupCode.value = ''
if (method === 'email')
handleSendEmailCode()
}
async function handleSendEmailCode() {
try {
await sendEmailMutation.mutateAsync(props.mfaSessionToken)
}
catch {
// Rate limited or other error — user can try resend link
}
}
function onOtpFinish(code: string) {
otpCode.value = code
handleVerify()
}
async function handleVerify() {
errorMessage.value = ''
const code = isBackupMethod.value ? backupCode.value : otpCode.value
if (!code) return
try {
const data = await verifyMutation.mutateAsync({
mfa_session_token: props.mfaSessionToken,
code,
method: selectedMethod.value as MfaMethod,
trust_device: trustDevice.value || undefined,
device_fingerprint: trustDevice.value ? generateDeviceFingerprint() : undefined,
device_name: trustDevice.value ? getDeviceName() : undefined,
})
emit('verified', data)
}
catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } } }
errorMessage.value = ax.response?.data?.message ?? 'Verificatie mislukt. Probeer het opnieuw.'
otpCode.value = ''
backupCode.value = ''
}
}
</script>
<template>
<VCard
class="auth-card"
max-width="460"
:class="$vuetify.display.smAndUp ? 'pa-6' : 'pa-0'"
>
<VCardText>
<h4 class="text-h4 mb-1">
Tweestapsverificatie
</h4>
<p class="mb-0">
Voer je verificatiecode in om in te loggen
</p>
<VChip
v-if="timeLeft > 0"
size="small"
color="secondary"
variant="tonal"
class="mt-2"
>
<VIcon
start
icon="tabler-clock"
size="14"
/>
{{ formattedTimeLeft }}
</VChip>
</VCardText>
<VCardText>
<!-- Method tabs -->
<VTabs
v-if="methodTabs.length > 1"
v-model="selectedMethod"
class="mb-4"
density="comfortable"
@update:model-value="(v: unknown) => onMethodChange(String(v))"
>
<VTab
v-for="tab in methodTabs"
:key="tab.value"
:value="tab.value"
>
<VIcon
:icon="tab.icon"
size="20"
class="me-1"
/>
{{ tab.title }}
</VTab>
</VTabs>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ errorMessage }}
</VAlert>
<VForm @submit.prevent="handleVerify">
<VRow>
<!-- OTP input for TOTP and email methods -->
<VCol
v-if="!isBackupMethod"
cols="12"
>
<p class="text-body-2 mb-2">
{{
selectedMethod === 'totp'
? 'Voer de 6-cijferige code in uit je authenticator app'
: 'Voer de 6-cijferige code in die naar je e-mailadres is gestuurd'
}}
</p>
<VOtpInput
v-model="otpCode"
:disabled="verifyMutation.isPending.value || timeLeft <= 0"
type="number"
class="pa-0"
@finish="onOtpFinish"
/>
</VCol>
<!-- Backup code input -->
<VCol
v-else
cols="12"
>
<p class="text-body-2 mb-2">
Voer een van je backup codes in (formaat: XXXX-XXXX)
</p>
<AppTextField
v-model="backupCode"
placeholder="XXXX-XXXX"
autofocus
class="text-center"
/>
</VCol>
<!-- Trust device -->
<VCol cols="12">
<VCheckbox
v-model="trustDevice"
label="Onthoud dit apparaat voor 30 dagen"
density="compact"
/>
</VCol>
<!-- Verify button -->
<VCol cols="12">
<VBtn
block
type="submit"
:loading="verifyMutation.isPending.value"
:disabled="timeLeft <= 0"
>
Verifi&euml;ren
</VBtn>
</VCol>
<!-- Resend email code -->
<VCol
v-if="selectedMethod === 'email'"
cols="12"
class="text-center"
>
<div class="d-flex justify-center align-center flex-wrap">
<span class="me-1 text-body-2">Geen code ontvangen?</span>
<a
class="text-primary text-body-2"
href="#"
@click.prevent="handleSendEmailCode"
>
Opnieuw versturen
</a>
</div>
</VCol>
<!-- Back to login -->
<VCol cols="12">
<a
class="d-flex align-center justify-center text-body-2"
href="#"
@click.prevent="emit('cancelled')"
>
<VIcon
icon="tabler-chevron-left"
size="20"
class="me-1 flip-in-rtl"
/>
<span>Terug naar inloggen</span>
</a>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</template>
<style lang="scss">
.v-otp-input {
.v-otp-input__content {
padding-inline: 0;
}
}
</style>

View File

@@ -1,31 +0,0 @@
<script setup lang="ts">
const props = defineProps<{ password: string }>()
const requirements = computed(() => [
{ label: 'Minimaal 8 tekens', met: props.password.length >= 8 },
{ label: 'Een hoofdletter', met: /[A-Z]/.test(props.password) },
{ label: 'Een kleine letter', met: /[a-z]/.test(props.password) },
{ label: 'Een cijfer', met: /[0-9]/.test(props.password) },
])
const allMet = computed(() => requirements.value.every(r => r.met))
defineExpose({ allMet })
</script>
<template>
<div class="text-body-2 mt-2">
<div
v-for="req in requirements"
:key="req.label"
:class="req.met ? 'text-success' : 'text-medium-emphasis'"
class="d-flex align-center gap-1 mb-1"
>
<VIcon
:icon="req.met ? 'tabler-check' : 'tabler-x'"
size="14"
/>
<span>{{ req.label }}</span>
</div>
</div>
</template>

View File

@@ -1,115 +0,0 @@
<script setup lang="ts">
import { useDisableMfa } from '@/composables/api/useMfa'
const props = defineProps<{
modelValue: boolean
currentMethod: 'totp' | 'email' | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
disabled: []
}>()
const disableMutation = useDisableMfa()
const code = ref('')
const method = ref<string>(props.currentMethod === 'totp' ? 'totp' : 'backup_code')
const errorMessage = ref('')
const isDialogOpen = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val),
})
watch(isDialogOpen, (open) => {
if (open) {
code.value = ''
errorMessage.value = ''
method.value = props.currentMethod === 'totp' ? 'totp' : 'backup_code'
}
})
async function handleDisable() {
errorMessage.value = ''
try {
await disableMutation.mutateAsync({ code: code.value, method: method.value })
isDialogOpen.value = false
emit('disabled')
}
catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } } }
errorMessage.value = ax.response?.data?.message ?? 'Kon MFA niet uitschakelen. Probeer het opnieuw.'
code.value = ''
}
}
</script>
<template>
<VDialog
v-model="isDialogOpen"
max-width="460"
>
<VCard>
<VCardTitle class="d-flex align-center pt-4">
<VIcon
icon="tabler-shield-off"
color="error"
class="me-2"
/>
Tweestapsverificatie uitschakelen
</VCardTitle>
<VCardText>
<VAlert
type="warning"
variant="tonal"
class="mb-4"
>
Weet je zeker dat je tweestapsverificatie wilt uitschakelen? Je account wordt minder veilig.
</VAlert>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ errorMessage }}
</VAlert>
<VForm @submit.prevent="handleDisable">
<p class="text-body-2 mb-2">
Voer je {{ currentMethod === 'totp' ? 'authenticator code' : 'backup code' }} in ter bevestiging
</p>
<AppTextField
v-model="code"
:placeholder="currentMethod === 'totp' ? '123456' : 'XXXX-XXXX'"
autofocus
/>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="isDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="disableMutation.isPending.value"
:disabled="!code"
@click="handleDisable"
>
Uitschakelen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -1,276 +0,0 @@
<script setup lang="ts">
import { useSetupEmail, useConfirmEmail } from '@/composables/api/useMfa'
const props = defineProps<{
modelValue: boolean
userEmail: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
completed: []
}>()
const setupMutation = useSetupEmail()
const confirmMutation = useConfirmEmail()
const step = ref(1)
const confirmCode = ref('')
const backupCodes = ref<string[]>([])
const errorMessage = ref('')
const savedBackupCodes = ref(false)
const codeSent = ref(false)
const isDialogOpen = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val),
})
watch(isDialogOpen, (open) => {
if (open) {
step.value = 1
errorMessage.value = ''
confirmCode.value = ''
backupCodes.value = []
savedBackupCodes.value = false
codeSent.value = false
}
})
async function handleSendCode() {
errorMessage.value = ''
try {
await setupMutation.mutateAsync()
codeSent.value = true
step.value = 2
}
catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } } }
errorMessage.value = ax.response?.data?.message ?? 'Kon geen code versturen. Probeer het opnieuw.'
}
}
async function handleConfirm() {
errorMessage.value = ''
try {
const data = await confirmMutation.mutateAsync(confirmCode.value)
backupCodes.value = data.backup_codes
step.value = 3
}
catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } } }
errorMessage.value = ax.response?.data?.message ?? 'Ongeldige code. Probeer het opnieuw.'
confirmCode.value = ''
}
}
function copyBackupCodes() {
const text = backupCodes.value.join('\n')
navigator.clipboard.writeText(text)
}
function downloadBackupCodes() {
const text = `Crewli MFA Backup Codes\n${'='.repeat(30)}\n\n${backupCodes.value.join('\n')}\n\nBewaar deze codes op een veilige plek.\nElke code kan slechts eenmaal worden gebruikt.`
const blob = new Blob([text], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'crewli-backup-codes.txt'
a.click()
URL.revokeObjectURL(url)
}
function handleComplete() {
isDialogOpen.value = false
emit('completed')
}
</script>
<template>
<VDialog
v-model="isDialogOpen"
max-width="520"
persistent
>
<VCard>
<VCardTitle class="d-flex align-center pt-4">
<VIcon
icon="tabler-mail-code"
class="me-2"
/>
E-mailverificatie instellen
</VCardTitle>
<!-- Step 1: Send code -->
<template v-if="step === 1">
<VCardText>
<p class="text-body-1 mb-4">
We sturen een verificatiecode naar <strong>{{ userEmail }}</strong>
</p>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
>
{{ errorMessage }}
</VAlert>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="isDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="setupMutation.isPending.value"
@click="handleSendCode"
>
Code versturen
</VBtn>
</VCardActions>
</template>
<!-- Step 2: Verify code -->
<template v-if="step === 2">
<VCardText>
<VAlert
type="success"
variant="tonal"
class="mb-4"
density="comfortable"
>
Code verstuurd! Check je inbox.
</VAlert>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ errorMessage }}
</VAlert>
<p class="text-body-2 mb-2">
Voer de 6-cijferige code in
</p>
<VOtpInput
v-model="confirmCode"
type="number"
class="pa-0"
:disabled="confirmMutation.isPending.value"
@finish="handleConfirm"
/>
<div class="text-center mt-4">
<a
class="text-primary text-body-2"
href="#"
@click.prevent="handleSendCode"
>
Code opnieuw versturen
</a>
</div>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="step = 1"
>
Terug
</VBtn>
<VBtn
color="primary"
:loading="confirmMutation.isPending.value"
:disabled="confirmCode.length < 6"
@click="handleConfirm"
>
Verifi&euml;ren
</VBtn>
</VCardActions>
</template>
<!-- Step 3: Backup codes (same as TOTP) -->
<template v-if="step === 3">
<VCardText>
<VAlert
type="warning"
variant="tonal"
class="mb-4"
>
Bewaar deze codes op een veilige plek. Je kunt ze gebruiken als je geen toegang hebt tot je e-mail.
</VAlert>
<div class="d-flex flex-wrap gap-2 mb-4">
<VChip
v-for="code in backupCodes"
:key="code"
variant="tonal"
label
class="font-weight-bold text-body-1"
style="font-family: monospace;"
>
{{ code }}
</VChip>
</div>
<div class="d-flex gap-2 mb-4">
<VBtn
variant="tonal"
size="small"
prepend-icon="tabler-copy"
@click="copyBackupCodes"
>
Kopieer
</VBtn>
<VBtn
variant="tonal"
size="small"
prepend-icon="tabler-download"
@click="downloadBackupCodes"
>
Download
</VBtn>
</div>
<VCheckbox
v-model="savedBackupCodes"
label="Ik heb mijn backup codes veilig opgeslagen"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
color="primary"
:disabled="!savedBackupCodes"
@click="handleComplete"
>
Voltooien
</VBtn>
</VCardActions>
</template>
</VCard>
</VDialog>
</template>
<style lang="scss">
.v-otp-input .v-otp-input__content {
padding-inline: 0;
}
</style>

View File

@@ -1,291 +0,0 @@
<script setup lang="ts">
import QRCode from 'qrcode'
import { useSetupTotp, useConfirmTotp } from '@/composables/api/useMfa'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
completed: []
}>()
const setupMutation = useSetupTotp()
const confirmMutation = useConfirmTotp()
const step = ref(1)
const qrDataUrl = ref('')
const secret = ref('')
const confirmCode = ref('')
const backupCodes = ref<string[]>([])
const errorMessage = ref('')
const savedBackupCodes = ref(false)
const isDialogOpen = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val),
})
watch(isDialogOpen, async (open) => {
if (open) {
step.value = 1
errorMessage.value = ''
confirmCode.value = ''
backupCodes.value = []
savedBackupCodes.value = false
await startSetup()
}
})
async function startSetup() {
try {
const data = await setupMutation.mutateAsync()
secret.value = data.secret
qrDataUrl.value = await QRCode.toDataURL(data.provisioning_uri, {
width: 256,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' },
})
}
catch {
errorMessage.value = 'Kon TOTP setup niet starten. Probeer het opnieuw.'
}
}
async function handleConfirm() {
errorMessage.value = ''
try {
const data = await confirmMutation.mutateAsync(confirmCode.value)
backupCodes.value = data.backup_codes
step.value = 3
}
catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } } }
errorMessage.value = ax.response?.data?.message ?? 'Ongeldige code. Probeer het opnieuw.'
confirmCode.value = ''
}
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text)
}
function copyBackupCodes() {
const text = backupCodes.value.join('\n')
navigator.clipboard.writeText(text)
}
function downloadBackupCodes() {
const text = `Crewli MFA Backup Codes\n${'='.repeat(30)}\n\n${backupCodes.value.join('\n')}\n\nBewaar deze codes op een veilige plek.\nElke code kan slechts eenmaal worden gebruikt.`
const blob = new Blob([text], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'crewli-backup-codes.txt'
a.click()
URL.revokeObjectURL(url)
}
function handleComplete() {
isDialogOpen.value = false
emit('completed')
}
</script>
<template>
<VDialog
v-model="isDialogOpen"
max-width="520"
persistent
>
<VCard>
<VCardTitle class="d-flex align-center pt-4">
<VIcon
icon="tabler-shield-lock"
class="me-2"
/>
Authenticator app instellen
</VCardTitle>
<!-- Step 1: QR Code -->
<template v-if="step === 1">
<VCardText>
<p class="text-body-1 mb-4">
Scan de QR-code met je authenticator app (Google Authenticator, Authy, etc.)
</p>
<div
v-if="qrDataUrl"
class="d-flex justify-center mb-4"
>
<img
:src="qrDataUrl"
alt="QR Code"
width="256"
height="256"
style="border-radius: 8px;"
>
</div>
<VAlert
v-if="setupMutation.isPending.value"
type="info"
variant="tonal"
class="mb-4"
>
QR-code wordt gegenereerd...
</VAlert>
<VExpansionPanels variant="accordion">
<VExpansionPanel title="Kun je niet scannen? Voer deze code handmatig in:">
<VExpansionPanelText>
<AppTextField
:model-value="secret"
readonly
class="font-weight-bold"
append-inner-icon="tabler-copy"
@click:append-inner="() => copyToClipboard(secret)"
/>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="isDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:disabled="!qrDataUrl"
@click="step = 2"
>
Volgende
</VBtn>
</VCardActions>
</template>
<!-- Step 2: Verify code -->
<template v-if="step === 2">
<VCardText>
<p class="text-body-1 mb-4">
Open je authenticator app en voer de 6-cijferige code in
</p>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ errorMessage }}
</VAlert>
<VOtpInput
v-model="confirmCode"
type="number"
class="pa-0"
:disabled="confirmMutation.isPending.value"
@finish="handleConfirm"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="step = 1"
>
Terug
</VBtn>
<VBtn
color="primary"
:loading="confirmMutation.isPending.value"
:disabled="confirmCode.length < 6"
@click="handleConfirm"
>
Verifi&euml;ren
</VBtn>
</VCardActions>
</template>
<!-- Step 3: Backup codes -->
<template v-if="step === 3">
<VCardText>
<VAlert
type="warning"
variant="tonal"
class="mb-4"
>
Bewaar deze codes op een veilige plek. Je kunt ze gebruiken als je geen toegang hebt tot je authenticator app.
</VAlert>
<div class="d-flex flex-wrap gap-2 mb-4">
<VChip
v-for="code in backupCodes"
:key="code"
variant="tonal"
label
class="font-weight-bold text-body-1"
style="font-family: monospace;"
>
{{ code }}
</VChip>
</div>
<div class="d-flex gap-2 mb-4">
<VBtn
variant="tonal"
size="small"
prepend-icon="tabler-copy"
@click="copyBackupCodes"
>
Kopieer
</VBtn>
<VBtn
variant="tonal"
size="small"
prepend-icon="tabler-download"
@click="downloadBackupCodes"
>
Download
</VBtn>
</div>
<VCheckbox
v-model="savedBackupCodes"
label="Ik heb mijn backup codes veilig opgeslagen"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
color="primary"
:disabled="!savedBackupCodes"
@click="handleComplete"
>
Voltooien
</VBtn>
</VCardActions>
</template>
</VCard>
</VDialog>
</template>
<style lang="scss">
.v-otp-input .v-otp-input__content {
padding-inline: 0;
}
</style>

View File

@@ -1,18 +0,0 @@
import { vi } from 'vitest'
// Deterministic idempotency-key generation for useFormDraft tests.
if (!globalThis.crypto) {
;(globalThis as { crypto: Crypto }).crypto = {
randomUUID: () => '00000000-0000-4000-8000-000000000000',
getRandomValues: (buf: Uint8Array) => {
for (let i = 0; i < buf.length; i++) buf[i] = 0
return buf
},
} as unknown as Crypto
}
// Suppress Vue-router usage in isolated composable tests.
vi.mock('vue-router', () => ({
useRoute: () => ({ params: {}, query: {} }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))