Mirrors apps/portal's Vitest setup so the SPA can take frontend
unit + component tests. Required prerequisite for WS-6 sessie 3b's
admin UI work — apps/portal had 113+ tests, apps/app had zero, and
launching WS-6's organizer UI uncovered while the portal SPA is
well-tested would be asymmetric quality.
Setup:
- vitest, happy-dom, @vue/test-utils, @testing-library/vue installed
- vitest.config.ts mirrors portal config: trimmed auto-imports
(no pinia/vue-router/vue-i18n/@vueuse/math) so tests run fast
in happy-dom without loading the full Vuexy bundle
- AutoImport's dts:false prevents the trimmed test-only set from
clobbering the dev-server's full auto-imports.d.ts (apps/app's
auto-import surface is bigger than the portal's)
- tests/setup.ts mocks vue-router by default; tests that exercise
the real router can override per-suite
- Sample sanity test confirms the harness works end-to-end
Adds `pnpm test` and `pnpm test:watch` scripts to package.json.
Refs: BACKLOG TECH-APP-VITEST, WS-6 sessie 3b prerequisite
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Installs @total-typescript/ts-reset 0.6.1 as a dev-dependency in
apps/portal/ and apps/app/. Patches TypeScript's loosest default
types: Array.filter(Boolean) returns non-nullable, JSON.parse
returns unknown, fetch().json() returns unknown, Map.get() strict,
etc.
Configuration: src/reset.d.ts in each SPA imports the reset. Both
tsconfig.json files already include ./src/**/* so the .d.ts is
picked up automatically — no tsconfig edits needed.
Issues surfaced during install:
- apps/app — 0 pre-install tsc errors in own code; install
surfaced 2 errors in src/stores/useImpersonationStore.ts
(both from JSON.parse on sessionStorage content returning
unknown instead of any). Fixed inline at lines 19 + 123 via
`as ImpersonationState` casts that make the existing
trust-in-sessionStorage explicit. Backlog entry
TECH-TS-IMPERSONATION tracks proper runtime shape validation.
- apps/portal — 22 pre-existing tsc errors in own code (mostly
tiptap editor components — tracked as TECH-TS-PORTAL-TSC,
unrelated to ts-reset). Zero new errors in portal's own code.
4 additional errors surfaced in tiptap's uncompiled node_modules
.ts sources (third-party); left as-is.
Neither SPA achieves `tsc --noEmit` clean today — pre-existing
state unrelated to this work package. Build + vitest are the
actual working gates and both remain green:
- apps/portal: vitest 113/113 passing; production build succeeds
- apps/app: (no vitest setup — tracked as TECH-APP-VITEST);
production build succeeds
Documentation: /dev-docs/FRONTEND-TOOLING.md added; CLAUDE.md
quality-gates updated.
Backlog: TECH-TS-IMPERSONATION (runtime validation of stored
impersonation state), TECH-TS-PORTAL-TSC (pre-existing portal tsc
errors), TECH-APP-VITEST (Vitest coverage for apps/app).
No production behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds apps/app/src/types/formSchema.ts with FormSchema, FormSchemaSummary,
FormSchemaPurpose, FormSubmissionMode, FormSchemaSnapshotMode, and the
payload/response shapes for schema CRUD plus lifecycle operations
(publish, unpublish, duplicate, rotate-public-token).
Adds apps/app/src/composables/api/useFormSchemas.ts mirroring the
useSections pattern: useFormSchemaList, useFormSchema, plus seven
mutations covering CRUD, duplicate, publish/unpublish and public-token
rotation. All queries and mutations invalidate the right cache keys.
Fields and sections on the full FormSchema are typed as unknown[] with
a TODO pointing to PR-b3 when the organizer field types land. No UI,
routes, or navigation — those come in PR-b2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves formBuilder types, formValidation, useConditionalLogic, useFormSteps,
and formatFieldValue from apps/portal/src to packages/form-schema/src.
Adds @form-schema path alias to both apps/portal and apps/app.
Vue field components remain per-app to allow independent visual evolution.
Behavior-neutral: all 35 Vitest tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add OrganisationMember (met avatar + joined_at), ActivityLogEntry en
OrganisationDashboardStats interfaces die door de pagina en composable
worden gebruikt. Deze hoorden in de dashboard-commit maar vielen uit
door een staging-split.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vervang het naam-alleen dialoog door een volledig organisatiegegevens-
formulier: naam, slug (met copy-knop en tooltip), contactpersoon, contact
e-mail, telefoon en website. Slug krijgt een regex-validator; e-mail en
URL alleen gevalideerd wanneer ingevuld. Server-side validatiefouten per
veld getoond.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the minimal placeholder with a dashboard: header + edit action,
drie stat-tegels (Leden / Evenementen / Personen — de eerste twee
clickable), organisatiegegevens + leden-top-5 infokaarten en een recente-
activiteit lijst. Nieuwe TypeScript-types en useOrganisationDashboardStats
composable sluiten aan op de nieuwe backend-endpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the Algemeen tab together with the Organisatie subheader — organisatie-
gegevens verhuizen naar /organisation. Voeg een GEVAARLIJK-subheader toe met
een Gevaarlijke acties tab, die de bestaande platform-beheerder-notitie bevat
(self-delete blijft buiten scope). Legacy ?tab=algemeen/general redirects
door naar /organisation; default tab valt terug op Crowd Types.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add contact_name, contact_email, phone, website columns. Wire the new
fields through the Organisation model, update request validation,
response resource, and the TypeScript Organisation interface. Needed by
the upcoming dashboard + form-builder binding registry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- EventMetricCards: type navigateTo's routeName as the literal union
of the two routes it actually targets (events-id-persons,
events-id-sections) so the typed router accepts it.
- CreateTimeSlotDialog: type the form ref explicitly so person_type
is PersonType rather than being inferred as string.
- @layouts/types.ts: relax LayoutConfig.app.title from Lowercase<string>
to string. The lowercase constraint was a compile-time namespacing
convention in the Vuexy template with zero runtime effect;
relaxing it lets the branded "Crewli" title satisfy the type.
- useMembers.ts gains a scope param ('organisation' | 'platform') on list,
invite, update-role, and remove; endpoints branch accordingly.
- Platform Admin's [id].vue now consumes useMembers via scope='platform';
deleted the duplicated useInviteOrganisationMember / useRemoveOrganisationMember
/ useUpdateOrganisationMemberRole helpers from useAdmin.ts.
- Deduplicated InviteMemberPayload / UpdateMemberRolePayload / AdminOrganisationMember
from types/admin.ts; Member is now the canonical type.
- SettingsMembers.vue and EditMemberRoleDialog.vue removed (no remaining imports).
- InviteMemberDialog accepts an optional scope prop and is restricted to the
two organisation-level roles matching the /members UX.
- Wrap filter row so controls flow to a second line on narrow screens
- Search field now flex-fills available width instead of fixed 300px
- Type select: removed inline label, widened to 240px, prevented
shrink with flex-shrink-0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Login forms correctly only check for empty fields (no password
constraints needed). But password-reset, password-set, and
password-change forms now enforce constraints client-side:
- App reset-password: add PasswordRequirements component,
confirmation mismatch check, canSubmit guard, disabled button
- Portal wachtwoord-resetten: add canSubmit guard, confirmation
check, disabled button (PasswordRequirements was rendered but
not enforced)
- App SecurityTab (change password): replace static requirements
list with interactive PasswordRequirements, add canSubmit guard
Also created PasswordRequirements.vue component for the organizer
app (portal already had one).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The catch-all error handler (for anti-email-enumeration) was also
swallowing 422 validation errors, making it appear that a reset
email was sent even for empty or invalid input. Now 422 responses
are excluded from the catch — the user stays on the form so the
field-level validation messages remain visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both the organizer app and portal forgot-password pages now
validate the email field before submission: required + email
format check. Backend already validated this, but empty or
malformed emails were being sent to the API unnecessarily.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace card-based multi-line options with compact single-line rows
(grip + label + description + delete all on one row)
- Standardize event registration appearance page on ImageUploadField
(was VFileInput + manual preview, now consistent with email branding)
- Fix EmailBrandingTab logoUrl ref to properly handle null from
ImageUploadField, ensuring existing image preview works on page load
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Restructure field editor dialog: move Options section to bottom with
divider and subheader, fix delete button with flex layout
- Change tag_category (single string) to tag_categories (JSON array)
supporting multiple category selection in tag picker fields
- Portal tag picker now groups tags by category with subheaders
- Add generic file upload endpoint (FileUploadService + UploadController)
- Replace email branding logo URL text field with ImageUploadField
- Update Partner crowd type default icon to tabler-affiliate
- Apply changes consistently to both field and template dialogs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the per-field `section` text property with a dedicated HEADING field type that
organizers add as a separate block for visual grouping. Also fixes duplicate heading bug
on portal radio fields, replaces cramped VBtnToggle with VSelect for field width, and
adds grouped field type dropdown with structure/input categories.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add configurable column widths (full/half) and optional descriptions
for radio/select/checkbox options on registration form fields.
- Migration adds display_width column to both tables
- FieldDisplayWidth enum with smart defaults per field type
- normalized_options accessor for backwards-compatible option format
- Portal form renderer uses display_width for VRow/VCol grid layout
- Radio/select/checkbox options render with descriptions
- Admin field editor supports display_width toggle and description input
- System templates updated with appropriate widths and descriptions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace position:fixed VSystemBar + fragile :deep() CSS overrides
with a normal-flow div inside the Vuexy content area. The banner
renders in VerticalNavLayout's default slot (layout-page-content)
so it sits naturally below the navbar without fighting the layout
system. Sidebar and navbar are no longer affected.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous :deep() overrides had equal specificity to Vuexy's
unscoped styles in VerticalNavLayout.vue. Since child component
styles are injected after parent styles, Vuexy's inset-block-start: 0
won by source order. Add !important and simplify the navbar selector
to target .layout-navbar directly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous paddingTop on a wrapper div didn't affect the Vuexy
layout's fixed-position sidebar or sticky navbar. Replace with
scoped :deep() CSS overrides that shift both elements down 48px
when impersonation is active.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Banner: white elevated button for contrast, fixed 48px height,
layout top padding offset so content isn't obscured
- Middleware: allow GET me/profile (viewing), block mutations only;
add auth/refresh to blocked routes
- Navigation: hide Platform section during impersonation; hide
org-dependent items when impersonated user has no organisation
- Test: add read-only routes allowed test, auth/refresh blocked test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace horizontal tabs with VList-based vertical sidebar following the
Vuexy ecommerce settings pattern. Consolidate Tags, Crowd Types, Members,
and Registration Fields pages into the settings page as sidebar tabs.
Add SettingsGeneral panel with org details form and danger zone.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After successful MFA code verification, onMfaVerified() called
authStore.initialize() which returned immediately (isInitialized
was already true from the initial page load). The auth store was
never populated with user data, so the router guard saw
isAuthenticated === false and redirected back to /login — leaving
the user stuck on the MFA challenge screen with a consumed session.
Fix: use authStore.refreshUser() instead of initialize(). This
always calls GET /auth/me (using the new auth cookie from the MFA
verify response), populates the store, and then navigation to the
dashboard succeeds.
The portal login already uses authStore.fetchUser() which has no
isInitialized guard, so it was not affected.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Moves "Huidig wachtwoord" to a full-width row so "Nieuw wachtwoord"
and "Bevestig nieuw wachtwoord" sit together on the second row.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The "Annuleren" button served no purpose — there's no prior state to
revert to in a password change form. The fields are already empty on
load and the type="reset" just cleared them to the same empty state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Redesigns the MFA method cards and supporting sections for better
visual hierarchy and professional styling:
Method cards (organizer):
- Vertical layout with large icon (VAvatar 44px) at top
- Description text explaining each method
- Status chip with check icon when configured
- VCardActions with primary chip/button + "Opnieuw instellen"
- Primary method card highlighted with 2px primary border
- Proper h-100 for equal height side-by-side
Backup codes:
- Separate outlined VCard with key icon, progress bar, refresh button
- Cleaner spacing and visual grouping
Disable MFA:
- Replaced heavy danger-zone card with subtle text button
(tabler-shield-off icon, error color) — less visual weight for a
rarely-used destructive action
Portal:
- Per-method rows with VAvatar icons and stacked status chips
- Matching text-button style for disable action
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the ability for users to change their preferred/primary MFA method
when both TOTP and email are available.
Backend:
- Add PUT /auth/mfa/preferred-method endpoint with validation
(method must be totp/email, MFA must be enabled, TOTP must be
configured if selecting totp)
- Add totp_configured and email_configured fields to MFA status
endpoint (totp = has secret + enabled, email = always when enabled)
- Fix setupEmail() to preserve mfa_secret so TOTP config survives
when email is set up as a second method
Frontend (organizer + portal):
- Add useSetPreferredMethod() composable to useMfa.ts
- Add totp_configured/email_configured to MfaStatus type
- SecurityTab method cards now show "Primaire methode" chip on the
preferred method and "Als primair instellen" button on the other
- Portal security section shows per-method rows with status chips
and primary switching
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: the MFA status endpoint returned `mfa_enabled` as the JSON
key but the TypeScript MfaStatus interface expected `enabled`. At
runtime, `mfaStatus.value?.enabled` was always `undefined`, so
`isEnabled` was always false — the banner never hid and the method
cards never showed "Geconfigureerd".
Additionally, the auth store had no way to re-fetch /auth/me after
initialization, so `mfaSetupRequired` was never properly refreshed
from the backend after MFA setup.
Fixes:
- Rename `mfa_enabled` → `enabled` in the MFA status endpoint response
to match the TypeScript type (and the /auth/me MeResource which
already used `enabled`)
- Add `refreshUser()` to the auth store for post-initialization
re-fetching of /auth/me
- Call `refreshUser()` in onSetupCompleted so the store reflects the
backend state without a full page reload
- Update backend tests to match the renamed response key
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace 24 `err: any` error handler types with proper `AxiosError<ApiErrorResponse>`
typing. Fix additional `as any` casts and `Record<string, any>` patterns in registration
field components, event settings, and portal layout. Create shared `ApiErrorResponse`
type for portal app.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds three new tabs to the organisation settings page:
- E-mail opmaak: replaces old EmailBrandingTab to use the new
organisation_email_settings API (logo, colors, footer, reply-to)
- E-mail templates: list/edit/preview/test/reset all 6 template types
with variable hints, defaults comparison, and iframe preview
- E-mail log: server-side paginated table with filters (search, status,
type, date range), status chips, and expandable row details
Supporting files:
- types/email.ts: TypeScript interfaces for settings, templates, logs
- composables/api/useEmail.ts: TanStack Query hooks for all email endpoints
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add "Nieuwe organisatie" button to the platform organisations list page.
Dialog with name field (auto-generates slug) and slug field. Uses the
existing POST /organisations endpoint. On success, navigates to the
new organisation's detail page.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both org pages now use the same VDataTable with:
- Search field (name/email filter)
- Sortable columns (Naam, E-mail, Rol) with default sort on name
- Pagination (10 per page)
- Avatar with initials, role chips with color mapping
- Consistent empty state with icon
Platform page: replaced VTable with VDataTable, added role chips
(replacing inline AppSelect), role editing via menu on edit button.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace custom text-caption span with the standard
<p class="text-body-1 text-disabled mb-0"> pattern used across
all other pages in the codebase.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both organisation pages: slug wrapped in parentheses, billing status
label capitalized, timestamps use text-disabled for lighter appearance,
edit button labeled "Bewerken" consistently.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>