diff --git a/api/app/Http/Resources/Admin/AdminUserResource.php b/api/app/Http/Resources/Admin/AdminUserResource.php
index 8010c970..a2e258c1 100644
--- a/api/app/Http/Resources/Admin/AdminUserResource.php
+++ b/api/app/Http/Resources/Admin/AdminUserResource.php
@@ -24,6 +24,7 @@ final class AdminUserResource extends JsonResource
'created_at' => $this->created_at->toIso8601String(),
'roles' => $this->getRoleNames()->values()->all(),
'is_super_admin' => $this->hasRole('super_admin'),
+ 'mfa_enabled' => (bool) $this->mfa_enabled,
'organisations' => $this->whenLoaded('organisations', fn () =>
$this->organisations->map(fn ($org) => [
'id' => $org->id,
diff --git a/apps/app/auto-imports.d.ts b/apps/app/auto-imports.d.ts
index 8adf5b53..d1511d10 100644
--- a/apps/app/auto-imports.d.ts
+++ b/apps/app/auto-imports.d.ts
@@ -52,9 +52,11 @@ declare global {
const extendRef: typeof import('@vueuse/core')['extendRef']
const formatDate: typeof import('./src/@core/utils/formatters')['formatDate']
const formatDateToMonthShort: typeof import('./src/@core/utils/formatters')['formatDateToMonthShort']
+ const generateDeviceFingerprint: typeof import('./src/utils/deviceFingerprint')['generateDeviceFingerprint']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
+ const getDeviceName: typeof import('./src/utils/deviceFingerprint')['getDeviceName']
const h: typeof import('vue')['h']
const hexToRgb: typeof import('./src/@core/utils/colorConverter')['hexToRgb']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
@@ -421,9 +423,11 @@ declare module 'vue' {
readonly extendRef: UnwrapRef
readonly formatDate: UnwrapRef
readonly formatDateToMonthShort: UnwrapRef
+ readonly generateDeviceFingerprint: UnwrapRef
readonly getActivePinia: UnwrapRef
readonly getCurrentInstance: UnwrapRef
readonly getCurrentScope: UnwrapRef
+ readonly getDeviceName: UnwrapRef
readonly h: UnwrapRef
readonly hexToRgb: UnwrapRef
readonly ignorableWatch: UnwrapRef
diff --git a/apps/app/components.d.ts b/apps/app/components.d.ts
index 02a2e69c..a9583049 100644
--- a/apps/app/components.d.ts
+++ b/apps/app/components.d.ts
@@ -70,6 +70,10 @@ declare module 'vue' {
ImportFromEventDialog: typeof import('./src/components/event/ImportFromEventDialog.vue')['default']
InfoTooltip: typeof import('./src/components/common/InfoTooltip.vue')['default']
InviteMemberDialog: typeof import('./src/components/members/InviteMemberDialog.vue')['default']
+ MfaChallengeCard: typeof import('./src/components/auth/MfaChallengeCard.vue')['default']
+ MfaDisableDialog: typeof import('./src/components/settings/MfaDisableDialog.vue')['default']
+ MfaEmailSetupDialog: typeof import('./src/components/settings/MfaEmailSetupDialog.vue')['default']
+ MfaTotpSetupDialog: typeof import('./src/components/settings/MfaTotpSetupDialog.vue')['default']
MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default']
Notifications: typeof import('./src/@core/components/Notifications.vue')['default']
OrganisationSwitcher: typeof import('./src/components/layout/OrganisationSwitcher.vue')['default']
diff --git a/apps/app/package.json b/apps/app/package.json
index f0b695b9..9fcae881 100644
--- a/apps/app/package.json
+++ b/apps/app/package.json
@@ -40,6 +40,7 @@
"ofetch": "1.5.0",
"pinia": "3.0.3",
"prismjs": "1.30.0",
+ "qrcode": "^1.5.4",
"roboto-fontface": "0.10.0",
"shepherd.js": "13.0.3",
"ufo": "1.6.1",
@@ -84,6 +85,7 @@
"@tiptap/extension-underline": "^2.27.1",
"@types/mapbox-gl": "3.4.1",
"@types/node": "24.9.2",
+ "@types/qrcode": "^1.5.6",
"@types/webfontloader": "1.6.38",
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
diff --git a/apps/app/pnpm-lock.yaml b/apps/app/pnpm-lock.yaml
index 68cc6394..8eae7e09 100644
--- a/apps/app/pnpm-lock.yaml
+++ b/apps/app/pnpm-lock.yaml
@@ -90,6 +90,9 @@ importers:
prismjs:
specifier: 1.30.0
version: 1.30.0
+ qrcode:
+ specifier: ^1.5.4
+ version: 1.5.4
roboto-fontface:
specifier: 0.10.0
version: 0.10.0
@@ -217,6 +220,9 @@ importers:
'@types/node':
specifier: 24.9.2
version: 24.9.2
+ '@types/qrcode':
+ specifier: ^1.5.6
+ version: 1.5.6
'@types/webfontloader':
specifier: 1.6.38
version: 1.6.38
@@ -1435,6 +1441,9 @@ packages:
'@types/pbf@3.0.5':
resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==}
+ '@types/qrcode@1.5.6':
+ resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
+
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
@@ -2059,6 +2068,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
+ camelcase@5.3.1:
+ resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+ engines: {node: '>=6'}
+
caniuse-lite@1.0.30001752:
resolution: {integrity: sha512-vKUk7beoukxE47P5gcVNKkDRzXdVofotshHwfR9vmpeFKxmI5PBpgOMC18LUJUA/DvJ70Y7RveasIBraqsyO/g==}
@@ -2126,6 +2139,9 @@ packages:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
+ cliui@6.0.0:
+ resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -2267,6 +2283,10 @@ packages:
supports-color:
optional: true
+ decamelize@1.2.0:
+ resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+ engines: {node: '>=0.10.0'}
+
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -2316,6 +2336,9 @@ packages:
resolution: {integrity: sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+ dijkstrajs@1.0.3:
+ resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@@ -3720,6 +3743,10 @@ packages:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
+ pngjs@5.0.0:
+ resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+ engines: {node: '>=10.13.0'}
+
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -3872,6 +3899,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ qrcode@1.5.4:
+ resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+ engines: {node: '>=10.13.0'}
+ hasBin: true
+
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -3940,6 +3972,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ require-main-filename@2.0.0:
+ resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
@@ -4054,6 +4089,9 @@ packages:
resolution: {integrity: sha512-owllqNuDDEimQat7EPG0tH7JjO090xKNzUtYz6X+Sk2BXDnOCilDdNLwjWeFywG9xkJul1ULvtUQa9O4pUaY0w==}
engines: {node: '>=4.0.0'}
+ set-blocking@2.0.0:
+ resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -4816,6 +4854,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
+ which-module@2.0.1:
+ resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+
which-typed-array@1.1.19:
resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
engines: {node: '>= 0.4'}
@@ -4856,6 +4897,9 @@ packages:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
+ y18n@4.0.3:
+ resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -4875,10 +4919,18 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
+ yargs-parser@18.1.3:
+ resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+ engines: {node: '>=6'}
+
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
+ yargs@15.4.1:
+ resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+ engines: {node: '>=8'}
+
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@@ -6050,6 +6102,10 @@ snapshots:
'@types/pbf@3.0.5': {}
+ '@types/qrcode@1.5.6':
+ dependencies:
+ '@types/node': 24.9.2
+
'@types/semver@7.7.1': {}
'@types/statuses@2.0.6': {}
@@ -6825,6 +6881,8 @@ snapshots:
callsites@3.1.0: {}
+ camelcase@5.3.1: {}
+
caniuse-lite@1.0.30001752: {}
case-police@0.6.1: {}
@@ -6899,6 +6957,12 @@ snapshots:
cli-width@4.1.0: {}
+ cliui@6.0.0:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 6.2.0
+
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -7025,6 +7089,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decamelize@1.2.0: {}
+
deep-is@0.1.4: {}
deepmerge-ts@5.1.0: {}
@@ -7064,6 +7130,8 @@ snapshots:
diff-sequences@27.5.1: {}
+ dijkstrajs@1.0.3: {}
+
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0
@@ -8755,6 +8823,8 @@ snapshots:
pluralize@8.0.0: {}
+ pngjs@5.0.0: {}
+
possible-typed-array-names@1.1.0: {}
postcss-html@1.8.0:
@@ -8936,6 +9006,12 @@ snapshots:
punycode@2.3.1: {}
+ qrcode@1.5.4:
+ dependencies:
+ dijkstrajs: 1.0.3
+ pngjs: 5.0.0
+ yargs: 15.4.1
+
quansync@0.2.11: {}
querystringify@2.2.0: {}
@@ -9013,6 +9089,8 @@ snapshots:
require-from-string@2.0.2: {}
+ require-main-filename@2.0.0: {}
+
requires-port@1.0.0: {}
resolve-from@4.0.0: {}
@@ -9140,6 +9218,8 @@ snapshots:
serialize-to-js@3.1.2: {}
+ set-blocking@2.0.0: {}
+
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -10073,6 +10153,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
+ which-module@2.0.1: {}
+
which-typed-array@1.1.19:
dependencies:
available-typed-arrays: 1.0.7
@@ -10118,6 +10200,8 @@ snapshots:
xml-name-validator@4.0.0: {}
+ y18n@4.0.3: {}
+
y18n@5.0.8: {}
yallist@3.1.1: {}
@@ -10131,8 +10215,27 @@ snapshots:
yaml@2.8.1: {}
+ yargs-parser@18.1.3:
+ dependencies:
+ camelcase: 5.3.1
+ decamelize: 1.2.0
+
yargs-parser@21.1.1: {}
+ yargs@15.4.1:
+ dependencies:
+ cliui: 6.0.0
+ decamelize: 1.2.0
+ find-up: 4.1.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ require-main-filename: 2.0.0
+ set-blocking: 2.0.0
+ string-width: 4.2.3
+ which-module: 2.0.1
+ y18n: 4.0.3
+ yargs-parser: 18.1.3
+
yargs@17.7.2:
dependencies:
cliui: 8.0.1
diff --git a/apps/app/src/components/auth/MfaChallengeCard.vue b/apps/app/src/components/auth/MfaChallengeCard.vue
new file mode 100644
index 00000000..7345e59f
--- /dev/null
+++ b/apps/app/src/components/auth/MfaChallengeCard.vue
@@ -0,0 +1,283 @@
+
+
+
+
+
+
+ Tweestapsverificatie
+
+
+ Voer je verificatiecode in om in te loggen
+
+
+
+ {{ formattedTimeLeft }}
+
+
+
+
+
+ onMethodChange(String(v))"
+ >
+
+
+ {{ tab.title }}
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+ {{
+ 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'
+ }}
+
+
+
+
+
+
+
+ Voer een van je backup codes in (formaat: XXXX-XXXX)
+
+
+
+
+
+
+
+
+
+
+
+
+ Verifiëren
+
+
+
+
+
+
+
+
+
+
+
+
+ Terug naar inloggen
+
+
+
+
+
+
+
+
+
diff --git a/apps/app/src/components/settings/MfaDisableDialog.vue b/apps/app/src/components/settings/MfaDisableDialog.vue
new file mode 100644
index 00000000..4b3ab61b
--- /dev/null
+++ b/apps/app/src/components/settings/MfaDisableDialog.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+ Tweestapsverificatie uitschakelen
+
+
+
+
+ Weet je zeker dat je tweestapsverificatie wilt uitschakelen? Je account wordt minder veilig.
+
+
+
+ {{ errorMessage }}
+
+
+
+
+ Voer je {{ currentMethod === 'totp' ? 'authenticator code' : 'backup code' }} in ter bevestiging
+
+
+
+
+
+
+
+
+
+ Annuleren
+
+
+ Uitschakelen
+
+
+
+
+
diff --git a/apps/app/src/components/settings/MfaEmailSetupDialog.vue b/apps/app/src/components/settings/MfaEmailSetupDialog.vue
new file mode 100644
index 00000000..c76c481a
--- /dev/null
+++ b/apps/app/src/components/settings/MfaEmailSetupDialog.vue
@@ -0,0 +1,276 @@
+
+
+
+
+
+
+
+ E-mailverificatie instellen
+
+
+
+
+
+
+ We sturen een verificatiecode naar {{ userEmail }}
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+ Annuleren
+
+
+ Code versturen
+
+
+
+
+
+
+
+
+ Code verstuurd! Check je inbox.
+
+
+
+ {{ errorMessage }}
+
+
+
+ Voer de 6-cijferige code in
+
+
+
+
+
+
+
+
+
+
+ Terug
+
+
+ Verifiëren
+
+
+
+
+
+
+
+
+ Bewaar deze codes op een veilige plek. Je kunt ze gebruiken als je geen toegang hebt tot je e-mail.
+
+
+
+
+ {{ code }}
+
+
+
+
+
+ Kopieer
+
+
+ Download
+
+
+
+
+
+
+
+
+
+ Voltooien
+
+
+
+
+
+
+
+
diff --git a/apps/app/src/components/settings/MfaTotpSetupDialog.vue b/apps/app/src/components/settings/MfaTotpSetupDialog.vue
new file mode 100644
index 00000000..26462c00
--- /dev/null
+++ b/apps/app/src/components/settings/MfaTotpSetupDialog.vue
@@ -0,0 +1,291 @@
+
+
+
+
+
+
+
+ Authenticator app instellen
+
+
+
+
+
+
+ Scan de QR-code met je authenticator app (Google Authenticator, Authy, etc.)
+
+
+
+
![QR Code]()
+
+
+
+ QR-code wordt gegenereerd...
+
+
+
+
+
+ copyToClipboard(secret)"
+ />
+
+
+
+
+
+
+
+
+ Annuleren
+
+
+ Volgende
+
+
+
+
+
+
+
+
+ Open je authenticator app en voer de 6-cijferige code in
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
+ Terug
+
+
+ Verifiëren
+
+
+
+
+
+
+
+
+ Bewaar deze codes op een veilige plek. Je kunt ze gebruiken als je geen toegang hebt tot je authenticator app.
+
+
+
+
+ {{ code }}
+
+
+
+
+
+ Kopieer
+
+
+ Download
+
+
+
+
+
+
+
+
+
+ Voltooien
+
+
+
+
+
+
+
+
diff --git a/apps/app/src/composables/api/useMfa.ts b/apps/app/src/composables/api/useMfa.ts
new file mode 100644
index 00000000..abb52781
--- /dev/null
+++ b/apps/app/src/composables/api/useMfa.ts
@@ -0,0 +1,188 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
+import { apiClient } from '@/lib/axios'
+import type {
+ MfaConfirmResponse,
+ MfaStatus,
+ MfaTotpSetup,
+ MfaVerifyPayload,
+ TrustedDevice,
+} from '@/types/mfa'
+
+interface ApiResponse {
+ success: boolean
+ data: T
+ message: string
+}
+
+// ─── Setup ───
+
+export function useSetupTotp() {
+ return useMutation({
+ mutationFn: async () => {
+ const { data } = await apiClient.post>('/auth/mfa/setup/totp')
+
+ return data.data
+ },
+ })
+}
+
+export function useConfirmTotp() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (code: string) => {
+ const { data } = await apiClient.post>('/auth/mfa/setup/totp/confirm', { code })
+
+ return data.data
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
+ queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
+ },
+ })
+}
+
+export function useSetupEmail() {
+ return useMutation({
+ mutationFn: async () => {
+ const { data } = await apiClient.post>('/auth/mfa/setup/email')
+
+ return data
+ },
+ })
+}
+
+export function useConfirmEmail() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (code: string) => {
+ const { data } = await apiClient.post>('/auth/mfa/setup/email/confirm', { code })
+
+ return data.data
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
+ queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
+ },
+ })
+}
+
+// ─── Management ───
+
+export function useDisableMfa() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (payload: { code: string; method: string }) => {
+ const { data } = await apiClient.post>('/auth/mfa/disable', payload)
+
+ return data
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
+ queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
+ },
+ })
+}
+
+export function useRegenerateBackupCodes() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (payload: { code: string }) => {
+ const { data } = await apiClient.post>('/auth/mfa/backup-codes', payload)
+
+ return data.data
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
+ },
+ })
+}
+
+export function useMfaStatus() {
+ return useQuery({
+ queryKey: ['mfa-status'],
+ queryFn: async () => {
+ const { data } = await apiClient.get>('/auth/mfa/status')
+
+ return data.data
+ },
+ })
+}
+
+// ─── Trusted devices ───
+
+export function useTrustedDevices() {
+ return useQuery({
+ queryKey: ['trusted-devices'],
+ queryFn: async () => {
+ const { data } = await apiClient.get>('/auth/trusted-devices')
+
+ return data.data
+ },
+ })
+}
+
+export function useRevokeDevice() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (id: string) => {
+ await apiClient.delete(`/auth/trusted-devices/${id}`)
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['trusted-devices'] })
+ },
+ })
+}
+
+export function useRevokeAllDevices() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async () => {
+ await apiClient.delete('/auth/trusted-devices')
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['trusted-devices'] })
+ },
+ })
+}
+
+// ─── Login flow (no auth needed — uses session token) ───
+
+export function useVerifyMfa() {
+ return useMutation({
+ mutationFn: async (payload: MfaVerifyPayload) => {
+ const { data } = await apiClient.post('/auth/mfa/verify', payload)
+
+ return data
+ },
+ })
+}
+
+export function useSendMfaEmailCode() {
+ return useMutation({
+ mutationFn: async (mfaSessionToken: string) => {
+ const { data } = await apiClient.post('/auth/mfa/email/send', {
+ mfa_session_token: mfaSessionToken,
+ })
+
+ return data
+ },
+ })
+}
+
+// ─── Admin ───
+
+export function useAdminResetMfa() {
+ return useMutation({
+ mutationFn: async (userId: string) => {
+ const { data } = await apiClient.post(`/admin/users/${userId}/reset-mfa`)
+
+ return data
+ },
+ })
+}
diff --git a/apps/app/src/layouts/components/UserProfile.vue b/apps/app/src/layouts/components/UserProfile.vue
index 148bebfa..2d53703e 100644
--- a/apps/app/src/layouts/components/UserProfile.vue
+++ b/apps/app/src/layouts/components/UserProfile.vue
@@ -87,6 +87,17 @@ function handleLogout() {
Accountinstellingen
+
+
+
+
+ Beveiliging
+
+
+import { useAuthStore } from '@/stores/useAuthStore'
+import {
+ useMfaStatus,
+ useTrustedDevices,
+ useRevokeDevice,
+ useRevokeAllDevices,
+ useRegenerateBackupCodes,
+} from '@/composables/api/useMfa'
+import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue'
+import MfaEmailSetupDialog from '@/components/settings/MfaEmailSetupDialog.vue'
+import MfaDisableDialog from '@/components/settings/MfaDisableDialog.vue'
+
+definePage({
+ meta: {
+ navActiveLink: 'account-settings',
+ },
+})
+
+const authStore = useAuthStore()
+
+const { data: mfaStatus, refetch: refetchMfaStatus } = useMfaStatus()
+const { data: trustedDevices, refetch: refetchDevices } = useTrustedDevices()
+const revokeDeviceMutation = useRevokeDevice()
+const revokeAllMutation = useRevokeAllDevices()
+const regenerateCodesMutation = useRegenerateBackupCodes()
+
+const showTotpSetup = ref(false)
+const showEmailSetup = ref(false)
+const showDisableDialog = ref(false)
+const showRegenerateDialog = ref(false)
+const regenerateCode = ref('')
+const regeneratedCodes = ref([])
+const regenerateError = ref('')
+
+const isEnabled = computed(() => mfaStatus.value?.enabled ?? false)
+const methodLabel = computed(() => {
+ if (mfaStatus.value?.method === 'totp') return 'Authenticator app'
+ if (mfaStatus.value?.method === 'email') return 'E-mailcode'
+
+ return null
+})
+
+function onSetupCompleted() {
+ refetchMfaStatus()
+}
+
+function onDisabled() {
+ refetchMfaStatus()
+ refetchDevices()
+}
+
+async function handleRevokeDevice(id: string) {
+ await revokeDeviceMutation.mutateAsync(id)
+ refetchDevices()
+}
+
+async function handleRevokeAllDevices() {
+ await revokeAllMutation.mutateAsync()
+ refetchDevices()
+}
+
+async function handleRegenerateBackupCodes() {
+ regenerateError.value = ''
+ try {
+ const data = await regenerateCodesMutation.mutateAsync({ code: regenerateCode.value })
+
+ regeneratedCodes.value = data.backup_codes
+ refetchMfaStatus()
+ }
+ catch (err: unknown) {
+ const ax = err as { response?: { data?: { message?: string } } }
+
+ regenerateError.value = ax.response?.data?.message ?? 'Kon codes niet genereren.'
+ regenerateCode.value = ''
+ }
+}
+
+function copyRegeneratedCodes() {
+ navigator.clipboard.writeText(regeneratedCodes.value.join('\n'))
+}
+
+
+
+
+
+
+ Beveiliging
+
+
+
+
+ Je organisatie vereist tweestapsverificatie. Stel het nu in om het platform te kunnen gebruiken.
+
+
+
+
+
+
+ Tweestapsverificatie
+
+
+
+
+
+ Bescherm je account met een extra beveiligingslaag. Kies een methode:
+
+
+
+
+
+
+
+ Authenticator app
+
+
+ Aanbevolen. Gebruik Google Authenticator, Authy of een andere app.
+
+
+
+
+
+
+
+ E-mailcode
+
+
+ Ontvang een code per e-mail bij elke login.
+
+
+
+
+
+
+
+
+
+
+
+ Ingeschakeld
+
+ via {{ methodLabel }}
+
+
+
+
+
+
+ Backup codes
+
+
+ {{ mfaStatus?.backup_codes_remaining ?? 0 }} van 10 resterend
+
+
+
+ Nieuwe codes genereren
+
+
+
+
+ Uitschakelen
+
+
+
+
+
+
+
+
+
+
+ Vertrouwde apparaten
+
+
+ Alles intrekken
+
+
+
+
+
+
+
+
+
+ {{ device.device_name ?? 'Onbekend apparaat' }}
+
+ IP: {{ device.ip_address }}
+
+ · Laatst gebruikt: {{ new Date(device.last_used_at).toLocaleDateString('nl-NL') }}
+
+
+
+
+
+
+
+
+
+
+
+ Geen vertrouwde apparaten. Wanneer je inlogt met MFA kun je ervoor kiezen een apparaat te onthouden.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nieuwe backup codes genereren
+
+
+
+
+ Dit vervangt al je huidige backup codes. Oude codes werken niet meer.
+
+
+
+ {{ regenerateError }}
+
+
+
+ Voer je authenticator code in ter bevestiging
+
+
+
+
+
+
+ Nieuwe backup codes gegenereerd. Bewaar ze veilig.
+
+
+
+
+ {{ code }}
+
+
+
+
+ Kopieer
+
+
+
+
+
+
+ {{ regeneratedCodes.length > 0 ? 'Sluiten' : 'Annuleren' }}
+
+
+ Genereren
+
+
+
+
+
+
+
diff --git a/apps/app/src/pages/login.vue b/apps/app/src/pages/login.vue
index 8d7e81fd..1bf8f410 100644
--- a/apps/app/src/pages/login.vue
+++ b/apps/app/src/pages/login.vue
@@ -1,6 +1,5 @@
-
+
{{ themeConfig.app.title }}
-
+
+
+
+
+
+
+
@@ -163,27 +230,25 @@ function onSubmit() {
@submit.prevent="onSubmit"
>
-
-
- Login
+ Inloggen
-
-
- New on our platform?
+
+ Nog geen account? Neem contact op met je organisatie.
-
- Create an account
-
-
-
-
-
- or
-
-
-
-
-
-
diff --git a/apps/app/src/pages/platform/users/[id].vue b/apps/app/src/pages/platform/users/[id].vue
index ec486afe..3022e84b 100644
--- a/apps/app/src/pages/platform/users/[id].vue
+++ b/apps/app/src/pages/platform/users/[id].vue
@@ -4,6 +4,7 @@ import {
useUpdateAdminUser,
useStartImpersonation,
} from '@/composables/api/useAdmin'
+import { useAdminResetMfa } from '@/composables/api/useMfa'
import { useImpersonationStore } from '@/stores/useImpersonationStore'
import type { AdminUser, UpdateAdminUserPayload } from '@/types/admin'
@@ -86,6 +87,21 @@ function confirmImpersonate() {
})
}
+// MFA Reset
+const isMfaResetDialogOpen = ref(false)
+const { mutate: resetMfa, isPending: isResettingMfa } = useAdminResetMfa()
+const showMfaResetSuccess = ref(false)
+
+function confirmMfaReset() {
+ resetMfa(userId.value, {
+ onSuccess: () => {
+ isMfaResetDialogOpen.value = false
+ showMfaResetSuccess.value = true
+ refetch()
+ },
+ })
+}
+
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('nl-NL', {
day: '2-digit',
@@ -253,6 +269,30 @@ function getInitials(name: string): string {
{{ user.email_verified_at ? formatDate(user.email_verified_at) : 'Niet geverifieerd' }}
+
+
+ Tweestapsverificatie
+
+
+
+ {{ user.mfa_enabled ? 'Ingeschakeld' : 'Uitgeschakeld' }}
+
+
+ MFA resetten
+
+
+
@@ -404,6 +444,41 @@ function getInitials(name: string): string {
+
+
+
+
+
+ Weet je zeker dat je de tweestapsverificatie van {{ user?.full_name }} wilt uitschakelen?
+ De gebruiker moet MFA opnieuw instellen bij de volgende login.
+
+
+
+
+
+ Annuleren
+
+
+ MFA resetten
+
+
+
+
+
Gebruiker bijgewerkt
+
+
+ MFA is gereset voor deze gebruiker
+
diff --git a/apps/app/src/plugins/1.router/guards.ts b/apps/app/src/plugins/1.router/guards.ts
index f00e7ea5..c61f62c4 100644
--- a/apps/app/src/plugins/1.router/guards.ts
+++ b/apps/app/src/plugins/1.router/guards.ts
@@ -44,6 +44,12 @@ export function setupGuards(router: Router) {
return { path: '/login', query: { to: to.fullPath } }
}
+ // MFA enforcement — redirect to security settings if MFA setup is required
+ if (authStore.mfaSetupRequired && to.path !== '/account-settings/security') {
+ if (import.meta.env.DEV) console.log('🔒 MFA setup required, redirecting to security settings')
+ return { path: '/account-settings/security' }
+ }
+
// Platform admin routes — require super_admin role
if (to.path.startsWith('/platform')) {
if (!authStore.isSuperAdmin) {
diff --git a/apps/app/src/stores/useAuthStore.ts b/apps/app/src/stores/useAuthStore.ts
index 151f8869..f7ef2c14 100644
--- a/apps/app/src/stores/useAuthStore.ts
+++ b/apps/app/src/stores/useAuthStore.ts
@@ -11,6 +11,8 @@ export const useAuthStore = defineStore('auth', () => {
const permissions = ref([])
const isInitialized = ref(false)
+ const mfaSetupRequired = ref(false)
+
const isAuthenticated = computed(() => !!user.value)
const isSuperAdmin = computed(() => appRoles.value?.includes('super_admin') ?? false)
@@ -35,6 +37,7 @@ export const useAuthStore = defineStore('auth', () => {
organisations.value = me.organisations
appRoles.value = me.app_roles
permissions.value = me.permissions
+ mfaSetupRequired.value = me.mfa?.setup_required ?? false
// Auto-select first organisation if none is active
const orgStore = useOrganisationStore()
@@ -53,6 +56,7 @@ export const useAuthStore = defineStore('auth', () => {
organisations.value = []
appRoles.value = []
permissions.value = []
+ mfaSetupRequired.value = false
const orgStore = useOrganisationStore()
orgStore.clear()
@@ -120,6 +124,7 @@ export const useAuthStore = defineStore('auth', () => {
isInitialized,
isSuperAdmin,
currentOrganisation,
+ mfaSetupRequired,
setUser,
setActiveOrganisation,
logout,
diff --git a/apps/app/src/types/admin.ts b/apps/app/src/types/admin.ts
index 67a2136f..8b3d2d19 100644
--- a/apps/app/src/types/admin.ts
+++ b/apps/app/src/types/admin.ts
@@ -27,6 +27,7 @@ export interface AdminUser {
email_verified_at: string | null
created_at: string
is_super_admin: boolean
+ mfa_enabled: boolean
roles: string[]
organisations: Array<{
id: string
diff --git a/apps/app/src/types/auth.ts b/apps/app/src/types/auth.ts
index add34524..a7a8305f 100644
--- a/apps/app/src/types/auth.ts
+++ b/apps/app/src/types/auth.ts
@@ -16,6 +16,13 @@ export interface Organisation {
role: string
}
+export interface MfaUserInfo {
+ enabled: boolean
+ method: 'totp' | 'email' | null
+ confirmed_at: string | null
+ setup_required: boolean
+}
+
export interface MeResponse {
id: string
first_name: string
@@ -28,6 +35,7 @@ export interface MeResponse {
organisations: Organisation[]
app_roles: string[]
permissions: string[]
+ mfa?: MfaUserInfo
}
export interface LoginCredentials {
@@ -41,6 +49,12 @@ export interface LoginResponse {
user: MeResponse
}
message: string
+ mfa_required?: boolean
+ mfa_session_token?: string
+ methods?: string[]
+ preferred_method?: string
+ expires_in?: number
+ mfa_setup_required?: boolean
}
export interface ApiErrorResponse {
diff --git a/apps/app/src/types/mfa.ts b/apps/app/src/types/mfa.ts
new file mode 100644
index 00000000..905cdd8f
--- /dev/null
+++ b/apps/app/src/types/mfa.ts
@@ -0,0 +1,61 @@
+export const MfaMethod = {
+ TOTP: 'totp',
+ EMAIL: 'email',
+ BACKUP_CODE: 'backup_code',
+} as const
+
+export type MfaMethod = typeof MfaMethod[keyof typeof MfaMethod]
+
+export interface MfaStatus {
+ enabled: boolean
+ method: 'totp' | 'email' | null
+ confirmed_at: string | null
+ backup_codes_remaining: number
+ is_required: boolean
+}
+
+export interface MfaUserStatus {
+ enabled: boolean
+ method: 'totp' | 'email' | null
+ confirmed_at: string | null
+ setup_required: boolean
+}
+
+export interface MfaTotpSetup {
+ secret: string
+ qr_code_url: string
+ provisioning_uri: string
+}
+
+export interface MfaSessionResponse {
+ success: true
+ mfa_required: true
+ mfa_session_token: string
+ methods: MfaMethod[]
+ preferred_method: 'totp' | 'email'
+ expires_in: number
+}
+
+export interface MfaConfirmResponse {
+ mfa_enabled: boolean
+ method: 'totp' | 'email'
+ backup_codes: string[]
+}
+
+export interface TrustedDevice {
+ id: string
+ device_name: string | null
+ ip_address: string
+ trusted_until: string
+ last_used_at: string | null
+ created_at: string
+}
+
+export interface MfaVerifyPayload {
+ mfa_session_token: string
+ code: string
+ method: MfaMethod
+ trust_device?: boolean
+ device_fingerprint?: string
+ device_name?: string
+}
diff --git a/apps/app/src/utils/deviceFingerprint.ts b/apps/app/src/utils/deviceFingerprint.ts
new file mode 100644
index 00000000..89f86154
--- /dev/null
+++ b/apps/app/src/utils/deviceFingerprint.ts
@@ -0,0 +1,49 @@
+export function generateDeviceFingerprint(): string {
+ const components = [
+ navigator.userAgent,
+ `${screen.width}x${screen.height}`,
+ Intl.DateTimeFormat().resolvedOptions().timeZone,
+ navigator.language,
+ navigator.hardwareConcurrency?.toString() ?? '',
+ ]
+
+ const str = components.join('|')
+ let hash = 0
+
+ for (let i = 0; i < str.length; i++) {
+ const char = str.charCodeAt(i)
+
+ hash = ((hash << 5) - hash) + char
+ hash = hash & hash
+ }
+
+ return Math.abs(hash).toString(36)
+}
+
+export function getDeviceName(): string {
+ const ua = navigator.userAgent
+ let browser = 'Onbekend'
+ let os = 'Onbekend'
+
+ if (ua.includes('Chrome') && !ua.includes('Edg'))
+ browser = 'Chrome'
+ else if (ua.includes('Firefox'))
+ browser = 'Firefox'
+ else if (ua.includes('Safari') && !ua.includes('Chrome'))
+ browser = 'Safari'
+ else if (ua.includes('Edg'))
+ browser = 'Edge'
+
+ if (ua.includes('Mac OS'))
+ os = 'macOS'
+ else if (ua.includes('Windows'))
+ os = 'Windows'
+ else if (ua.includes('Linux'))
+ os = 'Linux'
+ else if (ua.includes('Android'))
+ os = 'Android'
+ else if (ua.includes('iPhone') || ua.includes('iPad'))
+ os = 'iOS'
+
+ return `${browser} op ${os}`
+}
diff --git a/apps/app/typed-router.d.ts b/apps/app/typed-router.d.ts
index 166ffce4..fc772e45 100644
--- a/apps/app/typed-router.d.ts
+++ b/apps/app/typed-router.d.ts
@@ -21,6 +21,7 @@ declare module 'vue-router/auto-routes' {
'root': RouteRecordInfo<'root', '/', Record, Record>,
'$error': RouteRecordInfo<'$error', '/:error(.*)', { error: ParamValue }, { error: ParamValue }>,
'account-settings': RouteRecordInfo<'account-settings', '/account-settings', Record, Record>,
+ 'account-settings-security': RouteRecordInfo<'account-settings-security', '/account-settings/security', Record, Record>,
'dashboard': RouteRecordInfo<'dashboard', '/dashboard', Record, Record>,
'events': RouteRecordInfo<'events', '/events', Record, Record>,
'events-id': RouteRecordInfo<'events-id', '/events/:id', { id: ParamValue }, { id: ParamValue }>,
diff --git a/apps/portal/components.d.ts b/apps/portal/components.d.ts
index 8f719db0..3533a4a5 100644
--- a/apps/portal/components.d.ts
+++ b/apps/portal/components.d.ts
@@ -36,6 +36,10 @@ declare module 'vue' {
EventCard: typeof import('./src/components/portal/EventCard.vue')['default']
I18n: typeof import('./src/@core/components/I18n.vue')['default']
InformatieTab: typeof import('./src/components/event/InformatieTab.vue')['default']
+ MfaChallengeCard: typeof import('./src/components/auth/MfaChallengeCard.vue')['default']
+ MfaDisableDialog: typeof import('./src/components/settings/MfaDisableDialog.vue')['default']
+ MfaEmailSetupDialog: typeof import('./src/components/settings/MfaEmailSetupDialog.vue')['default']
+ MfaTotpSetupDialog: typeof import('./src/components/settings/MfaTotpSetupDialog.vue')['default']
MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default']
Notifications: typeof import('./src/@core/components/Notifications.vue')['default']
OverzichtTab: typeof import('./src/components/event/OverzichtTab.vue')['default']
diff --git a/apps/portal/package.json b/apps/portal/package.json
index 3bdbc79c..d44bb7e1 100644
--- a/apps/portal/package.json
+++ b/apps/portal/package.json
@@ -41,6 +41,7 @@
"ofetch": "1.5.0",
"pinia": "3.0.3",
"prismjs": "1.30.0",
+ "qrcode": "^1.5.4",
"roboto-fontface": "0.10.0",
"shepherd.js": "13.0.3",
"swiper": "11.2.10",
@@ -84,6 +85,7 @@
"@tiptap/extension-underline": "^2.27.1",
"@types/mapbox-gl": "3.4.1",
"@types/node": "24.9.2",
+ "@types/qrcode": "^1.5.6",
"@types/webfontloader": "1.6.38",
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
diff --git a/apps/portal/pnpm-lock.yaml b/apps/portal/pnpm-lock.yaml
index 22852b6d..d250b930 100644
--- a/apps/portal/pnpm-lock.yaml
+++ b/apps/portal/pnpm-lock.yaml
@@ -93,6 +93,9 @@ importers:
prismjs:
specifier: 1.30.0
version: 1.30.0
+ qrcode:
+ specifier: ^1.5.4
+ version: 1.5.4
roboto-fontface:
specifier: 0.10.0
version: 0.10.0
@@ -217,6 +220,9 @@ importers:
'@types/node':
specifier: 24.9.2
version: 24.9.2
+ '@types/qrcode':
+ specifier: ^1.5.6
+ version: 1.5.6
'@types/webfontloader':
specifier: 1.6.38
version: 1.6.38
@@ -1435,6 +1441,9 @@ packages:
'@types/pbf@3.0.5':
resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==}
+ '@types/qrcode@1.5.6':
+ resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
+
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
@@ -2058,6 +2067,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
+ camelcase@5.3.1:
+ resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+ engines: {node: '>=6'}
+
caniuse-lite@1.0.30001752:
resolution: {integrity: sha512-vKUk7beoukxE47P5gcVNKkDRzXdVofotshHwfR9vmpeFKxmI5PBpgOMC18LUJUA/DvJ70Y7RveasIBraqsyO/g==}
@@ -2125,6 +2138,9 @@ packages:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
+ cliui@6.0.0:
+ resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -2266,6 +2282,10 @@ packages:
supports-color:
optional: true
+ decamelize@1.2.0:
+ resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+ engines: {node: '>=0.10.0'}
+
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -2315,6 +2335,9 @@ packages:
resolution: {integrity: sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+ dijkstrajs@1.0.3:
+ resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@@ -3719,6 +3742,10 @@ packages:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
+ pngjs@5.0.0:
+ resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+ engines: {node: '>=10.13.0'}
+
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -3871,6 +3898,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ qrcode@1.5.4:
+ resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+ engines: {node: '>=10.13.0'}
+ hasBin: true
+
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -3939,6 +3971,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ require-main-filename@2.0.0:
+ resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
@@ -4053,6 +4088,9 @@ packages:
resolution: {integrity: sha512-owllqNuDDEimQat7EPG0tH7JjO090xKNzUtYz6X+Sk2BXDnOCilDdNLwjWeFywG9xkJul1ULvtUQa9O4pUaY0w==}
engines: {node: '>=4.0.0'}
+ set-blocking@2.0.0:
+ resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -4811,6 +4849,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
+ which-module@2.0.1:
+ resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+
which-typed-array@1.1.19:
resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
engines: {node: '>= 0.4'}
@@ -4851,6 +4892,9 @@ packages:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
+ y18n@4.0.3:
+ resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -4870,10 +4914,18 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
+ yargs-parser@18.1.3:
+ resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+ engines: {node: '>=6'}
+
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
+ yargs@15.4.1:
+ resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+ engines: {node: '>=8'}
+
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@@ -6047,6 +6099,10 @@ snapshots:
'@types/pbf@3.0.5': {}
+ '@types/qrcode@1.5.6':
+ dependencies:
+ '@types/node': 24.9.2
+
'@types/semver@7.7.1': {}
'@types/statuses@2.0.6': {}
@@ -6822,6 +6878,8 @@ snapshots:
callsites@3.1.0: {}
+ camelcase@5.3.1: {}
+
caniuse-lite@1.0.30001752: {}
case-police@0.6.1: {}
@@ -6896,6 +6954,12 @@ snapshots:
cli-width@4.1.0: {}
+ cliui@6.0.0:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 6.2.0
+
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -7022,6 +7086,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decamelize@1.2.0: {}
+
deep-is@0.1.4: {}
deepmerge-ts@5.1.0: {}
@@ -7061,6 +7127,8 @@ snapshots:
diff-sequences@27.5.1: {}
+ dijkstrajs@1.0.3: {}
+
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0
@@ -8752,6 +8820,8 @@ snapshots:
pluralize@8.0.0: {}
+ pngjs@5.0.0: {}
+
possible-typed-array-names@1.1.0: {}
postcss-html@1.8.0:
@@ -8933,6 +9003,12 @@ snapshots:
punycode@2.3.1: {}
+ qrcode@1.5.4:
+ dependencies:
+ dijkstrajs: 1.0.3
+ pngjs: 5.0.0
+ yargs: 15.4.1
+
quansync@0.2.11: {}
querystringify@2.2.0: {}
@@ -9010,6 +9086,8 @@ snapshots:
require-from-string@2.0.2: {}
+ require-main-filename@2.0.0: {}
+
requires-port@1.0.0: {}
resolve-from@4.0.0: {}
@@ -9137,6 +9215,8 @@ snapshots:
serialize-to-js@3.1.2: {}
+ set-blocking@2.0.0: {}
+
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -10065,6 +10145,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
+ which-module@2.0.1: {}
+
which-typed-array@1.1.19:
dependencies:
available-typed-arrays: 1.0.7
@@ -10110,6 +10192,8 @@ snapshots:
xml-name-validator@4.0.0: {}
+ y18n@4.0.3: {}
+
y18n@5.0.8: {}
yallist@3.1.1: {}
@@ -10123,8 +10207,27 @@ snapshots:
yaml@2.8.1: {}
+ yargs-parser@18.1.3:
+ dependencies:
+ camelcase: 5.3.1
+ decamelize: 1.2.0
+
yargs-parser@21.1.1: {}
+ yargs@15.4.1:
+ dependencies:
+ cliui: 6.0.0
+ decamelize: 1.2.0
+ find-up: 4.1.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ require-main-filename: 2.0.0
+ set-blocking: 2.0.0
+ string-width: 4.2.3
+ which-module: 2.0.1
+ y18n: 4.0.3
+ yargs-parser: 18.1.3
+
yargs@17.7.2:
dependencies:
cliui: 8.0.1
diff --git a/apps/portal/src/components/auth/MfaChallengeCard.vue b/apps/portal/src/components/auth/MfaChallengeCard.vue
new file mode 100644
index 00000000..7345e59f
--- /dev/null
+++ b/apps/portal/src/components/auth/MfaChallengeCard.vue
@@ -0,0 +1,283 @@
+
+
+
+
+
+
+ Tweestapsverificatie
+
+
+ Voer je verificatiecode in om in te loggen
+
+
+
+ {{ formattedTimeLeft }}
+
+
+
+
+
+ onMethodChange(String(v))"
+ >
+
+
+ {{ tab.title }}
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+ {{
+ 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'
+ }}
+
+
+
+
+
+
+
+ Voer een van je backup codes in (formaat: XXXX-XXXX)
+
+
+
+
+
+
+
+
+
+
+
+
+ Verifiëren
+
+
+
+
+
+
+
+
+
+
+
+
+ Terug naar inloggen
+
+
+
+
+
+
+
+
+
diff --git a/apps/portal/src/components/settings/MfaDisableDialog.vue b/apps/portal/src/components/settings/MfaDisableDialog.vue
new file mode 100644
index 00000000..4b3ab61b
--- /dev/null
+++ b/apps/portal/src/components/settings/MfaDisableDialog.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+ Tweestapsverificatie uitschakelen
+
+
+
+
+ Weet je zeker dat je tweestapsverificatie wilt uitschakelen? Je account wordt minder veilig.
+
+
+
+ {{ errorMessage }}
+
+
+
+
+ Voer je {{ currentMethod === 'totp' ? 'authenticator code' : 'backup code' }} in ter bevestiging
+
+
+
+
+
+
+
+
+
+ Annuleren
+
+
+ Uitschakelen
+
+
+
+
+
diff --git a/apps/portal/src/components/settings/MfaEmailSetupDialog.vue b/apps/portal/src/components/settings/MfaEmailSetupDialog.vue
new file mode 100644
index 00000000..c76c481a
--- /dev/null
+++ b/apps/portal/src/components/settings/MfaEmailSetupDialog.vue
@@ -0,0 +1,276 @@
+
+
+
+
+
+
+
+ E-mailverificatie instellen
+
+
+
+
+
+
+ We sturen een verificatiecode naar {{ userEmail }}
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+ Annuleren
+
+
+ Code versturen
+
+
+
+
+
+
+
+
+ Code verstuurd! Check je inbox.
+
+
+
+ {{ errorMessage }}
+
+
+
+ Voer de 6-cijferige code in
+
+
+
+
+
+
+
+
+
+
+ Terug
+
+
+ Verifiëren
+
+
+
+
+
+
+
+
+ Bewaar deze codes op een veilige plek. Je kunt ze gebruiken als je geen toegang hebt tot je e-mail.
+
+
+
+
+ {{ code }}
+
+
+
+
+
+ Kopieer
+
+
+ Download
+
+
+
+
+
+
+
+
+
+ Voltooien
+
+
+
+
+
+
+
+
diff --git a/apps/portal/src/components/settings/MfaTotpSetupDialog.vue b/apps/portal/src/components/settings/MfaTotpSetupDialog.vue
new file mode 100644
index 00000000..26462c00
--- /dev/null
+++ b/apps/portal/src/components/settings/MfaTotpSetupDialog.vue
@@ -0,0 +1,291 @@
+
+
+
+
+
+
+
+ Authenticator app instellen
+
+
+
+
+
+
+ Scan de QR-code met je authenticator app (Google Authenticator, Authy, etc.)
+
+
+
+
![QR Code]()
+
+
+
+ QR-code wordt gegenereerd...
+
+
+
+
+
+ copyToClipboard(secret)"
+ />
+
+
+
+
+
+
+
+
+ Annuleren
+
+
+ Volgende
+
+
+
+
+
+
+
+
+ Open je authenticator app en voer de 6-cijferige code in
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
+ Terug
+
+
+ Verifiëren
+
+
+
+
+
+
+
+
+ Bewaar deze codes op een veilige plek. Je kunt ze gebruiken als je geen toegang hebt tot je authenticator app.
+
+
+
+
+ {{ code }}
+
+
+
+
+
+ Kopieer
+
+
+ Download
+
+
+
+
+
+
+
+
+
+ Voltooien
+
+
+
+
+
+
+
+
diff --git a/apps/portal/src/composables/api/useMfa.ts b/apps/portal/src/composables/api/useMfa.ts
new file mode 100644
index 00000000..6cac3623
--- /dev/null
+++ b/apps/portal/src/composables/api/useMfa.ts
@@ -0,0 +1,173 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
+import { apiClient } from '@/lib/axios'
+import type {
+ MfaConfirmResponse,
+ MfaStatus,
+ MfaTotpSetup,
+ MfaVerifyPayload,
+ TrustedDevice,
+} from '@/types/mfa'
+
+interface ApiResponse {
+ success: boolean
+ data: T
+ message: string
+}
+
+// ─── Setup ───
+
+export function useSetupTotp() {
+ return useMutation({
+ mutationFn: async () => {
+ const { data } = await apiClient.post>('/auth/mfa/setup/totp')
+
+ return data.data
+ },
+ })
+}
+
+export function useConfirmTotp() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (code: string) => {
+ const { data } = await apiClient.post>('/auth/mfa/setup/totp/confirm', { code })
+
+ return data.data
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
+ },
+ })
+}
+
+export function useSetupEmail() {
+ return useMutation({
+ mutationFn: async () => {
+ const { data } = await apiClient.post>('/auth/mfa/setup/email')
+
+ return data
+ },
+ })
+}
+
+export function useConfirmEmail() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (code: string) => {
+ const { data } = await apiClient.post>('/auth/mfa/setup/email/confirm', { code })
+
+ return data.data
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
+ },
+ })
+}
+
+// ─── Management ───
+
+export function useDisableMfa() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (payload: { code: string; method: string }) => {
+ const { data } = await apiClient.post>('/auth/mfa/disable', payload)
+
+ return data
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
+ },
+ })
+}
+
+export function useRegenerateBackupCodes() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (payload: { code: string }) => {
+ const { data } = await apiClient.post>('/auth/mfa/backup-codes', payload)
+
+ return data.data
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
+ },
+ })
+}
+
+export function useMfaStatus() {
+ return useQuery({
+ queryKey: ['mfa-status'],
+ queryFn: async () => {
+ const { data } = await apiClient.get>('/auth/mfa/status')
+
+ return data.data
+ },
+ })
+}
+
+// ─── Trusted devices ───
+
+export function useTrustedDevices() {
+ return useQuery({
+ queryKey: ['trusted-devices'],
+ queryFn: async () => {
+ const { data } = await apiClient.get>('/auth/trusted-devices')
+
+ return data.data
+ },
+ })
+}
+
+export function useRevokeDevice() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (id: string) => {
+ await apiClient.delete(`/auth/trusted-devices/${id}`)
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['trusted-devices'] })
+ },
+ })
+}
+
+export function useRevokeAllDevices() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async () => {
+ await apiClient.delete('/auth/trusted-devices')
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['trusted-devices'] })
+ },
+ })
+}
+
+// ─── Login flow (no auth needed — uses session token) ───
+
+export function useVerifyMfa() {
+ return useMutation({
+ mutationFn: async (payload: MfaVerifyPayload) => {
+ const { data } = await apiClient.post('/auth/mfa/verify', payload)
+
+ return data
+ },
+ })
+}
+
+export function useSendMfaEmailCode() {
+ return useMutation({
+ mutationFn: async (mfaSessionToken: string) => {
+ const { data } = await apiClient.post('/auth/mfa/email/send', {
+ mfa_session_token: mfaSessionToken,
+ })
+
+ return data
+ },
+ })
+}
diff --git a/apps/portal/src/pages/login.vue b/apps/portal/src/pages/login.vue
index 9f1748b7..53728686 100644
--- a/apps/portal/src/pages/login.vue
+++ b/apps/portal/src/pages/login.vue
@@ -1,6 +1,13 @@
-
-
-
-
-
-
-
- Crewli
-
-
-
+
+
+
-
-
- Welkom terug!
-
-
- Log in om je rooster en diensten te bekijken
-
+
-
- Wachtwoord gewijzigd. Je kunt nu inloggen.
-
+
+
-
- {{ errorMessage }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- Wachtwoord vergeten?
-
+
+
+
+
+
+
+
+
+ {{ themeConfig.app.title }}
+
+
+
+
-
- Inloggen
-
-
-
-
+
+
+ Welkom terug!
+
+
+ Log in om je rooster en diensten te bekijken
+
+
-
- Nog geen account?
-
+
- Meld je aan als vrijwilliger
-
-
-
-
+ Wachtwoord gewijzigd. Je kunt nu inloggen.
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Wachtwoord vergeten?
+
+
+
+
+ Inloggen
+
+
+
+
+ Nog geen account?
+
+ Meld je aan als vrijwilliger
+
+
+
+
+
+
+
+
+
diff --git a/apps/portal/src/pages/profiel.vue b/apps/portal/src/pages/profiel.vue
index ed0df2bc..223755fa 100644
--- a/apps/portal/src/pages/profiel.vue
+++ b/apps/portal/src/pages/profiel.vue
@@ -2,7 +2,11 @@
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/usePortalStore'
import { useUpdateProfile, useUpdatePassword } from '@/composables/api/usePortalProfile'
+import { useMfaStatus, useTrustedDevices, useRevokeDevice, useRevokeAllDevices } from '@/composables/api/useMfa'
import { apiClient } from '@/lib/axios'
+import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue'
+import MfaEmailSetupDialog from '@/components/settings/MfaEmailSetupDialog.vue'
+import MfaDisableDialog from '@/components/settings/MfaDisableDialog.vue'
definePage({
name: 'portal-profiel',
@@ -89,6 +93,36 @@ const showCurrentPassword = ref(false)
const showNewPassword = ref(false)
const showConfirmPassword = ref(false)
+// MFA
+const { data: mfaStatus, refetch: refetchMfaStatus } = useMfaStatus()
+const { data: trustedDevices, refetch: refetchDevices } = useTrustedDevices()
+const revokeDeviceMutation = useRevokeDevice()
+const revokeAllMutation = useRevokeAllDevices()
+
+const showTotpSetup = ref(false)
+const showEmailSetup = ref(false)
+const showDisableDialog = ref(false)
+const isMfaEnabled = computed(() => mfaStatus.value?.enabled ?? false)
+const mfaMethodLabel = computed(() => {
+ if (mfaStatus.value?.method === 'totp') return 'Authenticator app'
+ if (mfaStatus.value?.method === 'email') return 'E-mailcode'
+
+ return null
+})
+
+function onMfaSetupCompleted() { refetchMfaStatus() }
+function onMfaDisabled() { refetchMfaStatus(); refetchDevices() }
+
+async function handleRevokeDevice(id: string) {
+ await revokeDeviceMutation.mutateAsync(id)
+ refetchDevices()
+}
+
+async function handleRevokeAllDevices() {
+ await revokeAllMutation.mutateAsync()
+ refetchDevices()
+}
+
// Populate profile form from auth user / current person data
watch(
[() => authStore.user, () => portal.currentPerson],
@@ -477,6 +511,123 @@ async function savePassword() {
+
+
+
+
+ Beveiliging
+
+
+
+
+ Bescherm je account met tweestapsverificatie.
+
+
+
+ Authenticator app
+
+
+ E-mailcode
+
+
+
+
+
+
+
+
+ Ingeschakeld
+
+ via {{ mfaMethodLabel }}
+
+
+ Uitschakelen
+
+
+
+
+
+
+ Vertrouwde apparaten
+
+
+
+
+ {{ device.device_name ?? 'Onbekend apparaat' }}
+
+
+ IP: {{ device.ip_address }}
+
+
+
+
+
+
+
+
+ Alle apparaten intrekken
+
+
+
+
+
+
+
+
+
+
+
+
+
Mijn evenementen
diff --git a/apps/portal/src/pages/verify-email-change.vue b/apps/portal/src/pages/verify-email-change.vue
index 0c66e767..80cbbd8a 100644
--- a/apps/portal/src/pages/verify-email-change.vue
+++ b/apps/portal/src/pages/verify-email-change.vue
@@ -1,4 +1,8 @@
-
-
-
-
-
- Crewli
-
-
+
+
+
-
-
-
- E-mailadres wordt geverifieerd...
-
+
-
-
- tabler-circle-check
-
-
- E-mailadres gewijzigd!
-
-
- Je e-mailadres is succesvol gewijzigd.
- Log opnieuw in met je nieuwe e-mailadres.
-
-
- Ga naar inloggen
-
-
+
+
+
+
+
+
+
+ {{ themeConfig.app.title }}
+
+
+
+
+
-
-
- tabler-circle-x
-
-
- Verificatie mislukt
-
-
- {{ errorMessage }}
-
-
-
-
+
+
+
+ E-mailadres wordt geverifieerd...
+
+
+
+
+ tabler-circle-check
+
+
+ E-mailadres gewijzigd!
+
+
+ Je e-mailadres is succesvol gewijzigd.
+ Log opnieuw in met je nieuwe e-mailadres.
+
+
+ Ga naar inloggen
+
+
+
+
+
+ tabler-circle-x
+
+
+ Verificatie mislukt
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
diff --git a/apps/portal/src/pages/wachtwoord-resetten.vue b/apps/portal/src/pages/wachtwoord-resetten.vue
index 6f2f51e9..fc06362c 100644
--- a/apps/portal/src/pages/wachtwoord-resetten.vue
+++ b/apps/portal/src/pages/wachtwoord-resetten.vue
@@ -1,4 +1,8 @@
-
-
-
- Nieuw wachtwoord
-
-
- Kies een nieuw wachtwoord voor je account.
-
+
+
+
-
-
- {{ errorMessage }}
-
+
-
-
-
-
+
+
+
+
+
+
+ {{ themeConfig.app.title }}
+
+
+
+
+
+
+
+
+ Nieuw wachtwoord instellen
+
+
+ Kies een nieuw wachtwoord voor je account.
+
+
+
+
+
-
- Wachtwoord opslaan
-
-
+ {{ errorMessage }}
+
-
-
- Terug naar inloggen
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ Wachtwoord opslaan
+
+
+
+
+
+
+ Terug naar inloggen
+
+
+
+
+
+
+
+
+
diff --git a/apps/portal/src/pages/wachtwoord-vergeten.vue b/apps/portal/src/pages/wachtwoord-vergeten.vue
index 51eeb2ed..3fd64f1c 100644
--- a/apps/portal/src/pages/wachtwoord-vergeten.vue
+++ b/apps/portal/src/pages/wachtwoord-vergeten.vue
@@ -1,4 +1,8 @@
-
-
-
- Wachtwoord vergeten
-
-
- Vul je e-mailadres in. Als dit adres bij ons bekend is, ontvang je een link om je wachtwoord te resetten.
-
+
+
+
-
-
- Als dit e-mailadres bij ons bekend is, ontvang je een link om je wachtwoord te resetten.
-
+
-
-
+
+
+
+
+
+
+ {{ themeConfig.app.title }}
+
+
+
+
+
+
+
+
+ Wachtwoord vergeten?
+
+
+ Vul je e-mailadres in en we sturen je een link om je wachtwoord te herstellen.
+
+
+
+
+
-
- Versturen
-
-
+ Als dit e-mailadres bij ons bekend is, ontvang je een link om je wachtwoord te resetten.
+
-
-
- Terug naar inloggen
-
-
-
-
+
+
+
+
+
+
+
+ Verstuur herstelmail
+
+
+
+
+
+
+ Terug naar inloggen
+
+
+
+
+
+
+
+
+
diff --git a/apps/portal/src/types/mfa.ts b/apps/portal/src/types/mfa.ts
new file mode 100644
index 00000000..905cdd8f
--- /dev/null
+++ b/apps/portal/src/types/mfa.ts
@@ -0,0 +1,61 @@
+export const MfaMethod = {
+ TOTP: 'totp',
+ EMAIL: 'email',
+ BACKUP_CODE: 'backup_code',
+} as const
+
+export type MfaMethod = typeof MfaMethod[keyof typeof MfaMethod]
+
+export interface MfaStatus {
+ enabled: boolean
+ method: 'totp' | 'email' | null
+ confirmed_at: string | null
+ backup_codes_remaining: number
+ is_required: boolean
+}
+
+export interface MfaUserStatus {
+ enabled: boolean
+ method: 'totp' | 'email' | null
+ confirmed_at: string | null
+ setup_required: boolean
+}
+
+export interface MfaTotpSetup {
+ secret: string
+ qr_code_url: string
+ provisioning_uri: string
+}
+
+export interface MfaSessionResponse {
+ success: true
+ mfa_required: true
+ mfa_session_token: string
+ methods: MfaMethod[]
+ preferred_method: 'totp' | 'email'
+ expires_in: number
+}
+
+export interface MfaConfirmResponse {
+ mfa_enabled: boolean
+ method: 'totp' | 'email'
+ backup_codes: string[]
+}
+
+export interface TrustedDevice {
+ id: string
+ device_name: string | null
+ ip_address: string
+ trusted_until: string
+ last_used_at: string | null
+ created_at: string
+}
+
+export interface MfaVerifyPayload {
+ mfa_session_token: string
+ code: string
+ method: MfaMethod
+ trust_device?: boolean
+ device_fingerprint?: string
+ device_name?: string
+}
diff --git a/apps/portal/src/types/portal.ts b/apps/portal/src/types/portal.ts
index b230da84..acfadd84 100644
--- a/apps/portal/src/types/portal.ts
+++ b/apps/portal/src/types/portal.ts
@@ -37,6 +37,12 @@ export interface AuthMeUser {
roles?: string[]
permissions?: string[]
portal_events?: PortalEvent[]
+ mfa?: {
+ enabled: boolean
+ method: 'totp' | 'email' | null
+ confirmed_at: string | null
+ setup_required: boolean
+ }
}
/** GET /portal/me?event_id= — person payload (subset used by dashboard) */
diff --git a/apps/portal/src/utils/deviceFingerprint.ts b/apps/portal/src/utils/deviceFingerprint.ts
new file mode 100644
index 00000000..89f86154
--- /dev/null
+++ b/apps/portal/src/utils/deviceFingerprint.ts
@@ -0,0 +1,49 @@
+export function generateDeviceFingerprint(): string {
+ const components = [
+ navigator.userAgent,
+ `${screen.width}x${screen.height}`,
+ Intl.DateTimeFormat().resolvedOptions().timeZone,
+ navigator.language,
+ navigator.hardwareConcurrency?.toString() ?? '',
+ ]
+
+ const str = components.join('|')
+ let hash = 0
+
+ for (let i = 0; i < str.length; i++) {
+ const char = str.charCodeAt(i)
+
+ hash = ((hash << 5) - hash) + char
+ hash = hash & hash
+ }
+
+ return Math.abs(hash).toString(36)
+}
+
+export function getDeviceName(): string {
+ const ua = navigator.userAgent
+ let browser = 'Onbekend'
+ let os = 'Onbekend'
+
+ if (ua.includes('Chrome') && !ua.includes('Edg'))
+ browser = 'Chrome'
+ else if (ua.includes('Firefox'))
+ browser = 'Firefox'
+ else if (ua.includes('Safari') && !ua.includes('Chrome'))
+ browser = 'Safari'
+ else if (ua.includes('Edg'))
+ browser = 'Edge'
+
+ if (ua.includes('Mac OS'))
+ os = 'macOS'
+ else if (ua.includes('Windows'))
+ os = 'Windows'
+ else if (ua.includes('Linux'))
+ os = 'Linux'
+ else if (ua.includes('Android'))
+ os = 'Android'
+ else if (ua.includes('iPhone') || ua.includes('iPad'))
+ os = 'iOS'
+
+ return `${browser} op ${os}`
+}