@seifer-webapp-factory/authentication 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +8 -0
  2. package/backend/templates/config/config-fragment.ts +73 -0
  3. package/backend/templates/mail/templates.ts +84 -0
  4. package/backend/templates/nestjs/auth.controller.ts +274 -0
  5. package/backend/templates/nestjs/auth.module.ts +207 -0
  6. package/backend/templates/nestjs/tokens.ts +24 -0
  7. package/backend/templates/persistence/migrations/0001_auth.sql +36 -0
  8. package/backend/templates/persistence/migrations/index.ts +75 -0
  9. package/backend/templates/persistence/pg-single-use-store.ts +64 -0
  10. package/backend/templates/persistence/pg-token-store.ts +75 -0
  11. package/backend/templates/persistence/pg-user-store.ts +53 -0
  12. package/backend/templates/security/cookies.ts +89 -0
  13. package/backend/templates/security/csrf.ts +44 -0
  14. package/backend/templates/security/headers.ts +30 -0
  15. package/backend/templates/security/redaction.ts +38 -0
  16. package/dist/backend/src/errors.d.ts +12 -0
  17. package/dist/backend/src/errors.d.ts.map +1 -0
  18. package/dist/backend/src/errors.js +55 -0
  19. package/dist/backend/src/errors.js.map +1 -0
  20. package/dist/backend/src/index.d.ts +9 -0
  21. package/dist/backend/src/index.d.ts.map +1 -0
  22. package/dist/backend/src/index.js +8 -0
  23. package/dist/backend/src/index.js.map +1 -0
  24. package/dist/backend/src/ports.d.ts +60 -0
  25. package/dist/backend/src/ports.d.ts.map +1 -0
  26. package/dist/backend/src/ports.js +2 -0
  27. package/dist/backend/src/ports.js.map +1 -0
  28. package/dist/backend/src/services.d.ts +49 -0
  29. package/dist/backend/src/services.d.ts.map +1 -0
  30. package/dist/backend/src/services.js +178 -0
  31. package/dist/backend/src/services.js.map +1 -0
  32. package/dist/contract/endpoints.d.ts +259 -0
  33. package/dist/contract/endpoints.d.ts.map +1 -0
  34. package/dist/contract/endpoints.js +42 -0
  35. package/dist/contract/endpoints.js.map +1 -0
  36. package/dist/contract/errors.d.ts +23 -0
  37. package/dist/contract/errors.d.ts.map +1 -0
  38. package/dist/contract/errors.js +31 -0
  39. package/dist/contract/errors.js.map +1 -0
  40. package/dist/contract/events.d.ts +40 -0
  41. package/dist/contract/events.d.ts.map +1 -0
  42. package/dist/contract/events.js +14 -0
  43. package/dist/contract/events.js.map +1 -0
  44. package/dist/contract/index.d.ts +9 -0
  45. package/dist/contract/index.d.ts.map +1 -0
  46. package/dist/contract/index.js +9 -0
  47. package/dist/contract/index.js.map +1 -0
  48. package/dist/contract/schemas.d.ts +150 -0
  49. package/dist/contract/schemas.d.ts.map +1 -0
  50. package/dist/contract/schemas.js +43 -0
  51. package/dist/contract/schemas.js.map +1 -0
  52. package/dist/frontend/src/client.d.ts +38 -0
  53. package/dist/frontend/src/client.d.ts.map +1 -0
  54. package/dist/frontend/src/client.js +88 -0
  55. package/dist/frontend/src/client.js.map +1 -0
  56. package/dist/frontend/src/composables.d.ts +46 -0
  57. package/dist/frontend/src/composables.d.ts.map +1 -0
  58. package/dist/frontend/src/composables.js +111 -0
  59. package/dist/frontend/src/composables.js.map +1 -0
  60. package/dist/frontend/src/guards.d.ts +10 -0
  61. package/dist/frontend/src/guards.d.ts.map +1 -0
  62. package/dist/frontend/src/guards.js +9 -0
  63. package/dist/frontend/src/guards.js.map +1 -0
  64. package/dist/frontend/src/index.d.ts +12 -0
  65. package/dist/frontend/src/index.d.ts.map +1 -0
  66. package/dist/frontend/src/index.js +9 -0
  67. package/dist/frontend/src/index.js.map +1 -0
  68. package/dist/manifest.d.ts +80 -0
  69. package/dist/manifest.d.ts.map +1 -0
  70. package/dist/manifest.js +126 -0
  71. package/dist/manifest.js.map +1 -0
  72. package/dist/scaffolder/core/config.d.ts +213 -0
  73. package/dist/scaffolder/core/config.d.ts.map +1 -0
  74. package/dist/scaffolder/core/config.js +132 -0
  75. package/dist/scaffolder/core/config.js.map +1 -0
  76. package/dist/scaffolder/core/errors.d.ts +37 -0
  77. package/dist/scaffolder/core/errors.d.ts.map +1 -0
  78. package/dist/scaffolder/core/errors.js +46 -0
  79. package/dist/scaffolder/core/errors.js.map +1 -0
  80. package/dist/scaffolder/core/extend.d.ts +115 -0
  81. package/dist/scaffolder/core/extend.d.ts.map +1 -0
  82. package/dist/scaffolder/core/extend.js +116 -0
  83. package/dist/scaffolder/core/extend.js.map +1 -0
  84. package/dist/scaffolder/core/materialize.d.ts +71 -0
  85. package/dist/scaffolder/core/materialize.d.ts.map +1 -0
  86. package/dist/scaffolder/core/materialize.js +47 -0
  87. package/dist/scaffolder/core/materialize.js.map +1 -0
  88. package/dist/scaffolder/core/ports.d.ts +39 -0
  89. package/dist/scaffolder/core/ports.d.ts.map +1 -0
  90. package/dist/scaffolder/core/ports.js +33 -0
  91. package/dist/scaffolder/core/ports.js.map +1 -0
  92. package/dist/scaffolder/core/three-way-merge.d.ts +113 -0
  93. package/dist/scaffolder/core/three-way-merge.d.ts.map +1 -0
  94. package/dist/scaffolder/core/three-way-merge.js +184 -0
  95. package/dist/scaffolder/core/three-way-merge.js.map +1 -0
  96. package/dist/scaffolder/index.d.ts +21 -0
  97. package/dist/scaffolder/index.d.ts.map +1 -0
  98. package/dist/scaffolder/index.js +20 -0
  99. package/dist/scaffolder/index.js.map +1 -0
  100. package/frontend/templates/components/AuthField.vue +68 -0
  101. package/frontend/templates/i18n/en.json +70 -0
  102. package/frontend/templates/i18n/nl.json +70 -0
  103. package/frontend/templates/middleware/auth.ts +25 -0
  104. package/frontend/templates/middleware/guest.ts +25 -0
  105. package/frontend/templates/pages/forgot-password.vue +89 -0
  106. package/frontend/templates/pages/login.vue +90 -0
  107. package/frontend/templates/pages/logout.vue +46 -0
  108. package/frontend/templates/pages/register.vue +100 -0
  109. package/frontend/templates/pages/reset-password.vue +105 -0
  110. package/frontend/templates/pages/verify-email.vue +76 -0
  111. package/frontend/templates/plugins/auth.client.ts +111 -0
  112. package/frontend/templates/runtime.ts +60 -0
  113. package/package.json +71 -0
@@ -0,0 +1,89 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * US-A0503 — Wachtwoord-vergeten-pagina (surface, gematerialiseerd). Composeert de forms-kit met de
4
+ * pinned `useForgotPassword`-composable (`POST /auth/forgot-password`). No-enumeration: ongeacht of
5
+ * het e-mailadres bestaat, toont de pagina na verzending dezelfde generieke bevestiging — de UI
6
+ * onthult nooit of er een account bij het adres hoort. Alleen bij een client-side validatiefout of
7
+ * een echte transportfout (bv. rate-limit) verschijnt een gelokaliseerde melding.
8
+ */
9
+ import { computed, ref } from 'vue';
10
+ import { zodFormSchema } from '@seifer-webapp-factory/kits/frontend/forms';
11
+ import { useForm } from '@seifer-webapp-factory/kits/frontend/forms/vue';
12
+ import { forgotPasswordRequestSchema } from '../../../contract/index.js';
13
+ import { useForgotPassword } from '../../src/composables.js';
14
+ import AuthField from '../components/AuthField.vue';
15
+ import { useTranslate } from '../runtime.js';
16
+
17
+ const t = useTranslate();
18
+ const forgot = useForgotPassword();
19
+
20
+ /** Zodra we een generieke bevestiging tonen (no-enumeration). */
21
+ const submitted = ref(false);
22
+
23
+ const { handleSubmit, isSubmitting } = useForm(zodFormSchema(forgotPasswordRequestSchema), {
24
+ initialValues: { email: '' },
25
+ submitHandler: async (input) => {
26
+ const ack = await forgot.submit(input);
27
+ if (ack === undefined) return; // bv. rate_limited: toon de gelokaliseerde reden, geen bevestiging
28
+ submitted.value = true;
29
+ },
30
+ });
31
+
32
+ const submitError = computed(() => (forgot.error.value !== null ? t(forgot.error.value) : ''));
33
+ const busy = computed(() => forgot.pending.value || isSubmitting.value);
34
+
35
+ async function onSubmit(): Promise<void> {
36
+ await handleSubmit();
37
+ }
38
+ </script>
39
+
40
+ <template>
41
+ <section class="auth-page auth-page--forgot">
42
+ <div v-if="submitted" class="auth-page__panel">
43
+ <header class="auth-page__header">
44
+ <h1 class="auth-page__title">{{ t('auth.forgot.confirmTitle') }}</h1>
45
+ </header>
46
+ <p class="auth-page__confirmation" role="status" aria-live="polite" data-testid="auth-confirmation">
47
+ {{ t('auth.forgot.confirmBody') }}
48
+ </p>
49
+ <nav class="auth-page__links">
50
+ <a class="auth-page__link" href="/login">{{ t('auth.forgot.backToLogin') }}</a>
51
+ </nav>
52
+ </div>
53
+
54
+ <div v-else class="auth-page__panel">
55
+ <header class="auth-page__header">
56
+ <h1 class="auth-page__title">{{ t('auth.forgot.title') }}</h1>
57
+ <p class="auth-page__subtitle">{{ t('auth.forgot.subtitle') }}</p>
58
+ </header>
59
+
60
+ <form class="auth-form" novalidate @submit.prevent="onSubmit">
61
+ <p
62
+ v-if="submitError"
63
+ class="auth-form__error"
64
+ role="alert"
65
+ aria-live="assertive"
66
+ data-testid="auth-form-error"
67
+ >
68
+ {{ submitError }}
69
+ </p>
70
+
71
+ <AuthField
72
+ name="email"
73
+ :label="t('auth.field.email')"
74
+ type="email"
75
+ autocomplete="email"
76
+ required
77
+ />
78
+
79
+ <button class="auth-form__submit" type="submit" :disabled="busy">
80
+ {{ busy ? t('auth.forgot.submitting') : t('auth.forgot.submit') }}
81
+ </button>
82
+ </form>
83
+
84
+ <nav class="auth-page__links">
85
+ <a class="auth-page__link" href="/login">{{ t('auth.forgot.backToLogin') }}</a>
86
+ </nav>
87
+ </div>
88
+ </section>
89
+ </template>
@@ -0,0 +1,90 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * US-A0303 — Login-pagina (surface, gematerialiseerd, mag divergeren). Composeert de forms-kit
4
+ * (zod-gedreven validatie + toegankelijke veldfouten) met de pinned `useLogin`-composable, die via
5
+ * de getypte contract-client `POST /auth/login` aanroept en bij succes de auth-kit-sessie hydrateert.
6
+ * Serverfoutcodes uit de taxonomie worden via i18n vertaald en in een `aria-live`-regio getoond
7
+ * (nooit rauwe details — no-enumeration). Bij succes: toast + redirect naar de terugkeer-intentie.
8
+ *
9
+ * Design-system-agnostisch: plain semantische HTML + BEM-ish klassen; de host swapt zijn componenten in.
10
+ */
11
+ import { computed } from 'vue';
12
+ import { zodFormSchema } from '@seifer-webapp-factory/kits/frontend/forms';
13
+ import { useForm } from '@seifer-webapp-factory/kits/frontend/forms/vue';
14
+ import { loginRequestSchema } from '../../../contract/index.js';
15
+ import { useLogin } from '../../src/composables.js';
16
+ import AuthField from '../components/AuthField.vue';
17
+ import { readQueryParam, useNavigate, useOptionalToasts, useTranslate } from '../runtime.js';
18
+
19
+ const t = useTranslate();
20
+ const navigate = useNavigate();
21
+ const notifySuccess = useOptionalToasts();
22
+ const login = useLogin();
23
+
24
+ /** Terugkeer-intentie: het pad waar de guard de bezoeker vandaan wegstuurde, of de home. */
25
+ const redirectTarget = computed(() => readQueryParam('redirect') || '/');
26
+
27
+ const { handleSubmit, isSubmitting } = useForm(zodFormSchema(loginRequestSchema), {
28
+ initialValues: { email: '', password: '' },
29
+ submitHandler: async (input) => {
30
+ const session = await login.submit(input);
31
+ if (session === undefined) return; // fout staat reactief in `login.error` (i18n-sleutel)
32
+ notifySuccess?.(t('auth.login.success'));
33
+ navigate(redirectTarget.value);
34
+ },
35
+ });
36
+
37
+ /** Vertaalde, veilige serverfout (of leeg). */
38
+ const submitError = computed(() => (login.error.value !== null ? t(login.error.value) : ''));
39
+ /** Bezig zolang de client-call of de form-submit loopt. */
40
+ const busy = computed(() => login.pending.value || isSubmitting.value);
41
+
42
+ async function onSubmit(): Promise<void> {
43
+ await handleSubmit();
44
+ }
45
+ </script>
46
+
47
+ <template>
48
+ <section class="auth-page auth-page--login">
49
+ <header class="auth-page__header">
50
+ <h1 class="auth-page__title">{{ t('auth.login.title') }}</h1>
51
+ <p class="auth-page__subtitle">{{ t('auth.login.subtitle') }}</p>
52
+ </header>
53
+
54
+ <form class="auth-form" novalidate @submit.prevent="onSubmit">
55
+ <p
56
+ v-if="submitError"
57
+ class="auth-form__error"
58
+ role="alert"
59
+ aria-live="assertive"
60
+ data-testid="auth-form-error"
61
+ >
62
+ {{ submitError }}
63
+ </p>
64
+
65
+ <AuthField
66
+ name="email"
67
+ :label="t('auth.field.email')"
68
+ type="email"
69
+ autocomplete="email"
70
+ required
71
+ />
72
+ <AuthField
73
+ name="password"
74
+ :label="t('auth.field.password')"
75
+ type="password"
76
+ autocomplete="current-password"
77
+ required
78
+ />
79
+
80
+ <button class="auth-form__submit" type="submit" :disabled="busy">
81
+ {{ busy ? t('auth.login.submitting') : t('auth.login.submit') }}
82
+ </button>
83
+ </form>
84
+
85
+ <nav class="auth-page__links">
86
+ <a class="auth-page__link" href="/forgot-password">{{ t('auth.login.forgotLink') }}</a>
87
+ <a class="auth-page__link" href="/register">{{ t('auth.login.registerLink') }}</a>
88
+ </nav>
89
+ </section>
90
+ </template>
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * US-A0404 — Logout-pagina (surface, gematerialiseerd). Roept bij het laden de pinned
4
+ * `useLogout`-composable aan (`POST /auth/logout`), die de server-sessie beëindigt én de lokale
5
+ * auth-kit-sessie hoe dan ook wist — ook als de server-call faalt (netwerk/al afgemeld). Daarna een
6
+ * bevestiging + redirect naar de login-pagina. Geen invoervelden.
7
+ */
8
+ import { computed, onMounted, ref } from 'vue';
9
+ import { useLogout } from '../../src/composables.js';
10
+ import { useNavigate, useTranslate } from '../runtime.js';
11
+
12
+ const t = useTranslate();
13
+ const navigate = useNavigate();
14
+ const logout = useLogout();
15
+
16
+ /** Klaar zodra de (lokale) sessie is gewist — ongeacht het server-resultaat. */
17
+ const done = ref(false);
18
+
19
+ onMounted(async () => {
20
+ // De composable wist de lokale sessie altijd, ook bij een fout; daarom is "afgemeld" altijd waar.
21
+ await logout.submit();
22
+ done.value = true;
23
+ navigate('/login');
24
+ });
25
+
26
+ const busy = computed(() => logout.pending.value);
27
+ </script>
28
+
29
+ <template>
30
+ <section class="auth-page auth-page--logout">
31
+ <p v-if="busy" class="auth-page__status" role="status" aria-live="polite" data-testid="auth-logging-out">
32
+ {{ t('auth.logout.pending') }}
33
+ </p>
34
+ <div v-else class="auth-page__panel">
35
+ <header class="auth-page__header">
36
+ <h1 class="auth-page__title">{{ t('auth.logout.doneTitle') }}</h1>
37
+ </header>
38
+ <p class="auth-page__confirmation" role="status" aria-live="polite" data-testid="auth-confirmation">
39
+ {{ t('auth.logout.doneBody') }}
40
+ </p>
41
+ <nav class="auth-page__links">
42
+ <a class="auth-page__link" href="/login">{{ t('auth.logout.toLogin') }}</a>
43
+ </nav>
44
+ </div>
45
+ </section>
46
+ </template>
@@ -0,0 +1,100 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * US-A0204 — Register-pagina + form (surface, gematerialiseerd, mag divergeren). Composeert de
4
+ * forms-kit (zod-gedreven, spiegelt de password-policy) met de pinned `useRegister`-composable, die
5
+ * via de getypte contract-client `POST /auth/register` aanroept. Client-side zod-validatie blokkeert
6
+ * verzending vóór het netwerk (AC2); serverfoutcodes (`email_taken`, `weak_password`) worden via
7
+ * i18n vertaald en in een `aria-live`-regio getoond (AC3). Bij succes: toast + de
8
+ * "controleer je e-mail"-bevestiging (AC1). Geen eigen HTTP-mechanisme.
9
+ */
10
+ import { computed, ref } from 'vue';
11
+ import { zodFormSchema } from '@seifer-webapp-factory/kits/frontend/forms';
12
+ import { useForm } from '@seifer-webapp-factory/kits/frontend/forms/vue';
13
+ import { registerRequestSchema } from '../../../contract/index.js';
14
+ import { useRegister } from '../../src/composables.js';
15
+ import AuthField from '../components/AuthField.vue';
16
+ import { useOptionalToasts, useTranslate } from '../runtime.js';
17
+
18
+ const t = useTranslate();
19
+ const notifySuccess = useOptionalToasts();
20
+ const register = useRegister();
21
+
22
+ /** Wisselt naar de "controleer je e-mail"-status na een geslaagde registratie. */
23
+ const registered = ref(false);
24
+
25
+ const { handleSubmit, isSubmitting } = useForm(zodFormSchema(registerRequestSchema), {
26
+ initialValues: { email: '', password: '' },
27
+ submitHandler: async (input) => {
28
+ const ack = await register.submit(input);
29
+ if (ack === undefined) return; // fout staat reactief in `register.error`
30
+ notifySuccess?.(t('auth.register.success'));
31
+ registered.value = true;
32
+ },
33
+ });
34
+
35
+ const submitError = computed(() => (register.error.value !== null ? t(register.error.value) : ''));
36
+ const busy = computed(() => register.pending.value || isSubmitting.value);
37
+
38
+ async function onSubmit(): Promise<void> {
39
+ await handleSubmit();
40
+ }
41
+ </script>
42
+
43
+ <template>
44
+ <section class="auth-page auth-page--register">
45
+ <div v-if="registered" class="auth-page__panel">
46
+ <header class="auth-page__header">
47
+ <h1 class="auth-page__title">{{ t('auth.register.checkEmailTitle') }}</h1>
48
+ </header>
49
+ <p class="auth-page__confirmation" role="status" aria-live="polite" data-testid="auth-confirmation">
50
+ {{ t('auth.register.checkEmailBody') }}
51
+ </p>
52
+ <nav class="auth-page__links">
53
+ <a class="auth-page__link" href="/login">{{ t('auth.register.backToLogin') }}</a>
54
+ </nav>
55
+ </div>
56
+
57
+ <div v-else class="auth-page__panel">
58
+ <header class="auth-page__header">
59
+ <h1 class="auth-page__title">{{ t('auth.register.title') }}</h1>
60
+ <p class="auth-page__subtitle">{{ t('auth.register.subtitle') }}</p>
61
+ </header>
62
+
63
+ <form class="auth-form" novalidate @submit.prevent="onSubmit">
64
+ <p
65
+ v-if="submitError"
66
+ class="auth-form__error"
67
+ role="alert"
68
+ aria-live="assertive"
69
+ data-testid="auth-form-error"
70
+ >
71
+ {{ submitError }}
72
+ </p>
73
+
74
+ <AuthField
75
+ name="email"
76
+ :label="t('auth.field.email')"
77
+ type="email"
78
+ autocomplete="email"
79
+ required
80
+ />
81
+ <AuthField
82
+ name="password"
83
+ :label="t('auth.field.password')"
84
+ type="password"
85
+ autocomplete="new-password"
86
+ required
87
+ />
88
+ <p class="auth-form__hint">{{ t('auth.register.passwordHint') }}</p>
89
+
90
+ <button class="auth-form__submit" type="submit" :disabled="busy">
91
+ {{ busy ? t('auth.register.submitting') : t('auth.register.submit') }}
92
+ </button>
93
+ </form>
94
+
95
+ <nav class="auth-page__links">
96
+ <a class="auth-page__link" href="/login">{{ t('auth.register.loginLink') }}</a>
97
+ </nav>
98
+ </div>
99
+ </section>
100
+ </template>
@@ -0,0 +1,105 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * US-A0504 — Wachtwoord-reset-pagina (surface, gematerialiseerd). Leest het reset-token uit de
4
+ * route-query en composeert de forms-kit met de pinned `useResetPassword`-composable
5
+ * (`POST /auth/reset-password`). Ontbreekt het token, dan toont de pagina een "ongeldige link"-status
6
+ * en géén formulier. Serverfoutcodes (`token_invalid`, `token_expired`, `weak_password`) worden via
7
+ * i18n vertaald en in een `aria-live`-regio getoond. Bij succes: een bevestiging + link naar login.
8
+ */
9
+ import { computed, ref } from 'vue';
10
+ import { zodFormSchema } from '@seifer-webapp-factory/kits/frontend/forms';
11
+ import { useForm } from '@seifer-webapp-factory/kits/frontend/forms/vue';
12
+ import { resetPasswordRequestSchema } from '../../../contract/index.js';
13
+ import { useResetPassword } from '../../src/composables.js';
14
+ import AuthField from '../components/AuthField.vue';
15
+ import { readQueryParam, useOptionalToasts, useTranslate } from '../runtime.js';
16
+
17
+ const t = useTranslate();
18
+ const notifySuccess = useOptionalToasts();
19
+ const reset = useResetPassword();
20
+
21
+ /** Het token uit de e-maillink (`?token=...`). Leeg = ongeldige/onvolledige link. */
22
+ const token = readQueryParam('token');
23
+ const hasToken = computed(() => token.length > 0);
24
+
25
+ /** Wisselt naar de bevestiging na een geslaagde reset. */
26
+ const done = ref(false);
27
+
28
+ const { handleSubmit, isSubmitting } = useForm(zodFormSchema(resetPasswordRequestSchema), {
29
+ // `token` reist mee in de waarden (uit de query); alleen `password` heeft een zichtbaar veld.
30
+ initialValues: { token, password: '' },
31
+ submitHandler: async (input) => {
32
+ const ack = await reset.submit(input);
33
+ if (ack === undefined) return; // fout staat reactief in `reset.error`
34
+ notifySuccess?.(t('auth.reset.success'));
35
+ done.value = true;
36
+ },
37
+ });
38
+
39
+ const submitError = computed(() => (reset.error.value !== null ? t(reset.error.value) : ''));
40
+ const busy = computed(() => reset.pending.value || isSubmitting.value);
41
+
42
+ async function onSubmit(): Promise<void> {
43
+ await handleSubmit();
44
+ }
45
+ </script>
46
+
47
+ <template>
48
+ <section class="auth-page auth-page--reset">
49
+ <div v-if="!hasToken" class="auth-page__panel">
50
+ <header class="auth-page__header">
51
+ <h1 class="auth-page__title">{{ t('auth.reset.invalidLinkTitle') }}</h1>
52
+ </header>
53
+ <p class="auth-page__error" role="alert" aria-live="assertive" data-testid="auth-invalid-link">
54
+ {{ t('auth.reset.invalidLinkBody') }}
55
+ </p>
56
+ <nav class="auth-page__links">
57
+ <a class="auth-page__link" href="/forgot-password">{{ t('auth.reset.requestNew') }}</a>
58
+ </nav>
59
+ </div>
60
+
61
+ <div v-else-if="done" class="auth-page__panel">
62
+ <header class="auth-page__header">
63
+ <h1 class="auth-page__title">{{ t('auth.reset.doneTitle') }}</h1>
64
+ </header>
65
+ <p class="auth-page__confirmation" role="status" aria-live="polite" data-testid="auth-confirmation">
66
+ {{ t('auth.reset.doneBody') }}
67
+ </p>
68
+ <nav class="auth-page__links">
69
+ <a class="auth-page__link" href="/login">{{ t('auth.reset.toLogin') }}</a>
70
+ </nav>
71
+ </div>
72
+
73
+ <div v-else class="auth-page__panel">
74
+ <header class="auth-page__header">
75
+ <h1 class="auth-page__title">{{ t('auth.reset.title') }}</h1>
76
+ <p class="auth-page__subtitle">{{ t('auth.reset.subtitle') }}</p>
77
+ </header>
78
+
79
+ <form class="auth-form" novalidate @submit.prevent="onSubmit">
80
+ <p
81
+ v-if="submitError"
82
+ class="auth-form__error"
83
+ role="alert"
84
+ aria-live="assertive"
85
+ data-testid="auth-form-error"
86
+ >
87
+ {{ submitError }}
88
+ </p>
89
+
90
+ <AuthField
91
+ name="password"
92
+ :label="t('auth.reset.newPassword')"
93
+ type="password"
94
+ autocomplete="new-password"
95
+ required
96
+ />
97
+ <p class="auth-form__hint">{{ t('auth.register.passwordHint') }}</p>
98
+
99
+ <button class="auth-form__submit" type="submit" :disabled="busy">
100
+ {{ busy ? t('auth.reset.submitting') : t('auth.reset.submit') }}
101
+ </button>
102
+ </form>
103
+ </div>
104
+ </section>
105
+ </template>
@@ -0,0 +1,76 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * US-A0205 — Verify-email-pagina (surface, gematerialiseerd). Leest het verificatie-token uit de
4
+ * route-query en roept bij het laden de pinned `useVerifyEmail`-composable aan
5
+ * (`POST /auth/verify-email`). De pagina toont vier toestanden: ontbrekend token (ongeldige link),
6
+ * bezig, geslaagd (met link naar login) en mislukt (`token_invalid`/`token_expired`, via i18n
7
+ * vertaald in een `aria-live`-regio). Geen invoervelden — het token komt volledig uit de link.
8
+ */
9
+ import { computed, onMounted, ref } from 'vue';
10
+ import { useVerifyEmail } from '../../src/composables.js';
11
+ import { readQueryParam, useTranslate } from '../runtime.js';
12
+
13
+ const t = useTranslate();
14
+ const verify = useVerifyEmail();
15
+
16
+ /** Het token uit de e-maillink (`?token=...`). Leeg = ongeldige/onvolledige link. */
17
+ const token = readQueryParam('token');
18
+ const hasToken = computed(() => token.length > 0);
19
+
20
+ /** Geslaagd zodra de composable een bevestiging teruggaf. */
21
+ const verified = ref(false);
22
+
23
+ const errorMessage = computed(() => (verify.error.value !== null ? t(verify.error.value) : ''));
24
+
25
+ onMounted(async () => {
26
+ if (!hasToken.value) return;
27
+ const ack = await verify.submit({ token });
28
+ verified.value = ack !== undefined;
29
+ });
30
+ </script>
31
+
32
+ <template>
33
+ <section class="auth-page auth-page--verify">
34
+ <div v-if="!hasToken" class="auth-page__panel">
35
+ <header class="auth-page__header">
36
+ <h1 class="auth-page__title">{{ t('auth.verify.invalidLinkTitle') }}</h1>
37
+ </header>
38
+ <p class="auth-page__error" role="alert" aria-live="assertive" data-testid="auth-invalid-link">
39
+ {{ t('auth.verify.invalidLinkBody') }}
40
+ </p>
41
+ <nav class="auth-page__links">
42
+ <a class="auth-page__link" href="/login">{{ t('auth.verify.toLogin') }}</a>
43
+ </nav>
44
+ </div>
45
+
46
+ <div v-else-if="verify.pending.value" class="auth-page__panel">
47
+ <p class="auth-page__status" role="status" aria-live="polite" data-testid="auth-verifying">
48
+ {{ t('auth.verify.verifying') }}
49
+ </p>
50
+ </div>
51
+
52
+ <div v-else-if="verified" class="auth-page__panel">
53
+ <header class="auth-page__header">
54
+ <h1 class="auth-page__title">{{ t('auth.verify.successTitle') }}</h1>
55
+ </header>
56
+ <p class="auth-page__confirmation" role="status" aria-live="polite" data-testid="auth-confirmation">
57
+ {{ t('auth.verify.successBody') }}
58
+ </p>
59
+ <nav class="auth-page__links">
60
+ <a class="auth-page__link" href="/login">{{ t('auth.verify.toLogin') }}</a>
61
+ </nav>
62
+ </div>
63
+
64
+ <div v-else class="auth-page__panel">
65
+ <header class="auth-page__header">
66
+ <h1 class="auth-page__title">{{ t('auth.verify.failedTitle') }}</h1>
67
+ </header>
68
+ <p class="auth-page__error" role="alert" aria-live="assertive" data-testid="auth-form-error">
69
+ {{ errorMessage || t('auth.verify.failedBody') }}
70
+ </p>
71
+ <nav class="auth-page__links">
72
+ <a class="auth-page__link" href="/login">{{ t('auth.verify.toLogin') }}</a>
73
+ </nav>
74
+ </div>
75
+ </section>
76
+ </template>
@@ -0,0 +1,111 @@
1
+ /**
2
+ * US-A0303/US-A0304 — Auth-bootstrap-plugin (surface, gematerialiseerd, Nuxt client-plugin).
3
+ * Bindt de frontend-kits en het mechanisme samen tot één per-app-instance geheel — géén
4
+ * module-globale mutable state:
5
+ *
6
+ * 1. `createHttpClient` met een `fetchTransport` die cookies meestuurt (`credentials: 'include'`),
7
+ * zodat het httpOnly refresh-token + CSRF-cookie (US-A0703) automatisch meereizen.
8
+ * 2. `createAuthClient` (het mechanisme) rond die http-client → `provideAuthClient`.
9
+ * 3. De auth-kit sessie-store → `provideAuth` (bron van waarheid voor de sessie-status).
10
+ * 4. i18n-, notifications- en forms-translate-providers voor de surface-pagina's.
11
+ * 5. Hydratie via `client.me()`: bij een geldige sessie wordt de store `authenticated`.
12
+ *
13
+ * Dit bestand is een template dat in de host-Nuxt-app wordt gematerialiseerd; het draait niet in de
14
+ * vitest-render-tests (die injecteren een fake client rechtstreeks via `provideAuthClient`).
15
+ */
16
+ import { createHttpClient, fetchTransport } from '@seifer-webapp-factory/kits/frontend/http-client';
17
+ import {
18
+ createSessionStore,
19
+ memoryTokenStorage,
20
+ systemClock,
21
+ realScheduler,
22
+ } from '@seifer-webapp-factory/kits/frontend/auth';
23
+ import { provideAuth } from '@seifer-webapp-factory/kits/frontend/auth/vue';
24
+ import {
25
+ createNotifications,
26
+ systemClock as notifyClock,
27
+ realScheduler as notifyScheduler,
28
+ } from '@seifer-webapp-factory/kits/frontend/notifications';
29
+ import { provideNotifications } from '@seifer-webapp-factory/kits/frontend/notifications/vue';
30
+ import { provideFormTranslate } from '@seifer-webapp-factory/kits/frontend/forms/vue';
31
+ import { createI18n, provideI18n } from '@seifer-webapp-factory/kits/frontend/i18n/vue';
32
+ import { staticCatalogLoader } from '@seifer-webapp-factory/kits/frontend/i18n';
33
+ import { createAuthClient } from '../../src/index.js';
34
+ import { provideAuthClient } from '../../src/composables.js';
35
+ import { provideNavigate } from '../runtime.js';
36
+ import type { PublicUser } from '../../../contract/index.js';
37
+ import nl from '../i18n/nl.json';
38
+ import en from '../i18n/en.json';
39
+
40
+ // `defineNuxtPlugin`, `navigateTo`, `useRuntimeConfig` zijn Nuxt auto-imports (host-context).
41
+ declare const defineNuxtPlugin: <T>(setup: (nuxtApp: NuxtAppLike) => T) => unknown;
42
+ declare const navigateTo: (to: string) => unknown;
43
+ declare const useRuntimeConfig: () => { public?: { authBaseUrl?: string } };
44
+
45
+ interface NuxtAppLike {
46
+ readonly vueApp: import('vue').App;
47
+ provide(name: string, value: unknown): void;
48
+ }
49
+
50
+ export default defineNuxtPlugin(async (nuxtApp) => {
51
+ const app = nuxtApp.vueApp;
52
+
53
+ // 1. HTTP-client met cookie-transport (roterend refresh-token + CSRF via httpOnly-cookies).
54
+ const baseUrl = useRuntimeConfig().public?.authBaseUrl ?? '/api';
55
+ const credentialledFetch: typeof fetch = (input, init) =>
56
+ fetch(input, { ...init, credentials: 'include' });
57
+ const http = createHttpClient({
58
+ transport: fetchTransport(credentialledFetch),
59
+ baseUrl,
60
+ defaultHeaders: { accept: 'application/json' },
61
+ });
62
+
63
+ // 2. Het mechanisme: de getypte auth-client.
64
+ const client = createAuthClient({ http });
65
+ provideAuthClient(app, client);
66
+
67
+ // 3. De auth-kit sessie-store (bron van waarheid). Access-token in geheugen; refresh via cookie.
68
+ const store = createSessionStore<PublicUser>({
69
+ storage: memoryTokenStorage(),
70
+ clock: systemClock(),
71
+ });
72
+ provideAuth<PublicUser, unknown>(app, {
73
+ store,
74
+ login: () => Promise.reject(new Error('login loopt via de contract-client, niet via de kit')),
75
+ logout: () => Promise.resolve(),
76
+ });
77
+ // Voor de route-middleware (US-A0305): de status-accessor op de Nuxt-app.
78
+ nuxtApp.provide('authIsAuthenticated', () => store.isAuthenticated());
79
+
80
+ // 4. Cross-cutting providers voor de surface-pagina's.
81
+ const i18n = await createI18n({
82
+ loader: staticCatalogLoader({ nl, en }),
83
+ supported: ['nl', 'en'],
84
+ defaultLocale: 'nl',
85
+ initialLocale: 'nl',
86
+ });
87
+ provideI18n(app, i18n);
88
+ provideFormTranslate(app, (key, params) => i18n.t(key, params));
89
+
90
+ const notifications = createNotifications({
91
+ clock: notifyClock(),
92
+ scheduler: notifyScheduler(),
93
+ });
94
+ provideNotifications(app, notifications);
95
+
96
+ provideNavigate(app, (to) => {
97
+ navigateTo(to);
98
+ });
99
+
100
+ // 5. Hydratie: probeer de bestaande sessie te herstellen. Anoniem = stil terugvallen op idle.
101
+ try {
102
+ const me = await client.me();
103
+ store.establish({
104
+ tokens: { accessToken: '', expiresAt: undefined },
105
+ subject: me.user.id,
106
+ claims: me.user,
107
+ });
108
+ } catch {
109
+ // Geen (geldige) sessie: de store blijft `idle`. Geen fout lekken naar de gebruiker.
110
+ }
111
+ });