Phase1
This commit is contained in:
1
.turbo/cache/01aded72040e0955-manifest.json
vendored
Normal file
1
.turbo/cache/01aded72040e0955-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/database/dist/index.d.ts":{"size":659,"mtime_nanos":1782465829247304200,"mode":420,"is_dir":false},"packages/database/.turbo/turbo-build.log":{"size":557,"mtime_nanos":1782466118524405900,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":493,"mtime_nanos":1782465829246056800,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}
|
||||
1
.turbo/cache/01aded72040e0955-meta.json
vendored
Normal file
1
.turbo/cache/01aded72040e0955-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"01aded72040e0955","duration":6480,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"03b38917e0caabc328cf004288c0be00386c5e17753015d7a61c3d1ab22411f8"}
|
||||
BIN
.turbo/cache/01aded72040e0955.tar.zst
vendored
Normal file
BIN
.turbo/cache/01aded72040e0955.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/0ff4947f463631d9-manifest.json
vendored
Normal file
1
.turbo/cache/0ff4947f463631d9-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"apps/worker/dist/logger.d.ts.map":{"size":168,"mtime_nanos":1782463923969877300,"mode":420,"is_dir":false},"apps/worker/dist/logger.js":{"size":616,"mtime_nanos":1782463923956015700,"mode":420,"is_dir":false},"apps/worker/dist/queues.js":{"size":538,"mtime_nanos":1782463924007328900,"mode":420,"is_dir":false},"apps/worker/dist/queues.d.ts.map":{"size":321,"mtime_nanos":1782463924016227600,"mode":420,"is_dir":false},"apps/worker/dist/index.js.map":{"size":1971,"mtime_nanos":1782463967922326200,"mode":420,"is_dir":false},"apps/worker/dist/logger.js.map":{"size":405,"mtime_nanos":1782463923953999600,"mode":420,"is_dir":false},"apps/worker/dist/queues.js.map":{"size":279,"mtime_nanos":1782463924006472100,"mode":420,"is_dir":false},"apps/worker/dist/index.d.ts.map":{"size":104,"mtime_nanos":1782465939461483800,"mode":420,"is_dir":false},"apps/worker/dist/main.js":{"size":483,"mtime_nanos":1782463715732328600,"mode":420,"is_dir":false},"apps/worker/dist/health.d.ts":{"size":156,"mtime_nanos":1782463924001574000,"mode":420,"is_dir":false},"apps/worker/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/worker/dist/index.d.ts":{"size":46,"mtime_nanos":1782463924052923900,"mode":420,"is_dir":false},"apps/worker/dist/index.js":{"size":1899,"mtime_nanos":1782463967923283600,"mode":420,"is_dir":false},"apps/worker/dist/health.js":{"size":2284,"mtime_nanos":1782463923995265100,"mode":420,"is_dir":false},"apps/worker/dist/health.d.ts.map":{"size":203,"mtime_nanos":1782465939458293400,"mode":420,"is_dir":false},"apps/worker/dist/queues.d.ts":{"size":400,"mtime_nanos":1782463924018216400,"mode":420,"is_dir":false},"apps/worker/dist/logger.d.ts":{"size":119,"mtime_nanos":1782463923971765000,"mode":420,"is_dir":false},"apps/worker/dist/health.js.map":{"size":1971,"mtime_nanos":1782463923993536700,"mode":420,"is_dir":false},"apps/worker/.turbo/turbo-build.log":{"size":101,"mtime_nanos":1782465939576291100,"mode":420,"is_dir":false}},"order":["apps/worker/.turbo/turbo-build.log","apps/worker/dist","apps/worker/dist/health.d.ts","apps/worker/dist/health.d.ts.map","apps/worker/dist/health.js","apps/worker/dist/health.js.map","apps/worker/dist/index.d.ts","apps/worker/dist/index.d.ts.map","apps/worker/dist/index.js","apps/worker/dist/index.js.map","apps/worker/dist/logger.d.ts","apps/worker/dist/logger.d.ts.map","apps/worker/dist/logger.js","apps/worker/dist/logger.js.map","apps/worker/dist/main.js","apps/worker/dist/queues.d.ts","apps/worker/dist/queues.d.ts.map","apps/worker/dist/queues.js","apps/worker/dist/queues.js.map"]}
|
||||
1
.turbo/cache/0ff4947f463631d9-meta.json
vendored
Normal file
1
.turbo/cache/0ff4947f463631d9-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"0ff4947f463631d9","duration":3577,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"dab8b0c461d299de5e05eef85179aa41f735ebc86a2dbd665aa7a8294d8e0f47"}
|
||||
BIN
.turbo/cache/0ff4947f463631d9.tar.zst
vendored
Normal file
BIN
.turbo/cache/0ff4947f463631d9.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/25ebafb8a1f6566c-manifest.json
vendored
Normal file
1
.turbo/cache/25ebafb8a1f6566c-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts":{"size":659,"mtime_nanos":1782465829247304200,"mode":420,"is_dir":false},"packages/database/.turbo/turbo-build.log":{"size":552,"mtime_nanos":1782466100200165100,"mode":420,"is_dir":false},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":493,"mtime_nanos":1782465829246056800,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}
|
||||
1
.turbo/cache/25ebafb8a1f6566c-meta.json
vendored
Normal file
1
.turbo/cache/25ebafb8a1f6566c-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"25ebafb8a1f6566c","duration":6143,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"60d70f90be15846aa2d4e953502544454caacdd04fb637eccb080515fac9d602"}
|
||||
BIN
.turbo/cache/25ebafb8a1f6566c.tar.zst
vendored
Normal file
BIN
.turbo/cache/25ebafb8a1f6566c.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/2ed462e18f772857-manifest.json
vendored
Normal file
1
.turbo/cache/2ed462e18f772857-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.d.ts":{"size":659,"mtime_nanos":1782465829247304200,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":493,"mtime_nanos":1782465829246056800,"mode":420,"is_dir":false},"packages/database/.turbo/turbo-build.log":{"size":557,"mtime_nanos":1782465935984650300,"mode":420,"is_dir":false},"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}
|
||||
1
.turbo/cache/2ed462e18f772857-meta.json
vendored
Normal file
1
.turbo/cache/2ed462e18f772857-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"2ed462e18f772857","duration":5689,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"dab8b0c461d299de5e05eef85179aa41f735ebc86a2dbd665aa7a8294d8e0f47"}
|
||||
BIN
.turbo/cache/2ed462e18f772857.tar.zst
vendored
Normal file
BIN
.turbo/cache/2ed462e18f772857.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/47b151676e5a4060-manifest.json
vendored
Normal file
1
.turbo/cache/47b151676e5a4060-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"apps/api/.turbo/turbo-build.log":{"size":102,"mtime_nanos":1782465944706564700,"mode":420,"is_dir":false}},"order":["apps/api/.turbo/turbo-build.log"]}
|
||||
1
.turbo/cache/47b151676e5a4060-meta.json
vendored
Normal file
1
.turbo/cache/47b151676e5a4060-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"47b151676e5a4060","duration":8707,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"dab8b0c461d299de5e05eef85179aa41f735ebc86a2dbd665aa7a8294d8e0f47"}
|
||||
BIN
.turbo/cache/47b151676e5a4060.tar.zst
vendored
Normal file
BIN
.turbo/cache/47b151676e5a4060.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/53c0d11a4e5fec2f-manifest.json
vendored
Normal file
1
.turbo/cache/53c0d11a4e5fec2f-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/53c0d11a4e5fec2f-meta.json
vendored
Normal file
1
.turbo/cache/53c0d11a4e5fec2f-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"53c0d11a4e5fec2f","duration":31664,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"dab8b0c461d299de5e05eef85179aa41f735ebc86a2dbd665aa7a8294d8e0f47"}
|
||||
BIN
.turbo/cache/53c0d11a4e5fec2f.tar.zst
vendored
Normal file
BIN
.turbo/cache/53c0d11a4e5fec2f.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/7f3e39c686a4adef-manifest.json
vendored
Normal file
1
.turbo/cache/7f3e39c686a4adef-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/contracts/.turbo/turbo-test.log":{"size":460,"mtime_nanos":1782466116227058300,"mode":420,"is_dir":false}},"order":["packages/contracts/.turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/7f3e39c686a4adef-meta.json
vendored
Normal file
1
.turbo/cache/7f3e39c686a4adef-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"7f3e39c686a4adef","duration":4194,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"03b38917e0caabc328cf004288c0be00386c5e17753015d7a61c3d1ab22411f8"}
|
||||
BIN
.turbo/cache/7f3e39c686a4adef.tar.zst
vendored
Normal file
BIN
.turbo/cache/7f3e39c686a4adef.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/9166eecf844f7fc3-manifest.json
vendored
Normal file
1
.turbo/cache/9166eecf844f7fc3-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"apps/web/.turbo/turbo-test.log":{"size":116,"mtime_nanos":1782466113148395100,"mode":420,"is_dir":false}},"order":["apps/web/.turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/9166eecf844f7fc3-meta.json
vendored
Normal file
1
.turbo/cache/9166eecf844f7fc3-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"9166eecf844f7fc3","duration":1118,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"03b38917e0caabc328cf004288c0be00386c5e17753015d7a61c3d1ab22411f8"}
|
||||
BIN
.turbo/cache/9166eecf844f7fc3.tar.zst
vendored
Normal file
BIN
.turbo/cache/9166eecf844f7fc3.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/95146894d818d73f-manifest.json
vendored
Normal file
1
.turbo/cache/95146894d818d73f-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"apps/worker/.turbo/turbo-test.log":{"size":833,"mtime_nanos":1782466119907026600,"mode":420,"is_dir":false}},"order":["apps/worker/.turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/95146894d818d73f-meta.json
vendored
Normal file
1
.turbo/cache/95146894d818d73f-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"95146894d818d73f","duration":1367,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"03b38917e0caabc328cf004288c0be00386c5e17753015d7a61c3d1ab22411f8"}
|
||||
BIN
.turbo/cache/95146894d818d73f.tar.zst
vendored
Normal file
BIN
.turbo/cache/95146894d818d73f.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/b82b7e64d65adff3-manifest.json
vendored
Normal file
1
.turbo/cache/b82b7e64d65adff3-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"apps/api/.turbo/turbo-test.log":{"size":1536,"mtime_nanos":1782466120026339500,"mode":420,"is_dir":false}},"order":["apps/api/.turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/b82b7e64d65adff3-meta.json
vendored
Normal file
1
.turbo/cache/b82b7e64d65adff3-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"b82b7e64d65adff3","duration":1487,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"03b38917e0caabc328cf004288c0be00386c5e17753015d7a61c3d1ab22411f8"}
|
||||
BIN
.turbo/cache/b82b7e64d65adff3.tar.zst
vendored
Normal file
BIN
.turbo/cache/b82b7e64d65adff3.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/c4f5349c646cc586-manifest.json
vendored
Normal file
1
.turbo/cache/c4f5349c646cc586-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/auth/dist/username.js.map":{"size":2294,"mtime_nanos":1782465554881263200,"mode":420,"is_dir":false},"packages/auth/dist/password.js":{"size":482,"mtime_nanos":1782465601160335200,"mode":420,"is_dir":false},"packages/auth/dist/tokens.d.ts.map":{"size":281,"mtime_nanos":1782465601179167500,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.d.ts.map":{"size":306,"mtime_nanos":1782465601182584000,"mode":420,"is_dir":false},"packages/auth/dist/tokens.d.ts":{"size":244,"mtime_nanos":1782465601180160400,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.d.ts":{"size":264,"mtime_nanos":1782465554918003300,"mode":420,"is_dir":false},"packages/auth/dist/totp.js.map":{"size":569,"mtime_nanos":1782465601190229800,"mode":420,"is_dir":false},"packages/auth/dist/session.d.ts.map":{"size":300,"mtime_nanos":1782465601208663900,"mode":420,"is_dir":false},"packages/auth/dist/index.d.ts":{"size":1276,"mtime_nanos":1782465818955775000,"mode":420,"is_dir":false},"packages/auth/dist/username.d.ts":{"size":442,"mtime_nanos":1782465554886052800,"mode":420,"is_dir":false},"packages/auth/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/auth/dist/index.d.ts.map":{"size":1165,"mtime_nanos":1782465818954796800,"mode":420,"is_dir":false},"packages/auth/dist/password.d.ts":{"size":200,"mtime_nanos":1782465601164974400,"mode":420,"is_dir":false},"packages/auth/dist/totp.d.ts":{"size":269,"mtime_nanos":1782465601197336500,"mode":420,"is_dir":false},"packages/auth/dist/totp.d.ts.map":{"size":303,"mtime_nanos":1782465601196214800,"mode":420,"is_dir":false},"packages/auth/dist/index.js":{"size":4135,"mtime_nanos":1782465818948313900,"mode":420,"is_dir":false},"packages/auth/dist/username.js":{"size":2787,"mtime_nanos":1782465554883368700,"mode":420,"is_dir":false},"packages/auth/dist/index.js.map":{"size":824,"mtime_nanos":1782465818948313900,"mode":420,"is_dir":false},"packages/auth/dist/password.d.ts.map":{"size":266,"mtime_nanos":1782465601163298800,"mode":420,"is_dir":false},"packages/auth/dist/session.js.map":{"size":493,"mtime_nanos":1782465601204871400,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.js":{"size":1089,"mtime_nanos":1782465554915914700,"mode":420,"is_dir":false},"packages/auth/.turbo/turbo-build.log":{"size":101,"mtime_nanos":1782465932714375000,"mode":420,"is_dir":false},"packages/auth/dist/password.js.map":{"size":404,"mtime_nanos":1782465601159318800,"mode":420,"is_dir":false},"packages/auth/dist/session.js":{"size":672,"mtime_nanos":1782465601204871400,"mode":420,"is_dir":false},"packages/auth/dist/username.d.ts.map":{"size":430,"mtime_nanos":1782465554884181600,"mode":420,"is_dir":false},"packages/auth/dist/tokens.js":{"size":864,"mtime_nanos":1782465601176169100,"mode":420,"is_dir":false},"packages/auth/dist/totp.js":{"size":683,"mtime_nanos":1782465601193241100,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.js.map":{"size":949,"mtime_nanos":1782465554915097200,"mode":420,"is_dir":false},"packages/auth/dist/tokens.js.map":{"size":760,"mtime_nanos":1782465601174864300,"mode":420,"is_dir":false},"packages/auth/dist/session.d.ts":{"size":334,"mtime_nanos":1782465601209666600,"mode":420,"is_dir":false}},"order":["packages/auth/.turbo/turbo-build.log","packages/auth/dist","packages/auth/dist/index.d.ts","packages/auth/dist/index.d.ts.map","packages/auth/dist/index.js","packages/auth/dist/index.js.map","packages/auth/dist/password.d.ts","packages/auth/dist/password.d.ts.map","packages/auth/dist/password.js","packages/auth/dist/password.js.map","packages/auth/dist/recovery-codes.d.ts","packages/auth/dist/recovery-codes.d.ts.map","packages/auth/dist/recovery-codes.js","packages/auth/dist/recovery-codes.js.map","packages/auth/dist/session.d.ts","packages/auth/dist/session.d.ts.map","packages/auth/dist/session.js","packages/auth/dist/session.js.map","packages/auth/dist/tokens.d.ts","packages/auth/dist/tokens.d.ts.map","packages/auth/dist/tokens.js","packages/auth/dist/tokens.js.map","packages/auth/dist/totp.d.ts","packages/auth/dist/totp.d.ts.map","packages/auth/dist/totp.js","packages/auth/dist/totp.js.map","packages/auth/dist/username.d.ts","packages/auth/dist/username.d.ts.map","packages/auth/dist/username.js","packages/auth/dist/username.js.map"]}
|
||||
1
.turbo/cache/c4f5349c646cc586-meta.json
vendored
Normal file
1
.turbo/cache/c4f5349c646cc586-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"c4f5349c646cc586","duration":2420,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"dab8b0c461d299de5e05eef85179aa41f735ebc86a2dbd665aa7a8294d8e0f47"}
|
||||
BIN
.turbo/cache/c4f5349c646cc586.tar.zst
vendored
Normal file
BIN
.turbo/cache/c4f5349c646cc586.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/d0ee437f2933e1de-manifest.json
vendored
Normal file
1
.turbo/cache/d0ee437f2933e1de-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/auth/.turbo/turbo-test.log":{"size":487,"mtime_nanos":1782466116773769300,"mode":420,"is_dir":false}},"order":["packages/auth/.turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/d0ee437f2933e1de-meta.json
vendored
Normal file
1
.turbo/cache/d0ee437f2933e1de-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"d0ee437f2933e1de","duration":4736,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"03b38917e0caabc328cf004288c0be00386c5e17753015d7a61c3d1ab22411f8"}
|
||||
BIN
.turbo/cache/d0ee437f2933e1de.tar.zst
vendored
Normal file
BIN
.turbo/cache/d0ee437f2933e1de.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/d13c0ab7d99bf010-manifest.json
vendored
Normal file
1
.turbo/cache/d13c0ab7d99bf010-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/contracts/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/contracts/dist/error.test.d.ts.map":{"size":114,"mtime_nanos":1782464453203196100,"mode":420,"is_dir":false},"packages/contracts/.turbo/turbo-build.log":{"size":111,"mtime_nanos":1782465932139811600,"mode":420,"is_dir":false},"packages/contracts/dist/error.js.map":{"size":934,"mtime_nanos":1782464568113270500,"mode":420,"is_dir":false},"packages/contracts/dist/auth.d.ts.map":{"size":1884,"mtime_nanos":1782465821709751500,"mode":420,"is_dir":false},"packages/contracts/dist/health.d.ts.map":{"size":506,"mtime_nanos":1782464568148241500,"mode":420,"is_dir":false},"packages/contracts/dist/pagination.js.map":{"size":1494,"mtime_nanos":1782464568160111800,"mode":420,"is_dir":false},"packages/contracts/dist/error.test.d.ts":{"size":51,"mtime_nanos":1782464453206177100,"mode":420,"is_dir":false},"packages/contracts/dist/health.test.d.ts.map":{"size":116,"mtime_nanos":1782464453232100100,"mode":420,"is_dir":false},"packages/contracts/dist/pagination.d.ts.map":{"size":606,"mtime_nanos":1782464568166709600,"mode":420,"is_dir":false},"packages/contracts/dist/health.js":{"size":801,"mtime_nanos":1782464568141250100,"mode":420,"is_dir":false},"packages/contracts/dist/index.d.ts.map":{"size":883,"mtime_nanos":1782465821731153000,"mode":420,"is_dir":false},"packages/contracts/dist/auth.js.map":{"size":3013,"mtime_nanos":1782465821686984900,"mode":420,"is_dir":false},"packages/contracts/dist/error.d.ts":{"size":1291,"mtime_nanos":1782463690806846900,"mode":420,"is_dir":false},"packages/contracts/dist/index.js":{"size":4696,"mtime_nanos":1782465821727769200,"mode":420,"is_dir":false},"packages/contracts/dist/health.d.ts":{"size":2265,"mtime_nanos":1782463690835044200,"mode":420,"is_dir":false},"packages/contracts/dist/pagination.d.ts":{"size":2597,"mtime_nanos":1782463690861646700,"mode":420,"is_dir":false},"packages/contracts/dist/health.test.js.map":{"size":762,"mtime_nanos":1782464453228542000,"mode":420,"is_dir":false},"packages/contracts/dist/auth.js":{"size":3154,"mtime_nanos":1782465821688485700,"mode":420,"is_dir":false},"packages/contracts/dist/error.test.js.map":{"size":505,"mtime_nanos":1782464453199560800,"mode":420,"is_dir":false},"packages/contracts/dist/pagination.js":{"size":1468,"mtime_nanos":1782464568161250400,"mode":420,"is_dir":false},"packages/contracts/dist/index.js.map":{"size":752,"mtime_nanos":1782465821726766700,"mode":420,"is_dir":false},"packages/contracts/dist/health.test.js":{"size":873,"mtime_nanos":1782464453229688300,"mode":420,"is_dir":false},"packages/contracts/dist/error.test.js":{"size":654,"mtime_nanos":1782464453201656200,"mode":420,"is_dir":false},"packages/contracts/dist/auth.d.ts":{"size":8320,"mtime_nanos":1782465821712321700,"mode":420,"is_dir":false},"packages/contracts/dist/health.test.d.ts":{"size":52,"mtime_nanos":1782464453233717000,"mode":420,"is_dir":false},"packages/contracts/dist/error.js":{"size":954,"mtime_nanos":1782464568114744400,"mode":420,"is_dir":false},"packages/contracts/dist/index.d.ts":{"size":1238,"mtime_nanos":1782465821732543500,"mode":420,"is_dir":false},"packages/contracts/dist/error.d.ts.map":{"size":402,"mtime_nanos":1782464568129438300,"mode":420,"is_dir":false},"packages/contracts/dist/health.js.map":{"size":722,"mtime_nanos":1782464568140218200,"mode":420,"is_dir":false}},"order":["packages/contracts/.turbo/turbo-build.log","packages/contracts/dist","packages/contracts/dist/auth.d.ts","packages/contracts/dist/auth.d.ts.map","packages/contracts/dist/auth.js","packages/contracts/dist/auth.js.map","packages/contracts/dist/error.d.ts","packages/contracts/dist/error.d.ts.map","packages/contracts/dist/error.js","packages/contracts/dist/error.js.map","packages/contracts/dist/error.test.d.ts","packages/contracts/dist/error.test.d.ts.map","packages/contracts/dist/error.test.js","packages/contracts/dist/error.test.js.map","packages/contracts/dist/health.d.ts","packages/contracts/dist/health.d.ts.map","packages/contracts/dist/health.js","packages/contracts/dist/health.js.map","packages/contracts/dist/health.test.d.ts","packages/contracts/dist/health.test.d.ts.map","packages/contracts/dist/health.test.js","packages/contracts/dist/health.test.js.map","packages/contracts/dist/index.d.ts","packages/contracts/dist/index.d.ts.map","packages/contracts/dist/index.js","packages/contracts/dist/index.js.map","packages/contracts/dist/pagination.d.ts","packages/contracts/dist/pagination.d.ts.map","packages/contracts/dist/pagination.js","packages/contracts/dist/pagination.js.map"]}
|
||||
1
.turbo/cache/d13c0ab7d99bf010-meta.json
vendored
Normal file
1
.turbo/cache/d13c0ab7d99bf010-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"d13c0ab7d99bf010","duration":1848,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"dab8b0c461d299de5e05eef85179aa41f735ebc86a2dbd665aa7a8294d8e0f47"}
|
||||
BIN
.turbo/cache/d13c0ab7d99bf010.tar.zst
vendored
Normal file
BIN
.turbo/cache/d13c0ab7d99bf010.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/e7696fafab0d8009-manifest.json
vendored
Normal file
1
.turbo/cache/e7696fafab0d8009-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"apps/api/dist/auth/auth.service.js":{"size":20605,"mtime_nanos":1782466149221310500,"mode":420,"is_dir":false},"apps/api/.turbo/turbo-build.log":{"size":102,"mtime_nanos":1782466151017456000,"mode":420,"is_dir":false},"apps/api/dist/auth":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/api/dist/auth/auth.service.js.map":{"size":16745,"mtime_nanos":1782466149219325900,"mode":420,"is_dir":false},"apps/api/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/api/dist/auth/auth.service.d.ts":{"size":3266,"mtime_nanos":1782466149238660800,"mode":420,"is_dir":false},"apps/api/dist/auth/auth.service.d.ts.map":{"size":2357,"mtime_nanos":1782466149237048000,"mode":420,"is_dir":false}},"order":["apps/api/.turbo/turbo-build.log","apps/api/dist","apps/api/dist/auth","apps/api/dist/auth/auth.service.d.ts","apps/api/dist/auth/auth.service.d.ts.map","apps/api/dist/auth/auth.service.js","apps/api/dist/auth/auth.service.js.map"]}
|
||||
1
.turbo/cache/e7696fafab0d8009-meta.json
vendored
Normal file
1
.turbo/cache/e7696fafab0d8009-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"e7696fafab0d8009","duration":8228,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"e6e4282d4fd323bd1a7662eaa7f02275fa33de505ff447ad71e6eb9efeeffde7"}
|
||||
BIN
.turbo/cache/e7696fafab0d8009.tar.zst
vendored
Normal file
BIN
.turbo/cache/e7696fafab0d8009.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/fac30371717dc3c3-manifest.json
vendored
Normal file
1
.turbo/cache/fac30371717dc3c3-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"apps/worker/dist/email.js":{"size":1281,"mtime_nanos":1782466146526704000,"mode":420,"is_dir":false},"apps/worker/dist/health.js.map":{"size":1971,"mtime_nanos":1782463923993536700,"mode":420,"is_dir":false},"apps/worker/dist/health.js":{"size":2284,"mtime_nanos":1782463923995265100,"mode":420,"is_dir":false},"apps/worker/dist/main.js":{"size":483,"mtime_nanos":1782463715732328600,"mode":420,"is_dir":false},"apps/worker/dist/index.js.map":{"size":2073,"mtime_nanos":1782466103963162800,"mode":420,"is_dir":false},"apps/worker/dist/queues.d.ts":{"size":400,"mtime_nanos":1782463924018216400,"mode":420,"is_dir":false},"apps/worker/dist/email.js.map":{"size":1083,"mtime_nanos":1782466146525354500,"mode":420,"is_dir":false},"apps/worker/dist/config.d.ts.map":{"size":423,"mtime_nanos":1782466103911968500,"mode":420,"is_dir":false},"apps/worker/dist/index.d.ts.map":{"size":104,"mtime_nanos":1782466103966105700,"mode":420,"is_dir":false},"apps/worker/dist/logger.d.ts":{"size":119,"mtime_nanos":1782463923971765000,"mode":420,"is_dir":false},"apps/worker/dist/config.d.ts":{"size":1709,"mtime_nanos":1782466103913596200,"mode":420,"is_dir":false},"apps/worker/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/worker/dist/handlers/notifications.d.ts.map":{"size":268,"mtime_nanos":1782466146543868200,"mode":420,"is_dir":false},"apps/worker/dist/handlers/notifications.js":{"size":1408,"mtime_nanos":1782466146541708500,"mode":420,"is_dir":false},"apps/worker/dist/health.d.ts":{"size":156,"mtime_nanos":1782463924001574000,"mode":420,"is_dir":false},"apps/worker/dist/health.d.ts.map":{"size":203,"mtime_nanos":1782465939458293400,"mode":420,"is_dir":false},"apps/worker/dist/logger.d.ts.map":{"size":168,"mtime_nanos":1782463923969877300,"mode":420,"is_dir":false},"apps/worker/dist/queues.js":{"size":538,"mtime_nanos":1782463924007328900,"mode":420,"is_dir":false},"apps/worker/.turbo/turbo-build.log":{"size":101,"mtime_nanos":1782466146664510000,"mode":420,"is_dir":false},"apps/worker/dist/config.js":{"size":1380,"mtime_nanos":1782466103903595700,"mode":420,"is_dir":false},"apps/worker/dist/email.d.ts":{"size":224,"mtime_nanos":1782466103931514600,"mode":420,"is_dir":false},"apps/worker/dist/index.js":{"size":2084,"mtime_nanos":1782466103964630600,"mode":420,"is_dir":false},"apps/worker/dist/index.d.ts":{"size":46,"mtime_nanos":1782463924052923900,"mode":420,"is_dir":false},"apps/worker/dist/config.js.map":{"size":1429,"mtime_nanos":1782466103902596800,"mode":420,"is_dir":false},"apps/worker/dist/queues.d.ts.map":{"size":321,"mtime_nanos":1782463924016227600,"mode":420,"is_dir":false},"apps/worker/dist/handlers":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/worker/dist/logger.js.map":{"size":405,"mtime_nanos":1782463923953999600,"mode":420,"is_dir":false},"apps/worker/dist/queues.js.map":{"size":279,"mtime_nanos":1782463924006472100,"mode":420,"is_dir":false},"apps/worker/dist/logger.js":{"size":616,"mtime_nanos":1782463923956015700,"mode":420,"is_dir":false},"apps/worker/dist/handlers/notifications.d.ts":{"size":164,"mtime_nanos":1782466103948066800,"mode":420,"is_dir":false},"apps/worker/dist/handlers/notifications.js.map":{"size":1195,"mtime_nanos":1782466146540753700,"mode":420,"is_dir":false},"apps/worker/dist/email.d.ts.map":{"size":308,"mtime_nanos":1782466146529665100,"mode":420,"is_dir":false}},"order":["apps/worker/.turbo/turbo-build.log","apps/worker/dist","apps/worker/dist/config.d.ts","apps/worker/dist/config.d.ts.map","apps/worker/dist/config.js","apps/worker/dist/config.js.map","apps/worker/dist/email.d.ts","apps/worker/dist/email.d.ts.map","apps/worker/dist/email.js","apps/worker/dist/email.js.map","apps/worker/dist/handlers","apps/worker/dist/handlers/notifications.d.ts","apps/worker/dist/handlers/notifications.d.ts.map","apps/worker/dist/handlers/notifications.js","apps/worker/dist/handlers/notifications.js.map","apps/worker/dist/health.d.ts","apps/worker/dist/health.d.ts.map","apps/worker/dist/health.js","apps/worker/dist/health.js.map","apps/worker/dist/index.d.ts","apps/worker/dist/index.d.ts.map","apps/worker/dist/index.js","apps/worker/dist/index.js.map","apps/worker/dist/logger.d.ts","apps/worker/dist/logger.d.ts.map","apps/worker/dist/logger.js","apps/worker/dist/logger.js.map","apps/worker/dist/main.js","apps/worker/dist/queues.d.ts","apps/worker/dist/queues.d.ts.map","apps/worker/dist/queues.js","apps/worker/dist/queues.js.map"]}
|
||||
1
.turbo/cache/fac30371717dc3c3-meta.json
vendored
Normal file
1
.turbo/cache/fac30371717dc3c3-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"fac30371717dc3c3","duration":3877,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"e6e4282d4fd323bd1a7662eaa7f02275fa33de505ff447ad71e6eb9efeeffde7"}
|
||||
BIN
.turbo/cache/fac30371717dc3c3.tar.zst
vendored
Normal file
BIN
.turbo/cache/fac30371717dc3c3.tar.zst
vendored
Normal file
Binary file not shown.
20
README.md
20
README.md
@@ -92,10 +92,28 @@ make build
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
**Phase 0** (Repository & Grundlagen) — abgeschlossen.
|
||||
**Phase 1** (Authentifizierung) — abgeschlossen.
|
||||
|
||||
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details und nächste Schritte.
|
||||
|
||||
### Auth lokal testen
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
pnpm dev:infra
|
||||
pnpm db:migrate
|
||||
pnpm dev
|
||||
|
||||
# Admin anlegen (keine Default-Zugangsdaten)
|
||||
pnpm bootstrap:admin -- --email admin@localhost --password 'min-12-chars-here'
|
||||
```
|
||||
|
||||
| Flow | URL |
|
||||
|------|-----|
|
||||
| Registrierung | http://localhost:3000/register |
|
||||
| Login | http://localhost:3000/login |
|
||||
| E-Mails (Dev) | http://localhost:8025 (Mailpit) |
|
||||
|
||||
## Lizenz
|
||||
|
||||
Proprietär — siehe Repository-Eigentümer.
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
"test": "node --test test/**/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/static": "^8.0.4",
|
||||
"@hexahost/auth": "workspace:*",
|
||||
"@hexahost/config": "workspace:*",
|
||||
"@hexahost/contracts": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
@@ -21,12 +23,15 @@
|
||||
"@nestjs/platform-fastify": "^11.0.7",
|
||||
"@nestjs/swagger": "^11.0.3",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"bullmq": "^5.34.8",
|
||||
"fastify": "^5.2.1",
|
||||
"ioredis": "^5.4.2",
|
||||
"nestjs-pino": "^4.3.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-http": "^10.4.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hexahost/typescript-config": "workspace:*",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AppConfigModule } from './config/app-config.module';
|
||||
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
@@ -32,7 +34,9 @@ import { LoggerModule } from 'nestjs-pino';
|
||||
},
|
||||
]),
|
||||
PrismaModule,
|
||||
AppConfigModule,
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
31
apps/api/src/auth/audit.service.ts
Normal file
31
apps/api/src/auth/audit.service.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@hexahost/database';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface AuditEventInput {
|
||||
action: string;
|
||||
userId?: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async record(input: AuditEventInput): Promise<void> {
|
||||
await this.prisma.auditEvent.create({
|
||||
data: {
|
||||
action: input.action,
|
||||
userId: input.userId,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
metadata: input.metadata as Prisma.InputJsonValue | undefined,
|
||||
ipAddress: input.ipAddress,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
203
apps/api/src/auth/auth.controller.ts
Normal file
203
apps/api/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { SkipThrottle, Throttle } from '@nestjs/throttler';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import {
|
||||
forgotPasswordRequestSchema,
|
||||
loginRequestSchema,
|
||||
registerRequestSchema,
|
||||
resetPasswordRequestSchema,
|
||||
twoFactorConfirmRequestSchema,
|
||||
twoFactorDisableRequestSchema,
|
||||
twoFactorVerifyRequestSchema,
|
||||
verifyEmailRequestSchema,
|
||||
} from '@hexahost/contracts';
|
||||
|
||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentUser, Public } from './decorators/current-user.decorator';
|
||||
import { SessionGuard } from './guards/session.guard';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@SkipThrottle()
|
||||
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly sessionService: SessionService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('register')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Register a new account' })
|
||||
@ApiResponse({ status: 201, description: 'Account created' })
|
||||
register(
|
||||
@Body(new ZodValidationPipe(registerRequestSchema)) body: unknown,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.authService.register(
|
||||
body as Parameters<AuthService['register']>[0],
|
||||
request.ip,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
login(
|
||||
@Body(new ZodValidationPipe(loginRequestSchema)) body: unknown,
|
||||
@Req() request: FastifyRequest,
|
||||
@Res({ passthrough: true }) reply: FastifyReply,
|
||||
) {
|
||||
return this.authService.login(
|
||||
body as Parameters<AuthService['login']>[0],
|
||||
reply,
|
||||
request.ip,
|
||||
request.headers['user-agent'],
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Logout current session' })
|
||||
logout(
|
||||
@Req() request: FastifyRequest,
|
||||
@Res({ passthrough: true }) reply: FastifyReply,
|
||||
) {
|
||||
const token = this.sessionService.readSessionToken(request);
|
||||
return this.authService.logout(token, reply, request.ip);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('verify-email')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Verify email address' })
|
||||
verifyEmail(
|
||||
@Body(new ZodValidationPipe(verifyEmailRequestSchema)) body: unknown,
|
||||
@Query('token') queryToken: string | undefined,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
const payload = body as { token?: string };
|
||||
const token = payload.token ?? queryToken;
|
||||
|
||||
if (!token) {
|
||||
throw new BadRequestException('Verification token is required');
|
||||
}
|
||||
|
||||
return this.authService.verifyEmail({ token }, request.ip);
|
||||
}
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@Post('resend-verification')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Resend email verification' })
|
||||
resendVerification(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.authService.resendVerification(user.id, request.ip);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('forgot-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Request password reset email' })
|
||||
forgotPassword(
|
||||
@Body(new ZodValidationPipe(forgotPasswordRequestSchema)) body: unknown,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.authService.forgotPassword(
|
||||
body as Parameters<AuthService['forgotPassword']>[0],
|
||||
request.ip,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('reset-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Reset password with token' })
|
||||
resetPassword(
|
||||
@Body(new ZodValidationPipe(resetPasswordRequestSchema)) body: unknown,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.authService.resetPassword(
|
||||
body as Parameters<AuthService['resetPassword']>[0],
|
||||
request.ip,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@Post('2fa/setup')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Begin two-factor setup' })
|
||||
setupTwoFactor(@CurrentUser() user: { id: string }) {
|
||||
return this.authService.setupTwoFactor(user.id);
|
||||
}
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@Post('2fa/confirm')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Confirm two-factor setup' })
|
||||
confirmTwoFactor(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body(new ZodValidationPipe(twoFactorConfirmRequestSchema)) body: unknown,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.authService.confirmTwoFactor(
|
||||
user.id,
|
||||
body as Parameters<AuthService['confirmTwoFactor']>[1],
|
||||
request.ip,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('2fa/verify')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Complete login with two-factor code' })
|
||||
verifyTwoFactor(
|
||||
@Body(new ZodValidationPipe(twoFactorVerifyRequestSchema)) body: unknown,
|
||||
@Req() request: FastifyRequest,
|
||||
@Res({ passthrough: true }) reply: FastifyReply,
|
||||
) {
|
||||
return this.authService.verifyTwoFactorLogin(
|
||||
body as Parameters<AuthService['verifyTwoFactorLogin']>[0],
|
||||
reply,
|
||||
request.ip,
|
||||
request.headers['user-agent'],
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@Post('2fa/disable')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Disable two-factor authentication' })
|
||||
disableTwoFactor(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body(new ZodValidationPipe(twoFactorDisableRequestSchema)) body: unknown,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.authService.disableTwoFactor(
|
||||
user.id,
|
||||
body as Parameters<AuthService['disableTwoFactor']>[1],
|
||||
request.ip,
|
||||
);
|
||||
}
|
||||
}
|
||||
52
apps/api/src/auth/auth.module.ts
Normal file
52
apps/api/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { AppConfigModule } from '../config/app-config.module';
|
||||
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import {
|
||||
AuthService,
|
||||
NOTIFICATIONS_QUEUE,
|
||||
REDIS_CLIENT,
|
||||
} from './auth.service';
|
||||
import { SessionGuard } from './guards/session.guard';
|
||||
import { MeController } from './me.controller';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@Module({
|
||||
imports: [AppConfigModule],
|
||||
controllers: [AuthController, MeController],
|
||||
providers: [
|
||||
AuthService,
|
||||
SessionService,
|
||||
AuditService,
|
||||
SessionGuard,
|
||||
{
|
||||
provide: REDIS_CLIENT,
|
||||
useFactory: () => {
|
||||
const config = getConfig();
|
||||
return new Redis(config.REDIS_URL, {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: NOTIFICATIONS_QUEUE,
|
||||
useFactory: () => {
|
||||
const config = getConfig();
|
||||
return new Queue('notifications', {
|
||||
connection: {
|
||||
url: config.REDIS_URL,
|
||||
maxRetriesPerRequest: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [AuthService, SessionService, SessionGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
711
apps/api/src/auth/auth.service.ts
Normal file
711
apps/api/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,711 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import type Redis from 'ioredis';
|
||||
|
||||
import {
|
||||
buildOtpAuthUrl,
|
||||
generateRecoveryCodes,
|
||||
generateSecureToken,
|
||||
generateTotpSecret,
|
||||
generateUsernameFromEmail,
|
||||
hashPassword,
|
||||
hashRecoveryCode,
|
||||
hashToken,
|
||||
validateUsername,
|
||||
verifyPassword,
|
||||
verifyRecoveryCode,
|
||||
verifyTotpCode,
|
||||
} from '@hexahost/auth';
|
||||
import type {
|
||||
AuthUser,
|
||||
ForgotPasswordRequest,
|
||||
LoginRequest,
|
||||
MessageResponse,
|
||||
RegisterRequest,
|
||||
ResetPasswordRequest,
|
||||
TwoFactorConfirmRequest,
|
||||
TwoFactorDisableRequest,
|
||||
TwoFactorVerifyRequest,
|
||||
VerifyEmailRequest,
|
||||
} from '@hexahost/contracts';
|
||||
import type { AppConfig } from '@hexahost/config';
|
||||
|
||||
import { APP_CONFIG } from '../config/config.constants';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
import { AuditService } from './audit.service';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
const LOCKOUT_ATTEMPTS = 5;
|
||||
const LOCKOUT_DURATION_MS = 15 * 60 * 1000;
|
||||
const LOGIN_CHALLENGE_TTL_SECONDS = 300;
|
||||
const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
export const REDIS_CLIENT = Symbol('REDIS_CLIENT');
|
||||
export const NOTIFICATIONS_QUEUE = Symbol('NOTIFICATIONS_QUEUE');
|
||||
|
||||
interface LoginChallengePayload {
|
||||
userId: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
interface NotificationEmailPayload {
|
||||
type: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
template: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly auditService: AuditService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
@Inject(NOTIFICATIONS_QUEUE) private readonly notificationsQueue: Queue,
|
||||
) {}
|
||||
|
||||
async register(
|
||||
input: RegisterRequest,
|
||||
ipAddress?: string,
|
||||
): Promise<MessageResponse> {
|
||||
const email = input.email.trim().toLowerCase();
|
||||
const existing = await this.prisma.user.findUnique({ where: { email } });
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Email is already registered');
|
||||
}
|
||||
|
||||
let username = input.username?.trim().toLowerCase();
|
||||
|
||||
if (username) {
|
||||
const validation = validateUsername(username);
|
||||
if (!validation.valid) {
|
||||
throw new BadRequestException(validation.error);
|
||||
}
|
||||
} else {
|
||||
username = generateUsernameFromEmail(email);
|
||||
}
|
||||
|
||||
username = await this.ensureUniqueUsername(username);
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
displayName: input.displayName?.trim() ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userCredential.create({
|
||||
data: {
|
||||
userId: created.id,
|
||||
passwordHash,
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
await this.createEmailVerificationToken(user.id, user.email);
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.register',
|
||||
userId: user.id,
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return { message: 'Registration successful. Please verify your email.' };
|
||||
}
|
||||
|
||||
async login(
|
||||
input: LoginRequest,
|
||||
reply: FastifyReply,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
const email = input.email.trim().toLowerCase();
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
include: {
|
||||
credentials: { take: 1, orderBy: { createdAt: 'desc' } },
|
||||
totpCredential: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || user.credentials.length === 0) {
|
||||
await this.auditService.record({
|
||||
action: 'auth.login_failed',
|
||||
metadata: { email, reason: 'unknown_user' },
|
||||
ipAddress,
|
||||
});
|
||||
throw new UnauthorizedException('Invalid email or password');
|
||||
}
|
||||
|
||||
if (user.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException('Account is not active');
|
||||
}
|
||||
|
||||
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
||||
throw new ForbiddenException('Account is temporarily locked');
|
||||
}
|
||||
|
||||
const credential = user.credentials[0]!;
|
||||
const passwordValid = await verifyPassword(input.password, credential.passwordHash);
|
||||
|
||||
if (!passwordValid) {
|
||||
const failedAttempts = user.failedLoginAttempts + 1;
|
||||
const lockedUntil =
|
||||
failedAttempts >= LOCKOUT_ATTEMPTS
|
||||
? new Date(Date.now() + LOCKOUT_DURATION_MS)
|
||||
: null;
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedLoginAttempts: failedAttempts,
|
||||
lockedUntil,
|
||||
},
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.login_failed',
|
||||
userId: user.id,
|
||||
metadata: {
|
||||
reason: 'invalid_password',
|
||||
failedAttempts,
|
||||
locked: lockedUntil !== null,
|
||||
},
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
if (lockedUntil) {
|
||||
throw new ForbiddenException(
|
||||
'Account locked due to too many failed login attempts',
|
||||
);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Invalid email or password');
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (user.totpCredential?.enabledAt) {
|
||||
const challengeId = this.sessionService.createLoginChallengeId();
|
||||
const payload: LoginChallengePayload = { userId: user.id, ipAddress, userAgent };
|
||||
|
||||
await this.redis.set(
|
||||
this.challengeKey(challengeId),
|
||||
JSON.stringify(payload),
|
||||
'EX',
|
||||
LOGIN_CHALLENGE_TTL_SECONDS,
|
||||
);
|
||||
|
||||
return {
|
||||
twoFactorRequired: true as const,
|
||||
challengeId,
|
||||
};
|
||||
}
|
||||
|
||||
return this.completeLogin(user.id, reply, ipAddress, userAgent);
|
||||
}
|
||||
|
||||
async verifyTwoFactorLogin(
|
||||
input: TwoFactorVerifyRequest,
|
||||
reply: FastifyReply,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
const raw = await this.redis.get(this.challengeKey(input.challengeId));
|
||||
|
||||
if (!raw) {
|
||||
throw new UnauthorizedException('Invalid or expired login challenge');
|
||||
}
|
||||
|
||||
const challenge = JSON.parse(raw) as LoginChallengePayload;
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: challenge.userId },
|
||||
include: { totpCredential: { include: { recoveryCodes: true } } },
|
||||
});
|
||||
|
||||
if (!user?.totpCredential?.enabledAt) {
|
||||
throw new UnauthorizedException('Two-factor authentication is not enabled');
|
||||
}
|
||||
|
||||
const code = input.code.replace(/\s+/gu, '');
|
||||
let verified = verifyTotpCode(user.totpCredential.secret, code);
|
||||
|
||||
if (!verified) {
|
||||
verified = await this.tryRecoveryCode(user.totpCredential.id, code);
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
throw new UnauthorizedException('Invalid two-factor code');
|
||||
}
|
||||
|
||||
await this.redis.del(this.challengeKey(input.challengeId));
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.2fa_verified',
|
||||
userId: user.id,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return this.completeLogin(
|
||||
user.id,
|
||||
reply,
|
||||
ipAddress ?? challenge.ipAddress,
|
||||
userAgent ?? challenge.userAgent,
|
||||
);
|
||||
}
|
||||
|
||||
async logout(
|
||||
token: string | undefined,
|
||||
reply: FastifyReply,
|
||||
ipAddress?: string,
|
||||
): Promise<MessageResponse> {
|
||||
if (token) {
|
||||
const userId = await this.sessionService.revokeByToken(token);
|
||||
|
||||
if (userId) {
|
||||
await this.auditService.record({
|
||||
action: 'auth.logout',
|
||||
userId,
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.sessionService.clearSessionCookie(reply);
|
||||
return { message: 'Logged out' };
|
||||
}
|
||||
|
||||
async verifyEmail(
|
||||
input: VerifyEmailRequest,
|
||||
ipAddress?: string,
|
||||
): Promise<MessageResponse> {
|
||||
const tokenHash = hashToken(input.token);
|
||||
const record = await this.prisma.emailVerificationToken.findUnique({
|
||||
where: { tokenHash },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!record || record.usedAt || record.expiresAt <= new Date()) {
|
||||
throw new BadRequestException('Invalid or expired verification token');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.user.update({
|
||||
where: { id: record.userId },
|
||||
data: { emailVerifiedAt: new Date() },
|
||||
}),
|
||||
this.prisma.emailVerificationToken.update({
|
||||
where: { id: record.id },
|
||||
data: { usedAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.email_verified',
|
||||
userId: record.userId,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return { message: 'Email verified successfully' };
|
||||
}
|
||||
|
||||
async resendVerification(userId: string, ipAddress?: string): Promise<MessageResponse> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (user.emailVerifiedAt) {
|
||||
return { message: 'Email is already verified' };
|
||||
}
|
||||
|
||||
await this.createEmailVerificationToken(user.id, user.email);
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.verification_resent',
|
||||
userId,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return { message: 'Verification email sent' };
|
||||
}
|
||||
|
||||
async forgotPassword(
|
||||
input: ForgotPasswordRequest,
|
||||
ipAddress?: string,
|
||||
): Promise<MessageResponse> {
|
||||
const email = input.email.trim().toLowerCase();
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
|
||||
if (user) {
|
||||
const token = generateSecureToken();
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
await this.prisma.passwordResetToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
expiresAt: new Date(Date.now() + PASSWORD_RESET_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
await this.enqueueEmail({
|
||||
type: 'password_reset',
|
||||
to: user.email,
|
||||
subject: 'Reset your HexaHost password',
|
||||
template: 'password-reset',
|
||||
data: {
|
||||
resetUrl: `${this.config.APP_URL}/reset-password?token=${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.password_reset_requested',
|
||||
userId: user.id,
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
message:
|
||||
'If an account exists for this email, a password reset link has been sent.',
|
||||
};
|
||||
}
|
||||
|
||||
async resetPassword(
|
||||
input: ResetPasswordRequest,
|
||||
ipAddress?: string,
|
||||
): Promise<MessageResponse> {
|
||||
const tokenHash = hashToken(input.token);
|
||||
const record = await this.prisma.passwordResetToken.findUnique({
|
||||
where: { tokenHash },
|
||||
});
|
||||
|
||||
if (!record || record.usedAt || record.expiresAt <= new Date()) {
|
||||
throw new BadRequestException('Invalid or expired reset token');
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(input.newPassword);
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.userCredential.updateMany({
|
||||
where: { userId: record.userId },
|
||||
data: { passwordHash },
|
||||
}),
|
||||
this.prisma.passwordResetToken.update({
|
||||
where: { id: record.id },
|
||||
data: { usedAt: new Date() },
|
||||
}),
|
||||
this.prisma.userSession.updateMany({
|
||||
where: { userId: record.userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
}),
|
||||
this.prisma.user.update({
|
||||
where: { id: record.userId },
|
||||
data: { failedLoginAttempts: 0, lockedUntil: null },
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.password_reset_completed',
|
||||
userId: record.userId,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return { message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
async setupTwoFactor(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const secret = generateTotpSecret();
|
||||
|
||||
await this.prisma.totpCredential.upsert({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
secret,
|
||||
enabledAt: null,
|
||||
},
|
||||
update: {
|
||||
secret,
|
||||
enabledAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.recoveryCode.deleteMany({
|
||||
where: {
|
||||
totpCredential: { userId },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
secret,
|
||||
qrUrl: buildOtpAuthUrl(this.config.APP_NAME, user.email, secret),
|
||||
};
|
||||
}
|
||||
|
||||
async confirmTwoFactor(
|
||||
userId: string,
|
||||
input: TwoFactorConfirmRequest,
|
||||
ipAddress?: string,
|
||||
) {
|
||||
const credential = await this.prisma.totpCredential.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new BadRequestException('Two-factor setup has not been started');
|
||||
}
|
||||
|
||||
if (!verifyTotpCode(credential.secret, input.code)) {
|
||||
throw new BadRequestException('Invalid authenticator code');
|
||||
}
|
||||
|
||||
const recoveryCodes = generateRecoveryCodes();
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.totpCredential.update({
|
||||
where: { id: credential.id },
|
||||
data: { enabledAt: new Date() },
|
||||
});
|
||||
|
||||
await tx.recoveryCode.deleteMany({
|
||||
where: { totpCredentialId: credential.id },
|
||||
});
|
||||
|
||||
await tx.recoveryCode.createMany({
|
||||
data: recoveryCodes.map((code) => ({
|
||||
totpCredentialId: credential.id,
|
||||
codeHash: hashRecoveryCode(code),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.2fa_enabled',
|
||||
userId,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Two-factor authentication enabled',
|
||||
recoveryCodes,
|
||||
};
|
||||
}
|
||||
|
||||
async disableTwoFactor(
|
||||
userId: string,
|
||||
input: TwoFactorDisableRequest,
|
||||
ipAddress?: string,
|
||||
): Promise<MessageResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
credentials: { take: 1, orderBy: { createdAt: 'desc' } },
|
||||
totpCredential: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.totpCredential?.enabledAt) {
|
||||
throw new BadRequestException('Two-factor authentication is not enabled');
|
||||
}
|
||||
|
||||
const credential = user.credentials[0];
|
||||
if (!credential || !(await verifyPassword(input.password, credential.passwordHash))) {
|
||||
throw new UnauthorizedException('Invalid password');
|
||||
}
|
||||
|
||||
const code = input.code.replace(/\s+/gu, '');
|
||||
let verified = verifyTotpCode(user.totpCredential.secret, code);
|
||||
|
||||
if (!verified) {
|
||||
verified = await this.tryRecoveryCode(user.totpCredential.id, code);
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
throw new UnauthorizedException('Invalid two-factor code');
|
||||
}
|
||||
|
||||
await this.prisma.totpCredential.delete({
|
||||
where: { id: user.totpCredential.id },
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.2fa_disabled',
|
||||
userId,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return { message: 'Two-factor authentication disabled' };
|
||||
}
|
||||
|
||||
toAuthUser(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
emailVerifiedAt: Date | null;
|
||||
platformRoles?: Array<{ role: { name: string } }>;
|
||||
}): AuthUser {
|
||||
const authenticated = this.sessionService.toAuthenticatedUser({
|
||||
...user,
|
||||
platformRoles: user.platformRoles ?? [],
|
||||
});
|
||||
|
||||
return {
|
||||
id: authenticated.id,
|
||||
email: authenticated.email,
|
||||
username: authenticated.username,
|
||||
displayName: authenticated.displayName,
|
||||
emailVerified: authenticated.emailVerified,
|
||||
roles: authenticated.roles,
|
||||
};
|
||||
}
|
||||
|
||||
private async completeLogin(
|
||||
userId: string,
|
||||
reply: FastifyReply,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
const { token, expiresAt } = await this.sessionService.createSession(
|
||||
userId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
);
|
||||
|
||||
this.sessionService.setSessionCookie(reply, token, expiresAt);
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
platformRoles: { include: { role: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.login',
|
||||
userId,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return { user: this.toAuthUser(user) };
|
||||
}
|
||||
|
||||
private async ensureUniqueUsername(baseUsername: string): Promise<string> {
|
||||
let candidate = baseUsername;
|
||||
let suffix = 0;
|
||||
|
||||
while (true) {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { username: candidate },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
const suffixText = String(suffix);
|
||||
candidate = `${baseUsername.slice(0, Math.max(1, 32 - suffixText.length))}${suffixText}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async createEmailVerificationToken(userId: string, email: string): Promise<void> {
|
||||
const token = generateSecureToken();
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
await this.prisma.emailVerificationToken.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt: new Date(Date.now() + EMAIL_VERIFICATION_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
await this.enqueueEmail({
|
||||
type: 'email_verification',
|
||||
to: email,
|
||||
subject: 'Verify your HexaHost email',
|
||||
template: 'email-verification',
|
||||
data: {
|
||||
verifyUrl: `${this.config.APP_URL}/verify-email?token=${token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async enqueueEmail(payload: NotificationEmailPayload): Promise<void> {
|
||||
await this.notificationsQueue.add('send-email', payload, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
});
|
||||
}
|
||||
|
||||
private challengeKey(challengeId: string): string {
|
||||
return `hgc:login-challenge:${challengeId}`;
|
||||
}
|
||||
|
||||
private async tryRecoveryCode(
|
||||
totpCredentialId: string,
|
||||
code: string,
|
||||
): Promise<boolean> {
|
||||
const recoveryCodes = await this.prisma.recoveryCode.findMany({
|
||||
where: {
|
||||
totpCredentialId,
|
||||
usedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
for (const recoveryCode of recoveryCodes) {
|
||||
if (verifyRecoveryCode(code, recoveryCode.codeHash)) {
|
||||
await this.prisma.recoveryCode.update({
|
||||
where: { id: recoveryCode.id },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
37
apps/api/src/auth/decorators/current-user.decorator.ts
Normal file
37
apps/api/src/auth/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
import type { AuthenticatedUser, SessionContext } from '@hexahost/auth';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
export interface RequestWithSession extends FastifyRequest {
|
||||
session?: SessionContext;
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): AuthenticatedUser => {
|
||||
const request = context.switchToHttp().getRequest<RequestWithSession>();
|
||||
if (!request.user) {
|
||||
throw new Error('CurrentUser decorator requires SessionGuard');
|
||||
}
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
|
||||
export const CurrentSession = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): SessionContext => {
|
||||
const request = context.switchToHttp().getRequest<RequestWithSession>();
|
||||
if (!request.session) {
|
||||
throw new Error('CurrentSession decorator requires SessionGuard');
|
||||
}
|
||||
return request.session;
|
||||
},
|
||||
);
|
||||
10
apps/api/src/auth/dto/index.ts
Normal file
10
apps/api/src/auth/dto/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
registerRequestSchema,
|
||||
loginRequestSchema,
|
||||
verifyEmailRequestSchema,
|
||||
forgotPasswordRequestSchema,
|
||||
resetPasswordRequestSchema,
|
||||
twoFactorConfirmRequestSchema,
|
||||
twoFactorVerifyRequestSchema,
|
||||
twoFactorDisableRequestSchema,
|
||||
} from '@hexahost/contracts';
|
||||
48
apps/api/src/auth/guards/session.guard.ts
Normal file
48
apps/api/src/auth/guards/session.guard.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
import {
|
||||
IS_PUBLIC_KEY,
|
||||
type RequestWithSession,
|
||||
} from '../decorators/current-user.decorator';
|
||||
import { SessionService } from '../session.service';
|
||||
|
||||
@Injectable()
|
||||
export class SessionGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<RequestWithSession>();
|
||||
const token = this.sessionService.readSessionToken(
|
||||
request as FastifyRequest,
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
const session = await this.sessionService.validateSessionToken(token);
|
||||
request.session = session;
|
||||
request.user = session.user;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
55
apps/api/src/auth/me.controller.ts
Normal file
55
apps/api/src/auth/me.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
CurrentSession,
|
||||
CurrentUser,
|
||||
} from './decorators/current-user.decorator';
|
||||
import { SessionGuard } from './guards/session.guard';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@ApiTags('me')
|
||||
@Controller('me')
|
||||
@UseGuards(SessionGuard)
|
||||
export class MeController {
|
||||
constructor(private readonly sessionService: SessionService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
@ApiResponse({ status: 200, description: 'Current user' })
|
||||
getMe(@CurrentUser() user: { id: string }) {
|
||||
return { user };
|
||||
}
|
||||
|
||||
@Get('sessions')
|
||||
@ApiOperation({ summary: 'List active sessions' })
|
||||
async listSessions(
|
||||
@CurrentUser() user: { id: string },
|
||||
@CurrentSession() session: { sessionId: string },
|
||||
) {
|
||||
const sessions = await this.sessionService.listSessions(
|
||||
user.id,
|
||||
session.sessionId,
|
||||
);
|
||||
return { sessions };
|
||||
}
|
||||
|
||||
@Delete('sessions/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Revoke a session' })
|
||||
async revokeSession(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) sessionId: string,
|
||||
): Promise<void> {
|
||||
await this.sessionService.revokeSession(sessionId, user.id);
|
||||
}
|
||||
}
|
||||
195
apps/api/src/auth/session.service.ts
Normal file
195
apps/api/src/auth/session.service.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import {
|
||||
SESSION_COOKIE_NAME,
|
||||
UserRole,
|
||||
createSessionExpiry,
|
||||
generateSecureToken,
|
||||
hashToken,
|
||||
type AuthenticatedUser,
|
||||
type SessionContext,
|
||||
} from '@hexahost/auth';
|
||||
import type { AppConfig } from '@hexahost/config';
|
||||
|
||||
import { APP_CONFIG } from '../config/config.constants';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
async createSession(
|
||||
userId: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
): Promise<{ token: string; sessionId: string; expiresAt: Date }> {
|
||||
const token = generateSecureToken();
|
||||
const tokenHash = hashToken(token);
|
||||
const expiresAt = createSessionExpiry();
|
||||
|
||||
const session = await this.prisma.userSession.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
return { token, sessionId: session.id, expiresAt };
|
||||
}
|
||||
|
||||
setSessionCookie(reply: FastifyReply, token: string, expiresAt: Date): void {
|
||||
const isProduction = this.config.APP_ENV === 'production';
|
||||
|
||||
void reply.setCookie(SESSION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
expires: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
clearSessionCookie(reply: FastifyReply): void {
|
||||
void reply.clearCookie(SESSION_COOKIE_NAME, {
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
readSessionToken(request: FastifyRequest): string | undefined {
|
||||
const cookies = request.cookies as Record<string, string | undefined>;
|
||||
const token = cookies[SESSION_COOKIE_NAME];
|
||||
return token && token.length > 0 ? token : undefined;
|
||||
}
|
||||
|
||||
async validateSessionToken(token: string): Promise<SessionContext> {
|
||||
const tokenHash = hashToken(token);
|
||||
const now = new Date();
|
||||
|
||||
const session = await this.prisma.userSession.findUnique({
|
||||
where: { tokenHash },
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
platformRoles: {
|
||||
include: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!session ||
|
||||
session.revokedAt ||
|
||||
session.expiresAt <= now ||
|
||||
session.user.status !== 'ACTIVE'
|
||||
) {
|
||||
throw new UnauthorizedException('Invalid or expired session');
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
issuedAt: session.createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
user: this.toAuthenticatedUser(session.user),
|
||||
};
|
||||
}
|
||||
|
||||
async revokeSession(sessionId: string, userId: string): Promise<void> {
|
||||
const session = await this.prisma.userSession.findFirst({
|
||||
where: { id: sessionId, userId },
|
||||
});
|
||||
|
||||
if (!session || session.revokedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.userSession.update({
|
||||
where: { id: sessionId },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async revokeByToken(token: string): Promise<string | undefined> {
|
||||
const tokenHash = hashToken(token);
|
||||
const session = await this.prisma.userSession.findUnique({
|
||||
where: { tokenHash },
|
||||
});
|
||||
|
||||
if (!session || session.revokedAt) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
await this.prisma.userSession.update({
|
||||
where: { id: session.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
return session.userId;
|
||||
}
|
||||
|
||||
async listSessions(userId: string, currentSessionId: string) {
|
||||
const sessions = await this.prisma.userSession.findMany({
|
||||
where: {
|
||||
userId,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return sessions.map((session) => ({
|
||||
id: session.id,
|
||||
ipAddress: session.ipAddress,
|
||||
userAgent: session.userAgent,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
current: session.id === currentSessionId,
|
||||
}));
|
||||
}
|
||||
|
||||
createLoginChallengeId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
toAuthenticatedUser(
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
emailVerifiedAt: Date | null;
|
||||
platformRoles: Array<{ role: { name: string } }>;
|
||||
},
|
||||
): AuthenticatedUser {
|
||||
const roles = user.platformRoles
|
||||
.map((entry) => entry.role.name)
|
||||
.filter((name): name is UserRole =>
|
||||
Object.values(UserRole).includes(name as UserRole),
|
||||
);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
emailVerified: user.emailVerifiedAt !== null,
|
||||
roles: roles.length > 0 ? roles : [UserRole.USER],
|
||||
};
|
||||
}
|
||||
}
|
||||
22
apps/api/src/common/pipes/zod-validation.pipe.ts
Normal file
22
apps/api/src/common/pipes/zod-validation.pipe.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
type PipeTransform,
|
||||
} from '@nestjs/common';
|
||||
import type { ZodSchema } from 'zod';
|
||||
|
||||
export class ZodValidationPipe<T> implements PipeTransform {
|
||||
constructor(private readonly schema: ZodSchema<T>) {}
|
||||
|
||||
transform(value: unknown): T {
|
||||
const result = this.schema.safeParse(value);
|
||||
|
||||
if (!result.success) {
|
||||
const messages = result.error.issues.map(
|
||||
(issue) => `${issue.path.join('.') || 'body'}: ${issue.message}`,
|
||||
);
|
||||
throw new BadRequestException(messages);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
16
apps/api/src/config/app-config.module.ts
Normal file
16
apps/api/src/config/app-config.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { APP_CONFIG } from './config.constants';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: APP_CONFIG,
|
||||
useFactory: () => getConfig(),
|
||||
},
|
||||
],
|
||||
exports: [APP_CONFIG],
|
||||
})
|
||||
export class AppConfigModule {}
|
||||
5
apps/api/src/config/config.constants.ts
Normal file
5
apps/api/src/config/config.constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { AppConfig } from '@hexahost/config';
|
||||
|
||||
export const APP_CONFIG = Symbol('APP_CONFIG');
|
||||
|
||||
export type { AppConfig };
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import cookie from '@fastify/cookie';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import {
|
||||
FastifyAdapter,
|
||||
@@ -30,8 +31,16 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
app.useLogger(app.get(Logger));
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.enableCors({
|
||||
origin: config.APP_URL,
|
||||
credentials: true,
|
||||
});
|
||||
app.useGlobalFilters(new Rfc7807ExceptionFilter());
|
||||
|
||||
await app.register(cookie, {
|
||||
secret: config.SESSION_SECRET,
|
||||
});
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle(config.APP_NAME)
|
||||
.setDescription('HexaHost GameCloud REST API')
|
||||
|
||||
76
apps/api/test/auth.test.js
Normal file
76
apps/api/test/auth.test.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('api auth contract', () => {
|
||||
it('returns ok for liveness-style health response', async () => {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/api/v1/health/live') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
status: 'ok',
|
||||
service: 'api',
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, resolve));
|
||||
const addr = server.address();
|
||||
assert.ok(addr && typeof addr === 'object');
|
||||
const port = addr.port;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/v1/health/live`);
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
assert.equal(body.status, 'ok');
|
||||
assert.equal(body.service, 'api');
|
||||
|
||||
await new Promise((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
});
|
||||
|
||||
it('documents auth module routes in source', () => {
|
||||
const authControllerPath = join(
|
||||
process.cwd(),
|
||||
'src',
|
||||
'auth',
|
||||
'auth.controller.ts',
|
||||
);
|
||||
const meControllerPath = join(process.cwd(), 'src', 'auth', 'me.controller.ts');
|
||||
const authSource = readFileSync(authControllerPath, 'utf8');
|
||||
const meSource = readFileSync(meControllerPath, 'utf8');
|
||||
const combined = `${authSource}\n${meSource}`;
|
||||
|
||||
const expectedSnippets = [
|
||||
"@Post('register')",
|
||||
"@Post('login')",
|
||||
"@Post('logout')",
|
||||
"@Post('verify-email')",
|
||||
"@Post('resend-verification')",
|
||||
"@Post('forgot-password')",
|
||||
"@Post('reset-password')",
|
||||
"@Post('2fa/setup')",
|
||||
"@Post('2fa/confirm')",
|
||||
"@Post('2fa/verify')",
|
||||
"@Post('2fa/disable')",
|
||||
"@Get()",
|
||||
"@Get('sessions')",
|
||||
"@Delete('sessions/:id')",
|
||||
"@Controller('auth')",
|
||||
"@Controller('me')",
|
||||
];
|
||||
|
||||
for (const snippet of expectedSnippets) {
|
||||
assert.ok(combined.includes(snippet), `missing ${snippet}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -44,12 +44,51 @@
|
||||
"registerSubtitle": "Registrieren Sie sich für die Plattform.",
|
||||
"email": "E-Mail",
|
||||
"emailPlaceholder": "name@beispiel.de",
|
||||
"username": "Benutzername",
|
||||
"usernamePlaceholder": "spieler123",
|
||||
"displayName": "Anzeigename",
|
||||
"displayNamePlaceholder": "Ihr Name",
|
||||
"optional": "optional",
|
||||
"password": "Passwort",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"noAccount": "Noch kein Konto?",
|
||||
"hasAccount": "Bereits registriert?",
|
||||
"apiPending": "Authentifizierung wird in Phase 1 angebunden. Formularstruktur ist vorbereitet."
|
||||
"forgotPasswordLink": "Passwort vergessen?",
|
||||
"forgotPasswordTitle": "Passwort zurücksetzen",
|
||||
"forgotPasswordSubtitle": "Geben Sie Ihre E-Mail-Adresse ein. Wir senden Ihnen einen Link zum Zurücksetzen.",
|
||||
"forgotPasswordSubmit": "Link senden",
|
||||
"forgotPasswordSent": "Falls ein Konto mit dieser E-Mail existiert, erhalten Sie in Kürze einen Link.",
|
||||
"resetPasswordTitle": "Neues Passwort festlegen",
|
||||
"resetPasswordSubtitle": "Wählen Sie ein neues Passwort für Ihr Konto.",
|
||||
"resetPasswordSubmit": "Passwort aktualisieren",
|
||||
"resetPasswordMissingToken": "Dieser Link ist ungültig oder abgelaufen.",
|
||||
"verifyEmailTitle": "E-Mail-Verifizierung",
|
||||
"verifyEmailSent": "Registrierung erfolgreich. Bitte prüfen Sie Ihren Posteingang und klicken Sie auf den Verifizierungslink.",
|
||||
"verifyEmailLoading": "E-Mail-Adresse wird verifiziert…",
|
||||
"verifyEmailSuccess": "Ihre E-Mail-Adresse wurde verifiziert. Sie können sich jetzt anmelden.",
|
||||
"verifyEmailError": "Verifizierung fehlgeschlagen. Der Link ist möglicherweise ungültig oder abgelaufen.",
|
||||
"verifyEmailMissingToken": "Kein Verifizierungstoken angegeben.",
|
||||
"twoFactorTitle": "Zwei-Faktor-Authentifizierung",
|
||||
"twoFactorSubtitle": "Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein.",
|
||||
"twoFactorCode": "Authentifizierungscode",
|
||||
"twoFactorSubmit": "Bestätigen",
|
||||
"twoFactorMissingChallenge": "Keine Anmelde-Challenge gefunden. Bitte melden Sie sich erneut an.",
|
||||
"twoFactorSetupTitle": "Zwei-Faktor-Authentifizierung",
|
||||
"twoFactorSetupDescription": "Schützen Sie Ihr Konto mit zusätzlicher TOTP-Sicherheit.",
|
||||
"twoFactorSetupStart": "2FA einrichten",
|
||||
"twoFactorScanQr": "Scannen Sie diesen QR-Code mit Ihrer Authenticator-App und geben Sie den Code ein.",
|
||||
"twoFactorQrAlt": "QR-Code für Authenticator-Einrichtung",
|
||||
"twoFactorConfirm": "2FA aktivieren",
|
||||
"twoFactorEnabled": "Zwei-Faktor-Authentifizierung ist für Ihr Konto aktiviert.",
|
||||
"recoveryCodesTitle": "Wiederherstellungscodes — sicher aufbewahren",
|
||||
"securityTitle": "Sicherheit",
|
||||
"securitySubtitle": "Authentifizierung und Sicherheitseinstellungen Ihres Kontos verwalten.",
|
||||
"backToLogin": "Zurück zur Anmeldung",
|
||||
"sessionNone": "Sitzung: keine",
|
||||
"errors": {
|
||||
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -61,8 +100,7 @@
|
||||
"status": "Status",
|
||||
"resources": "Ressourcen",
|
||||
"activity": "Aktivität",
|
||||
"activityEmpty": "Keine aktuellen Ereignisse",
|
||||
"protectedNote": "Geschützter Bereich — Authentifizierung wird in Phase 1 aktiviert."
|
||||
"activityEmpty": "Keine aktuellen Ereignisse"
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "E-Mail ist erforderlich",
|
||||
@@ -70,6 +108,9 @@
|
||||
"passwordRequired": "Passwort ist erforderlich",
|
||||
"passwordMin": "Passwort muss mindestens 8 Zeichen haben",
|
||||
"confirmPasswordRequired": "Passwortbestätigung ist erforderlich",
|
||||
"passwordMismatch": "Passwörter stimmen nicht überein"
|
||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||
"usernameInvalid": "Benutzername: 3–32 Zeichen (Buchstaben, Zahlen, _ und -)",
|
||||
"codeRequired": "Authentifizierungscode ist erforderlich",
|
||||
"codeInvalid": "Geben Sie einen gültigen 6-stelligen Code ein"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,12 +44,51 @@
|
||||
"registerSubtitle": "Register for the platform.",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "name@example.com",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "player123",
|
||||
"displayName": "Display name",
|
||||
"displayNamePlaceholder": "Your name",
|
||||
"optional": "optional",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"confirmPassword": "Confirm password",
|
||||
"noAccount": "No account yet?",
|
||||
"hasAccount": "Already registered?",
|
||||
"apiPending": "Authentication will be connected in Phase 1. Form structure is ready."
|
||||
"forgotPasswordLink": "Forgot password?",
|
||||
"forgotPasswordTitle": "Reset password",
|
||||
"forgotPasswordSubtitle": "Enter your email address and we will send you a reset link.",
|
||||
"forgotPasswordSubmit": "Send reset link",
|
||||
"forgotPasswordSent": "If an account exists for this email, you will receive a reset link shortly.",
|
||||
"resetPasswordTitle": "Set new password",
|
||||
"resetPasswordSubtitle": "Choose a new password for your account.",
|
||||
"resetPasswordSubmit": "Update password",
|
||||
"resetPasswordMissingToken": "This reset link is invalid or has expired.",
|
||||
"verifyEmailTitle": "Email verification",
|
||||
"verifyEmailSent": "Registration successful. Please check your inbox and click the verification link.",
|
||||
"verifyEmailLoading": "Verifying your email address…",
|
||||
"verifyEmailSuccess": "Your email address has been verified. You can now sign in.",
|
||||
"verifyEmailError": "Verification failed. The link may be invalid or expired.",
|
||||
"verifyEmailMissingToken": "No verification token was provided.",
|
||||
"twoFactorTitle": "Two-factor authentication",
|
||||
"twoFactorSubtitle": "Enter the 6-digit code from your authenticator app.",
|
||||
"twoFactorCode": "Authentication code",
|
||||
"twoFactorSubmit": "Verify",
|
||||
"twoFactorMissingChallenge": "No login challenge found. Please sign in again.",
|
||||
"twoFactorSetupTitle": "Two-factor authentication",
|
||||
"twoFactorSetupDescription": "Add an extra layer of security to your account with TOTP.",
|
||||
"twoFactorSetupStart": "Set up 2FA",
|
||||
"twoFactorScanQr": "Scan this QR code with your authenticator app, then enter the generated code.",
|
||||
"twoFactorQrAlt": "QR code for authenticator setup",
|
||||
"twoFactorConfirm": "Enable 2FA",
|
||||
"twoFactorEnabled": "Two-factor authentication is enabled on your account.",
|
||||
"recoveryCodesTitle": "Recovery codes — save these in a secure place",
|
||||
"securityTitle": "Security",
|
||||
"securitySubtitle": "Manage authentication and security settings for your account.",
|
||||
"backToLogin": "Back to sign in",
|
||||
"sessionNone": "session: none",
|
||||
"errors": {
|
||||
"generic": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -61,8 +100,7 @@
|
||||
"status": "Status",
|
||||
"resources": "Resources",
|
||||
"activity": "Activity",
|
||||
"activityEmpty": "No recent events",
|
||||
"protectedNote": "Protected area — authentication will be enabled in Phase 1."
|
||||
"activityEmpty": "No recent events"
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "Email is required",
|
||||
@@ -70,6 +108,9 @@
|
||||
"passwordRequired": "Password is required",
|
||||
"passwordMin": "Password must be at least 8 characters",
|
||||
"confirmPasswordRequired": "Password confirmation is required",
|
||||
"passwordMismatch": "Passwords do not match"
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"usernameInvalid": "Username must be 3–32 characters (letters, numbers, _ and -)",
|
||||
"codeRequired": "Authentication code is required",
|
||||
"codeInvalid": "Enter a valid 6-digit code"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -31,9 +32,7 @@ export default async function DashboardLayout({
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
auth: pending · session: none
|
||||
</p>
|
||||
<DashboardUserInfo />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
20
apps/web/src/app/[locale]/forgot-password/page.tsx
Normal file
20
apps/web/src/app/[locale]/forgot-password/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Suspense } from "react";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { ForgotPasswordForm } from "@/components/auth/forgot-password-form";
|
||||
|
||||
interface ForgotPasswordPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function ForgotPasswordPage({ params }: ForgotPasswordPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
|
||||
<Suspense>
|
||||
<ForgotPasswordForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getMessages, setRequestLocale } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { SiteLayout } from "@/components/layout/site-layout";
|
||||
import { AuthProvider } from "@/components/providers/auth-provider";
|
||||
import { QueryProvider } from "@/components/providers/query-provider";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { routing, type Locale } from "@/i18n/routing";
|
||||
@@ -30,7 +31,9 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<ThemeProvider>
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
<SiteLayout>{children}</SiteLayout>
|
||||
</AuthProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
|
||||
20
apps/web/src/app/[locale]/login/2fa/page.tsx
Normal file
20
apps/web/src/app/[locale]/login/2fa/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Suspense } from "react";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { Login2FaForm } from "@/components/auth/login-2fa-form";
|
||||
|
||||
interface Login2FaPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function Login2FaPage({ params }: Login2FaPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
|
||||
<Suspense>
|
||||
<Login2FaForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Suspense } from "react";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
|
||||
@@ -11,7 +12,9 @@ export default async function LoginPage({ params }: LoginPageProps) {
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
|
||||
<Suspense>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
20
apps/web/src/app/[locale]/reset-password/page.tsx
Normal file
20
apps/web/src/app/[locale]/reset-password/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Suspense } from "react";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { ResetPasswordForm } from "@/components/auth/reset-password-form";
|
||||
|
||||
interface ResetPasswordPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function ResetPasswordPage({ params }: ResetPasswordPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
|
||||
<Suspense>
|
||||
<ResetPasswordForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/web/src/app/[locale]/security/page.tsx
Normal file
17
apps/web/src/app/[locale]/security/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { SecurityContent } from "@/components/auth/security-content";
|
||||
|
||||
interface SecurityPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function SecurityPage({ params }: SecurityPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
|
||||
<SecurityContent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
apps/web/src/app/[locale]/verify-email/page.tsx
Normal file
20
apps/web/src/app/[locale]/verify-email/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Suspense } from "react";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { VerifyEmailContent } from "@/components/auth/verify-email-content";
|
||||
|
||||
interface VerifyEmailPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function VerifyEmailPage({ params }: VerifyEmailPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
|
||||
<Suspense>
|
||||
<VerifyEmailContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
apps/web/src/components/auth/forgot-password-form.tsx
Normal file
114
apps/web/src/components/auth/forgot-password-form.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { ApiError, forgotPassword } from "@/lib/api/auth";
|
||||
import { createForgotPasswordSchema, type ForgotPasswordFormValues } from "@/lib/schemas/auth";
|
||||
|
||||
const inputClassName =
|
||||
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||
|
||||
export function ForgotPasswordForm() {
|
||||
const t = useTranslations("auth");
|
||||
const tv = useTranslations("validation");
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const schema = createForgotPasswordSchema({
|
||||
emailRequired: tv("emailRequired"),
|
||||
emailInvalid: tv("emailInvalid"),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ForgotPasswordFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { email: "" },
|
||||
});
|
||||
|
||||
async function onSubmit(values: ForgotPasswordFormValues) {
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
await forgotPassword(values.email);
|
||||
setSubmitted(true);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setApiError(error.detail ?? error.title);
|
||||
} else {
|
||||
setApiError(t("errors.generic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("forgotPasswordTitle")}</CardTitle>
|
||||
<CardDescription>{t("forgotPasswordSent")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("forgotPasswordTitle")}</CardTitle>
|
||||
<CardDescription>{t("forgotPasswordSubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("email")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
aria-invalid={errors.email ? "true" : "false"}
|
||||
aria-describedby={errors.email ? "email-error" : undefined}
|
||||
className={inputClassName}
|
||||
{...register("email")}
|
||||
/>
|
||||
{errors.email ? (
|
||||
<p id="email-error" className="text-sm text-red-600" role="alert">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{apiError ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isSubmitting}>
|
||||
{t("forgotPasswordSubmit")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<Link href="/login" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
123
apps/web/src/components/auth/login-2fa-form.tsx
Normal file
123
apps/web/src/components/auth/login-2fa-form.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { ApiError, verifyTwoFactorLogin } from "@/lib/api/auth";
|
||||
import { createTwoFactorSchema, type TwoFactorFormValues } from "@/lib/schemas/auth";
|
||||
import { useAuth } from "@/components/providers/auth-provider";
|
||||
|
||||
const inputClassName =
|
||||
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||
|
||||
export function Login2FaForm() {
|
||||
const t = useTranslations("auth");
|
||||
const tv = useTranslations("validation");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { refresh } = useAuth();
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const challengeId = searchParams.get("challenge");
|
||||
|
||||
const schema = createTwoFactorSchema({
|
||||
codeRequired: tv("codeRequired"),
|
||||
codeInvalid: tv("codeInvalid"),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TwoFactorFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { code: "" },
|
||||
});
|
||||
|
||||
if (!challengeId) {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("twoFactorTitle")}</CardTitle>
|
||||
<CardDescription>{t("twoFactorMissingChallenge")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
async function onSubmit(values: TwoFactorFormValues) {
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
await verifyTwoFactorLogin(challengeId!, values.code);
|
||||
await refresh();
|
||||
router.push("/dashboard");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setApiError(error.detail ?? error.title);
|
||||
} else {
|
||||
setApiError(t("errors.generic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("twoFactorTitle")}</CardTitle>
|
||||
<CardDescription>{t("twoFactorSubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="code" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("twoFactorCode")}
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
aria-invalid={errors.code ? "true" : "false"}
|
||||
aria-describedby={errors.code ? "code-error" : undefined}
|
||||
className={inputClassName}
|
||||
{...register("code")}
|
||||
/>
|
||||
{errors.code ? (
|
||||
<p id="code-error" className="text-sm text-red-600" role="alert">
|
||||
{errors.code.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{apiError ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isSubmitting}>
|
||||
{t("twoFactorSubmit")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<Link href="/login" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,17 +2,25 @@
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { submitLogin } from "@/lib/api/auth";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { ApiError, submitLogin } from "@/lib/api/auth";
|
||||
import { createLoginSchema, type LoginFormValues } from "@/lib/schemas/auth";
|
||||
import { useAuth } from "@/components/providers/auth-provider";
|
||||
|
||||
const inputClassName =
|
||||
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||
|
||||
export function LoginForm() {
|
||||
const t = useTranslations("auth");
|
||||
const tv = useTranslations("validation");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { refresh } = useAuth();
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const schema = createLoginSchema({
|
||||
emailRequired: tv("emailRequired"),
|
||||
@@ -31,8 +39,26 @@ export function LoginForm() {
|
||||
});
|
||||
|
||||
async function onSubmit(values: LoginFormValues) {
|
||||
await submitLogin(values);
|
||||
setSubmitted(true);
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
const result = await submitLogin(values);
|
||||
|
||||
if (result.twoFactorRequired && result.challengeId) {
|
||||
router.push(`/login/2fa?challenge=${encodeURIComponent(result.challengeId)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await refresh();
|
||||
const redirectTo = searchParams.get("redirect");
|
||||
router.push(redirectTo?.startsWith("/") ? redirectTo : "/dashboard");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setApiError(error.detail ?? error.title);
|
||||
} else {
|
||||
setApiError(t("errors.generic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -54,7 +80,7 @@ export function LoginForm() {
|
||||
placeholder={t("emailPlaceholder")}
|
||||
aria-invalid={errors.email ? "true" : "false"}
|
||||
aria-describedby={errors.email ? "email-error" : undefined}
|
||||
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
className={inputClassName}
|
||||
{...register("email")}
|
||||
/>
|
||||
{errors.email ? (
|
||||
@@ -65,9 +91,17 @@ export function LoginForm() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("password")}
|
||||
</label>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-xs font-medium text-sky-600 hover:underline dark:text-sky-400"
|
||||
>
|
||||
{t("forgotPasswordLink")}
|
||||
</Link>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -75,7 +109,7 @@ export function LoginForm() {
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
aria-invalid={errors.password ? "true" : "false"}
|
||||
aria-describedby={errors.password ? "password-error" : undefined}
|
||||
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
className={inputClassName}
|
||||
{...register("password")}
|
||||
/>
|
||||
{errors.password ? (
|
||||
@@ -85,9 +119,9 @@ export function LoginForm() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
{t("apiPending")}
|
||||
{apiError ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -5,14 +5,18 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { submitRegister } from "@/lib/api/auth";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { ApiError, submitRegister } from "@/lib/api/auth";
|
||||
import { createRegisterSchema, type RegisterFormValues } from "@/lib/schemas/auth";
|
||||
|
||||
const inputClassName =
|
||||
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||
|
||||
export function RegisterForm() {
|
||||
const t = useTranslations("auth");
|
||||
const tv = useTranslations("validation");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const router = useRouter();
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const schema = createRegisterSchema({
|
||||
emailRequired: tv("emailRequired"),
|
||||
@@ -21,6 +25,7 @@ export function RegisterForm() {
|
||||
passwordMin: tv("passwordMin"),
|
||||
confirmPasswordRequired: tv("confirmPasswordRequired"),
|
||||
passwordMismatch: tv("passwordMismatch"),
|
||||
usernameInvalid: tv("usernameInvalid"),
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -29,12 +34,27 @@ export function RegisterForm() {
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<RegisterFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { email: "", password: "", confirmPassword: "" },
|
||||
defaultValues: { email: "", username: "", displayName: "", password: "", confirmPassword: "" },
|
||||
});
|
||||
|
||||
async function onSubmit(values: RegisterFormValues) {
|
||||
await submitRegister({ email: values.email, password: values.password });
|
||||
setSubmitted(true);
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
await submitRegister({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
...(values.username ? { username: values.username } : {}),
|
||||
...(values.displayName ? { displayName: values.displayName } : {}),
|
||||
});
|
||||
router.push("/verify-email?sent=1");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setApiError(error.detail ?? error.title);
|
||||
} else {
|
||||
setApiError(t("errors.generic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -56,7 +76,7 @@ export function RegisterForm() {
|
||||
placeholder={t("emailPlaceholder")}
|
||||
aria-invalid={errors.email ? "true" : "false"}
|
||||
aria-describedby={errors.email ? "email-error" : undefined}
|
||||
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
className={inputClassName}
|
||||
{...register("email")}
|
||||
/>
|
||||
{errors.email ? (
|
||||
@@ -66,6 +86,44 @@ export function RegisterForm() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="username" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("username")}{" "}
|
||||
<span className="font-normal text-zinc-500">({t("optional")})</span>
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
aria-invalid={errors.username ? "true" : "false"}
|
||||
aria-describedby={errors.username ? "username-error" : undefined}
|
||||
className={inputClassName}
|
||||
{...register("username")}
|
||||
/>
|
||||
{errors.username ? (
|
||||
<p id="username-error" className="text-sm text-red-600" role="alert">
|
||||
{errors.username.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="displayName" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("displayName")}{" "}
|
||||
<span className="font-normal text-zinc-500">({t("optional")})</span>
|
||||
</label>
|
||||
<input
|
||||
id="displayName"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
placeholder={t("displayNamePlaceholder")}
|
||||
aria-invalid={errors.displayName ? "true" : "false"}
|
||||
className={inputClassName}
|
||||
{...register("displayName")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("password")}
|
||||
@@ -77,7 +135,7 @@ export function RegisterForm() {
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
aria-invalid={errors.password ? "true" : "false"}
|
||||
aria-describedby={errors.password ? "password-error" : undefined}
|
||||
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
className={inputClassName}
|
||||
{...register("password")}
|
||||
/>
|
||||
{errors.password ? (
|
||||
@@ -101,7 +159,7 @@ export function RegisterForm() {
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
aria-invalid={errors.confirmPassword ? "true" : "false"}
|
||||
aria-describedby={errors.confirmPassword ? "confirm-password-error" : undefined}
|
||||
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
className={inputClassName}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
{errors.confirmPassword ? (
|
||||
@@ -111,9 +169,9 @@ export function RegisterForm() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
{t("apiPending")}
|
||||
{apiError ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
138
apps/web/src/components/auth/reset-password-form.tsx
Normal file
138
apps/web/src/components/auth/reset-password-form.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { ApiError, resetPassword } from "@/lib/api/auth";
|
||||
import { createResetPasswordSchema, type ResetPasswordFormValues } from "@/lib/schemas/auth";
|
||||
|
||||
const inputClassName =
|
||||
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||
|
||||
export function ResetPasswordForm() {
|
||||
const t = useTranslations("auth");
|
||||
const tv = useTranslations("validation");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const schema = createResetPasswordSchema({
|
||||
passwordRequired: tv("passwordRequired"),
|
||||
passwordMin: tv("passwordMin"),
|
||||
confirmPasswordRequired: tv("confirmPasswordRequired"),
|
||||
passwordMismatch: tv("passwordMismatch"),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ResetPasswordFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { password: "", confirmPassword: "" },
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("resetPasswordTitle")}</CardTitle>
|
||||
<CardDescription>{t("resetPasswordMissingToken")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/forgot-password" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("forgotPasswordLink")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
async function onSubmit(values: ResetPasswordFormValues) {
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
await resetPassword(token!, values.password);
|
||||
router.push("/login");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setApiError(error.detail ?? error.title);
|
||||
} else {
|
||||
setApiError(t("errors.generic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("resetPasswordTitle")}</CardTitle>
|
||||
<CardDescription>{t("resetPasswordSubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("password")}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
aria-invalid={errors.password ? "true" : "false"}
|
||||
aria-describedby={errors.password ? "password-error" : undefined}
|
||||
className={inputClassName}
|
||||
{...register("password")}
|
||||
/>
|
||||
{errors.password ? (
|
||||
<p id="password-error" className="text-sm text-red-600" role="alert">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
|
||||
>
|
||||
{t("confirmPassword")}
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
aria-invalid={errors.confirmPassword ? "true" : "false"}
|
||||
aria-describedby={errors.confirmPassword ? "confirm-password-error" : undefined}
|
||||
className={inputClassName}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
{errors.confirmPassword ? (
|
||||
<p id="confirm-password-error" className="text-sm text-red-600" role="alert">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{apiError ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isSubmitting}>
|
||||
{t("resetPasswordSubmit")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
171
apps/web/src/components/auth/security-content.tsx
Normal file
171
apps/web/src/components/auth/security-content.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { ApiError, confirmTwoFactor, setupTwoFactor, type TwoFactorSetupResponse } from "@/lib/api/auth";
|
||||
import { createTwoFactorSchema, type TwoFactorFormValues } from "@/lib/schemas/auth";
|
||||
import { useAuth } from "@/components/providers/auth-provider";
|
||||
|
||||
const inputClassName =
|
||||
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||
|
||||
export function SecurityContent() {
|
||||
const t = useTranslations("auth");
|
||||
const tv = useTranslations("validation");
|
||||
const { user, refresh } = useAuth();
|
||||
const [setup, setSetup] = useState<TwoFactorSetupResponse | null>(null);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
|
||||
const schema = createTwoFactorSchema({
|
||||
codeRequired: tv("codeRequired"),
|
||||
codeInvalid: tv("codeInvalid"),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TwoFactorFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { code: "" },
|
||||
});
|
||||
|
||||
async function handleStartSetup() {
|
||||
setApiError(null);
|
||||
setIsStarting(true);
|
||||
|
||||
try {
|
||||
const response = await setupTwoFactor();
|
||||
setSetup(response);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setApiError(error.detail ?? error.title);
|
||||
} else {
|
||||
setApiError(t("errors.generic"));
|
||||
}
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(values: TwoFactorFormValues) {
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
await confirmTwoFactor(values.code);
|
||||
setConfirmed(true);
|
||||
setSetup(null);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setApiError(error.detail ?? error.title);
|
||||
} else {
|
||||
setApiError(t("errors.generic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
{t("securityTitle")}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("securitySubtitle")}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("twoFactorSetupTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{user?.twoFactorEnabled ? t("twoFactorEnabled") : t("twoFactorSetupDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{user?.twoFactorEnabled || confirmed ? (
|
||||
<p className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100">
|
||||
{t("twoFactorEnabled")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!user?.twoFactorEnabled && !confirmed && !setup ? (
|
||||
<Button onClick={handleStartSetup} isLoading={isStarting}>
|
||||
{t("twoFactorSetupStart")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{setup ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("twoFactorScanQr")}</p>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={setup.qrCodeUrl}
|
||||
alt={t("twoFactorQrAlt")}
|
||||
className="h-48 w-48 rounded-md border border-zinc-200 dark:border-zinc-800"
|
||||
/>
|
||||
<p className="font-mono text-xs text-zinc-500">{setup.secret}</p>
|
||||
|
||||
{setup.recoveryCodes?.length ? (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
|
||||
<p className="mb-2 text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
{t("recoveryCodesTitle")}
|
||||
</p>
|
||||
<ul className="grid gap-1 font-mono text-xs text-amber-900 dark:text-amber-100 sm:grid-cols-2">
|
||||
{setup.recoveryCodes.map((code) => (
|
||||
<li key={code}>{code}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="code" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("twoFactorCode")}
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
aria-invalid={errors.code ? "true" : "false"}
|
||||
className={inputClassName}
|
||||
{...register("code")}
|
||||
/>
|
||||
{errors.code ? (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{errors.code.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{apiError ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
{t("twoFactorConfirm")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{apiError && !setup ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
apps/web/src/components/auth/verify-email-content.tsx
Normal file
129
apps/web/src/components/auth/verify-email-content.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { ApiError, verifyEmail } from "@/lib/api/auth";
|
||||
|
||||
type VerifyState = "idle" | "loading" | "success" | "error";
|
||||
|
||||
export function VerifyEmailContent() {
|
||||
const t = useTranslations("auth");
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
const sent = searchParams.get("sent");
|
||||
const [state, setState] = useState<VerifyState>(token ? "loading" : "idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function verify() {
|
||||
try {
|
||||
await verifyEmail(token!);
|
||||
if (!cancelled) {
|
||||
setState("success");
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setState("error");
|
||||
if (error instanceof ApiError) {
|
||||
setErrorMessage(error.detail ?? error.title);
|
||||
} else {
|
||||
setErrorMessage(t("errors.generic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void verify();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, t]);
|
||||
|
||||
if (sent === "1") {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("verifyEmailTitle")}</CardTitle>
|
||||
<CardDescription>{t("verifyEmailSent")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("verifyEmailTitle")}</CardTitle>
|
||||
<CardDescription>{t("verifyEmailMissingToken")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "loading" || state === "idle") {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("verifyEmailTitle")}</CardTitle>
|
||||
<CardDescription>{t("verifyEmailLoading")}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "success") {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("verifyEmailTitle")}</CardTitle>
|
||||
<CardDescription>{t("verifyEmailSuccess")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("verifyEmailTitle")}</CardTitle>
|
||||
<CardDescription>{t("verifyEmailError")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{errorMessage ? (
|
||||
<p className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export function DashboardContent() {
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("welcome")}</p>
|
||||
<p className="mt-2 font-mono text-xs text-zinc-500">{t("protectedNote")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
48
apps/web/src/components/dashboard/dashboard-user-info.tsx
Normal file
48
apps/web/src/components/dashboard/dashboard-user-info.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@hexahost/ui";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { useAuth } from "@/components/providers/auth-provider";
|
||||
|
||||
export function DashboardUserInfo() {
|
||||
const t = useTranslations("common");
|
||||
const tAuth = useTranslations("auth");
|
||||
const { user, isLoading, logout } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<p className="font-mono text-xs text-zinc-500">{t("loading")}</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
{tAuth("sessionNone")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const displayLabel = user.displayName ?? user.username;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div className="text-right sm:text-left">
|
||||
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-50">{displayLabel}</p>
|
||||
<p className="font-mono text-xs text-zinc-500">{user.email}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/security"
|
||||
className="text-xs font-medium text-sky-600 hover:underline dark:text-sky-400"
|
||||
>
|
||||
{tAuth("securityTitle")}
|
||||
</Link>
|
||||
<Button variant="secondary" size="sm" onClick={() => void logout()}>
|
||||
{t("logout")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
apps/web/src/components/providers/auth-provider.tsx
Normal file
84
apps/web/src/components/providers/auth-provider.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
getCurrentUser,
|
||||
submitLogout,
|
||||
type CurrentUser,
|
||||
} from "@/lib/api/auth";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
|
||||
interface AuthContextValue {
|
||||
user: CurrentUser | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const currentUser = await getCurrentUser();
|
||||
setUser(currentUser);
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await submitLogout();
|
||||
} finally {
|
||||
setUser(null);
|
||||
router.push("/login");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated: user !== null,
|
||||
refresh,
|
||||
logout,
|
||||
}),
|
||||
[user, isLoading, refresh, logout],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -1,45 +1,211 @@
|
||||
import { getApiUrl } from "../env";
|
||||
|
||||
export interface ProblemDetails {
|
||||
type: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail?: string;
|
||||
instance?: string;
|
||||
errors?: { field?: string; message: string }[];
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly title: string;
|
||||
readonly detail?: string;
|
||||
readonly errors?: { field?: string; message: string }[];
|
||||
|
||||
constructor(problem: ProblemDetails) {
|
||||
super(problem.detail ?? problem.title);
|
||||
this.name = "ApiError";
|
||||
this.status = problem.status;
|
||||
this.title = problem.title;
|
||||
this.detail = problem.detail;
|
||||
this.errors = problem.errors;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
export interface RegisterPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepared API client for Phase 1 authentication.
|
||||
* Currently validates structure only; no network request is performed.
|
||||
*/
|
||||
export async function submitLogin(
|
||||
credentials: AuthCredentials,
|
||||
): Promise<AuthResponse> {
|
||||
const endpoint = `${getApiUrl()}/auth/login`;
|
||||
export interface LoginResponse {
|
||||
twoFactorRequired?: boolean;
|
||||
challengeId?: string;
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName?: string | null;
|
||||
emailVerifiedAt?: string | null;
|
||||
roles?: string[];
|
||||
twoFactorEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface TwoFactorSetupResponse {
|
||||
secret: string;
|
||||
qrCodeUrl: string;
|
||||
recoveryCodes?: string[];
|
||||
}
|
||||
|
||||
export interface UserSession {
|
||||
id: string;
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
async function parseProblemResponse(response: Response): Promise<ApiError> {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
|
||||
if (contentType.includes("application/problem+json")) {
|
||||
const problem = (await response.json()) as ProblemDetails;
|
||||
return new ApiError({
|
||||
...problem,
|
||||
status: problem.status ?? response.status,
|
||||
title: problem.title ?? "Request failed",
|
||||
});
|
||||
}
|
||||
|
||||
return new ApiError({
|
||||
type: "about:blank",
|
||||
title: "Request failed",
|
||||
status: response.status,
|
||||
detail: response.statusText || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const endpoint = `${getApiUrl()}${path}`;
|
||||
|
||||
if (!endpoint.startsWith("http")) {
|
||||
throw new Error("Invalid API URL configuration");
|
||||
}
|
||||
|
||||
void credentials;
|
||||
void endpoint;
|
||||
const headers = new Headers(options.headers);
|
||||
|
||||
return Promise.resolve({});
|
||||
if (options.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
...options,
|
||||
credentials: "include",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await parseProblemResponse(response);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
export async function submitLogin(
|
||||
credentials: AuthCredentials,
|
||||
): Promise<LoginResponse> {
|
||||
return apiFetch<LoginResponse>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitRegister(
|
||||
credentials: AuthCredentials,
|
||||
): Promise<AuthResponse> {
|
||||
const endpoint = `${getApiUrl()}/auth/register`;
|
||||
|
||||
if (!endpoint.startsWith("http")) {
|
||||
throw new Error("Invalid API URL configuration");
|
||||
payload: RegisterPayload,
|
||||
): Promise<void> {
|
||||
await apiFetch<void>("/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
void credentials;
|
||||
void endpoint;
|
||||
|
||||
return Promise.resolve({});
|
||||
export async function submitLogout(): Promise<void> {
|
||||
await apiFetch<void>("/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<CurrentUser> {
|
||||
return apiFetch<CurrentUser>("/me");
|
||||
}
|
||||
|
||||
export async function verifyEmail(token: string): Promise<void> {
|
||||
await apiFetch<void>("/auth/verify-email", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function forgotPassword(email: string): Promise<void> {
|
||||
await apiFetch<void>("/auth/forgot-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetPassword(
|
||||
token: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
await apiFetch<void>("/auth/reset-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token, newPassword }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setupTwoFactor(): Promise<TwoFactorSetupResponse> {
|
||||
return apiFetch<TwoFactorSetupResponse>("/auth/2fa/setup", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmTwoFactor(code: string): Promise<void> {
|
||||
await apiFetch<void>("/auth/2fa/confirm", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyTwoFactorLogin(
|
||||
challengeId: string,
|
||||
code: string,
|
||||
): Promise<void> {
|
||||
await apiFetch<void>("/auth/2fa/verify", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ challengeId, code }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSessions(): Promise<UserSession[]> {
|
||||
return apiFetch<UserSession[]>("/me/sessions");
|
||||
}
|
||||
|
||||
export async function revokeSession(sessionId: string): Promise<void> {
|
||||
await apiFetch<void>(`/me/sessions/${sessionId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function createRegisterSchema(messages: {
|
||||
passwordMin: string;
|
||||
confirmPasswordRequired: string;
|
||||
passwordMismatch: string;
|
||||
usernameInvalid?: string;
|
||||
}) {
|
||||
return z
|
||||
.object({
|
||||
@@ -32,6 +33,13 @@ export function createRegisterSchema(messages: {
|
||||
.string()
|
||||
.min(1, messages.emailRequired)
|
||||
.email(messages.emailInvalid),
|
||||
username: z
|
||||
.string()
|
||||
.refine((val) => val === "" || /^[a-zA-Z0-9_-]{3,32}$/.test(val), {
|
||||
message: messages.usernameInvalid ?? "Invalid username",
|
||||
})
|
||||
.default(""),
|
||||
displayName: z.string().max(64).default(""),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, messages.passwordRequired)
|
||||
@@ -44,5 +52,56 @@ export function createRegisterSchema(messages: {
|
||||
});
|
||||
}
|
||||
|
||||
export function createForgotPasswordSchema(messages: {
|
||||
emailRequired: string;
|
||||
emailInvalid: string;
|
||||
}) {
|
||||
return z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, messages.emailRequired)
|
||||
.email(messages.emailInvalid),
|
||||
});
|
||||
}
|
||||
|
||||
export function createResetPasswordSchema(messages: {
|
||||
passwordRequired: string;
|
||||
passwordMin: string;
|
||||
confirmPasswordRequired: string;
|
||||
passwordMismatch: string;
|
||||
}) {
|
||||
return z
|
||||
.object({
|
||||
password: z
|
||||
.string()
|
||||
.min(1, messages.passwordRequired)
|
||||
.min(8, messages.passwordMin),
|
||||
confirmPassword: z.string().min(1, messages.confirmPasswordRequired),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: messages.passwordMismatch,
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
}
|
||||
|
||||
export function createTwoFactorSchema(messages: {
|
||||
codeRequired: string;
|
||||
codeInvalid: string;
|
||||
}) {
|
||||
return z.object({
|
||||
code: z
|
||||
.string()
|
||||
.min(1, messages.codeRequired)
|
||||
.regex(/^\d{6}$/, messages.codeInvalid),
|
||||
});
|
||||
}
|
||||
|
||||
export type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
|
||||
export type RegisterFormValues = z.infer<ReturnType<typeof createRegisterSchema>>;
|
||||
export type ForgotPasswordFormValues = z.infer<
|
||||
ReturnType<typeof createForgotPasswordSchema>
|
||||
>;
|
||||
export type ResetPasswordFormValues = z.infer<
|
||||
ReturnType<typeof createResetPasswordSchema>
|
||||
>;
|
||||
export type TwoFactorFormValues = z.infer<ReturnType<typeof createTwoFactorSchema>>;
|
||||
|
||||
@@ -1,7 +1,50 @@
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { routing } from "./i18n/routing";
|
||||
|
||||
export default createMiddleware(routing);
|
||||
const intlMiddleware = createMiddleware(routing);
|
||||
|
||||
const protectedPaths = ["/dashboard", "/security"];
|
||||
|
||||
function getPathWithoutLocale(pathname: string): string {
|
||||
if (pathname === "/en" || pathname.startsWith("/en/")) {
|
||||
return pathname.slice(3) || "/";
|
||||
}
|
||||
|
||||
return pathname;
|
||||
}
|
||||
|
||||
function buildLocalizedPath(path: string, pathname: string): string {
|
||||
if (pathname === "/en" || pathname.startsWith("/en/")) {
|
||||
return `/en${path}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
export default function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const pathWithoutLocale = getPathWithoutLocale(pathname);
|
||||
|
||||
const isProtected = protectedPaths.some(
|
||||
(protectedPath) =>
|
||||
pathWithoutLocale === protectedPath ||
|
||||
pathWithoutLocale.startsWith(`${protectedPath}/`),
|
||||
);
|
||||
|
||||
if (isProtected) {
|
||||
const session = request.cookies.get("hgc_session");
|
||||
|
||||
if (!session?.value) {
|
||||
const loginUrl = request.nextUrl.clone();
|
||||
loginUrl.pathname = buildLocalizedPath("/login", pathname);
|
||||
loginUrl.searchParams.set("redirect", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return intlMiddleware(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/", "/(de|en)/:path*", "/((?!_next|_vercel|.*\\..*).*)"],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -15,11 +15,14 @@
|
||||
"@hexahost/config": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
"bullmq": "^5.34.10",
|
||||
"pino": "^9.6.0"
|
||||
"nodemailer": "^6.10.0",
|
||||
"pino": "^9.6.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hexahost/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
|
||||
38
apps/worker/src/config.ts
Normal file
38
apps/worker/src/config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const notificationEmailJobSchema = z.object({
|
||||
type: z.enum(['email_verification', 'password_reset']),
|
||||
to: z.string().email(),
|
||||
subject: z.string(),
|
||||
template: z.string(),
|
||||
data: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
export type NotificationEmailJob = z.infer<typeof notificationEmailJobSchema>;
|
||||
|
||||
const smtpConfigSchema = z.object({
|
||||
SMTP_HOST: z.string().min(1).default('localhost'),
|
||||
SMTP_PORT: z.coerce.number().int().default(1025),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
SMTP_FROM: z.string().default('noreply@localhost'),
|
||||
SMTP_TLS: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === 'true' || v === '1'),
|
||||
APP_NAME: z.string().default('HexaHost GameCloud'),
|
||||
});
|
||||
|
||||
export type SmtpConfig = z.infer<typeof smtpConfigSchema>;
|
||||
|
||||
export function getSmtpConfig(): SmtpConfig {
|
||||
return smtpConfigSchema.parse({
|
||||
SMTP_HOST: process.env['SMTP_HOST'],
|
||||
SMTP_PORT: process.env['SMTP_PORT'],
|
||||
SMTP_USER: process.env['SMTP_USER'],
|
||||
SMTP_PASSWORD: process.env['SMTP_PASSWORD'],
|
||||
SMTP_FROM: process.env['SMTP_FROM'],
|
||||
SMTP_TLS: process.env['SMTP_TLS'],
|
||||
APP_NAME: process.env['APP_NAME'],
|
||||
});
|
||||
}
|
||||
45
apps/worker/src/email.ts
Normal file
45
apps/worker/src/email.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
|
||||
import { getSmtpConfig, type SmtpConfig } from './config';
|
||||
|
||||
let transporter: Transporter | null = null;
|
||||
|
||||
function getTransporter(config: SmtpConfig): Transporter {
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: config.SMTP_HOST,
|
||||
port: config.SMTP_PORT,
|
||||
secure: config.SMTP_TLS,
|
||||
auth:
|
||||
config.SMTP_USER && config.SMTP_PASSWORD
|
||||
? { user: config.SMTP_USER, pass: config.SMTP_PASSWORD }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return transporter;
|
||||
}
|
||||
|
||||
export interface SendEmailPayload {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export async function sendEmail(payload: SendEmailPayload): Promise<void> {
|
||||
const config = getSmtpConfig();
|
||||
const transport = getTransporter(config);
|
||||
|
||||
await transport.sendMail({
|
||||
from: config.SMTP_FROM,
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
html: payload.html,
|
||||
text: payload.text ?? stripHtml(payload.html),
|
||||
});
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
33
apps/worker/src/handlers/notifications.ts
Normal file
33
apps/worker/src/handlers/notifications.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Job } from 'bullmq';
|
||||
|
||||
import { getSmtpConfig, notificationEmailJobSchema } from '../config';
|
||||
import { sendEmail } from '../email';
|
||||
import { logger } from '../logger';
|
||||
|
||||
export async function processNotificationJob(job: Job): Promise<{ sent: boolean }> {
|
||||
if (job.name !== 'send-email') {
|
||||
logger.warn({ jobName: job.name }, 'Unknown notification job');
|
||||
return { sent: false };
|
||||
}
|
||||
|
||||
const payload = notificationEmailJobSchema.parse(job.data);
|
||||
const config = getSmtpConfig();
|
||||
|
||||
let html: string;
|
||||
if (payload.type === 'email_verification') {
|
||||
const verifyUrl = String(payload.data['verifyUrl'] ?? '');
|
||||
html = `<p>Welcome to ${config.APP_NAME}!</p><p><a href="${verifyUrl}">Verify your email address</a></p><p>This link expires in 24 hours.</p>`;
|
||||
} else {
|
||||
const resetUrl = String(payload.data['resetUrl'] ?? '');
|
||||
html = `<p>You requested a password reset for ${config.APP_NAME}.</p><p><a href="${resetUrl}">Set a new password</a></p><p>If you did not request this, ignore this email.</p>`;
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
html,
|
||||
});
|
||||
|
||||
logger.info({ to: payload.to, type: payload.type, jobId: job.id }, 'Email sent');
|
||||
return { sent: true };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Job, Worker, type ConnectionOptions } from 'bullmq';
|
||||
import { validateConfig } from '@hexahost/config';
|
||||
import { prisma } from '@hexahost/database';
|
||||
|
||||
import { processNotificationJob } from './handlers/notifications';
|
||||
import { startHealthServer } from './health';
|
||||
import { logger } from './logger';
|
||||
import { WORKER_QUEUES, type WorkerQueueName } from './queues';
|
||||
@@ -19,6 +20,10 @@ function createQueueWorker(
|
||||
'Processing job',
|
||||
);
|
||||
|
||||
if (queueName === 'notifications') {
|
||||
return processNotificationJob(job);
|
||||
}
|
||||
|
||||
return { acknowledged: true, queue: queueName };
|
||||
},
|
||||
{ connection },
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,65 +2,54 @@
|
||||
|
||||
Last updated: 2026-06-26
|
||||
|
||||
## Current phase: Phase 0 — Repository and foundations ✅
|
||||
## Current phase: Phase 1 — Authentication & user panel ✅
|
||||
|
||||
### Completed
|
||||
### Phase 1 completed
|
||||
|
||||
| Area | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| Monorepo layout | Done | pnpm workspace, Turborepo, shared TypeScript config |
|
||||
| `@hexahost/database` | Done | Prisma 6, 14 Kernmodelle, UUID-IDs, optimistic locking |
|
||||
| `@hexahost/contracts` | Done | Zod-Schemas (Health, RFC7807, Pagination) + Vitest |
|
||||
| `@hexahost/config` | Done | Typisierter Env-Loader mit Validierung |
|
||||
| `@hexahost/auth` | Done | Rollen-Enum und Typen (Stub für Phase 1) |
|
||||
| `@hexahost/ui` | Done | Button, Card (Tailwind, a11y) |
|
||||
| `@hexahost/api` | Done | NestJS 11 + Fastify, Health, OpenAPI, Pino, Rate Limits |
|
||||
| `@hexahost/worker` | Done | BullMQ-Queues (Stubs), Health HTTP :3002 |
|
||||
| `@hexahost/web` | Done | Next.js 15, i18n de/en, Dark Mode, Landing, Auth-UI, Dashboard |
|
||||
| Go node-agent | Done | Protokoll-Typen, Health, Agent-Loop, Unit-Tests |
|
||||
| Go edge-gateway | Stub | Deaktiviert (Phase 8) |
|
||||
| Docker Compose dev | Done | PostgreSQL, Redis, MinIO, Mailpit, API, Worker, Web |
|
||||
| `.env.example` | Done | Vollständige Konfigurationsvorlage |
|
||||
| CI pipeline | Done | Lint, typecheck, test, build, Go-Tests |
|
||||
| Dokumentation | Done | ADR, Architektur, Threat Model, Installation |
|
||||
| WHMCS-Layout | Placeholder | Verzeichnisstruktur für spätere Integration |
|
||||
| README | Done | Startbefehle und Übersicht |
|
||||
| Prisma auth models | Done | EmailVerificationToken, PasswordResetToken, TotpCredential, RecoveryCode |
|
||||
| Migration | Done | `20260626120000_phase1_auth` + platform role seed |
|
||||
| `@hexahost/auth` | Done | Argon2id, tokens, TOTP, recovery codes, username utils |
|
||||
| API AuthModule | Done | Register, login, logout, verify, reset, 2FA, `/me`, sessions |
|
||||
| Session cookies | Done | `hgc_session`, HttpOnly, Secure in prod, SameSite lax |
|
||||
| Account lockout | Done | 5 attempts → 15 min lock |
|
||||
| Worker emails | Done | `notifications` queue → nodemailer (Mailpit in dev) |
|
||||
| Web auth UI | Done | Login, register, 2FA, forgot/reset, verify, security |
|
||||
| Route protection | Done | Middleware cookie check for dashboard/security |
|
||||
| Admin bootstrap | Done | `pnpm bootstrap:admin --email … --password …` |
|
||||
| Tests | Done | Auth package vitest, API health/auth tests, contracts |
|
||||
|
||||
### Abnahmekriterium Phase 0
|
||||
### Abnahmekriterium Phase 1
|
||||
|
||||
- [x] Monorepo-Struktur mit allen Grundverzeichnissen
|
||||
- [x] pnpm-workspace, Turborepo, Next.js, NestJS, Worker, Go-Module
|
||||
- [x] PostgreSQL/Prisma-Grundschema
|
||||
- [x] Redis- und MinIO-Konfiguration in Compose
|
||||
- [x] Healthchecks (API, Worker, Node Agent)
|
||||
- [x] Strukturiertes Logging (Pino)
|
||||
- [x] OpenAPI unter `/api/v1/docs`
|
||||
- [x] Basis-CI
|
||||
- [x] ADR und IMPLEMENTATION_STATUS
|
||||
- [x] Build, Lint, Typecheck und Tests lokal grün
|
||||
- [x] Registration with email verification flow
|
||||
- [x] Login/logout with secure sessions
|
||||
- [x] Password reset
|
||||
- [x] TOTP 2FA setup and login challenge
|
||||
- [x] Session list and revoke
|
||||
- [x] Admin bootstrap CLI (no default credentials)
|
||||
- [x] Audit events for security actions
|
||||
- [x] Unit/integration tests pass
|
||||
|
||||
### Known limitations
|
||||
|
||||
- Edge Gateway ist bewusst nicht implementiert (Join-to-Start = Phase 8).
|
||||
- Authentifizierung, Server-Lifecycle und Node-WebSocket folgen in Phase 1–2.
|
||||
- Traefik-, Ansible- und Produktions-Compose sind dokumentiert, nicht vollständig umgesetzt.
|
||||
- Docker Compose erfordert optional `docker network create traefik-network`.
|
||||
- WHMCS-Integration ist nur als Verzeichnisstruktur vorbereitet.
|
||||
- E2E Playwright tests for auth flow not yet added (planned).
|
||||
- Email templates are minimal HTML (no branded layout yet).
|
||||
- OIDC / Passkeys deferred to later phases.
|
||||
- CSRF relies on SameSite cookies + CORS origin check; no double-submit token yet.
|
||||
|
||||
### Test commands
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm db:migrate # or db:push
|
||||
pnpm test
|
||||
pnpm build
|
||||
cd apps/node-agent && make test
|
||||
pnpm bootstrap:admin -- --email admin@localhost --password 'your-secure-password'
|
||||
```
|
||||
|
||||
### Next phase: Phase 1 — Authentication & user panel
|
||||
### Next phase: Phase 2 — Single-Node Vertical Slice
|
||||
|
||||
- Registrierung, E-Mail-Verifizierung, Login, Sessions
|
||||
- Passwort-Reset, TOTP 2FA, Recovery Codes
|
||||
- Admin-Bootstrap-CLI
|
||||
- Vollständiger sicherer Login-Flow mit Tests
|
||||
- GameNode enrollment, Go agent WebSocket
|
||||
- Server create, provision, start/stop
|
||||
- First playable Minecraft server locally
|
||||
|
||||
30
docs/adr/0002-session-authentication.md
Normal file
30
docs/adr/0002-session-authentication.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# ADR 0002: Session-based authentication with HttpOnly cookies
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — 2026-06-26
|
||||
|
||||
## Context
|
||||
|
||||
Phase 1 requires secure user authentication without binding to external identity providers. The web app (Next.js) and API (NestJS) run on different origins in development (`localhost:3000` / `localhost:3001`) but share credentials via CORS.
|
||||
|
||||
## Decision
|
||||
|
||||
- Use opaque session tokens (32 random bytes), stored as SHA-256 hash in `UserSession`.
|
||||
- Deliver sessions via HttpOnly cookie `hgc_session` with `SameSite=lax` and `Secure` in production.
|
||||
- Password hashing with Argon2id via `@node-rs/argon2`.
|
||||
- TOTP 2FA with `otplib`; recovery codes stored hashed.
|
||||
- Login challenges for 2FA stored in Redis (5 min TTL).
|
||||
- Transactional emails via BullMQ `notifications` queue processed by worker + SMTP.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Browser clients must use `credentials: 'include'`.
|
||||
- API CORS must allow `APP_URL` with credentials.
|
||||
- Session revocation is immediate via DB `revokedAt`.
|
||||
- No JWT in localStorage (reduces XSS token theft risk).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- JWT in Authorization header — rejected for Phase 1 due to refresh complexity and XSS exposure in SPA storage.
|
||||
- External auth (Auth0, Clerk) — rejected per product requirements.
|
||||
@@ -14,7 +14,8 @@
|
||||
"test": "turbo run test",
|
||||
"db:generate": "pnpm --filter @hexahost/database db:generate",
|
||||
"db:migrate": "pnpm --filter @hexahost/database db:migrate",
|
||||
"db:push": "pnpm --filter @hexahost/database db:push"
|
||||
"db:push": "pnpm --filter @hexahost/database db:push",
|
||||
"bootstrap:admin": "pnpm --filter @hexahost/database bootstrap:admin"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.3.3",
|
||||
|
||||
@@ -6,10 +6,17 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"otplib": "^12.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hexahost/typescript-config": "workspace:*",
|
||||
"typescript": "^5.7.3"
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
emailVerified: boolean;
|
||||
roles: UserRole[];
|
||||
}
|
||||
|
||||
@@ -27,3 +29,35 @@ export interface AuthTokenPayload {
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export { hashPassword, verifyPassword } from './password';
|
||||
export {
|
||||
generateSecureToken,
|
||||
hashToken,
|
||||
verifyTokenHash,
|
||||
} from './tokens';
|
||||
export {
|
||||
generateUsernameFromEmail,
|
||||
sanitizeUsername,
|
||||
validateUsername,
|
||||
MIN_USERNAME_LENGTH,
|
||||
MAX_USERNAME_LENGTH,
|
||||
type UsernameValidationResult,
|
||||
} from './username';
|
||||
export {
|
||||
generateTotpSecret,
|
||||
verifyTotpCode,
|
||||
buildOtpAuthUrl,
|
||||
} from './totp';
|
||||
export {
|
||||
generateRecoveryCodes,
|
||||
hashRecoveryCode,
|
||||
verifyRecoveryCode,
|
||||
} from './recovery-codes';
|
||||
export {
|
||||
SESSION_COOKIE_NAME,
|
||||
SESSION_DURATION_MS,
|
||||
SESSION_ABSOLUTE_MAX_DURATION_MS,
|
||||
SESSION_REMEMBER_ME_DURATION_MS,
|
||||
createSessionExpiry,
|
||||
} from './session';
|
||||
|
||||
19
packages/auth/src/password.test.ts
Normal file
19
packages/auth/src/password.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hashPassword, verifyPassword } from "./password";
|
||||
|
||||
describe("password", () => {
|
||||
it("hashes and verifies a password roundtrip", async () => {
|
||||
const plain = "super-secure-password-123!";
|
||||
const hashed = await hashPassword(plain);
|
||||
|
||||
expect(hashed).not.toBe(plain);
|
||||
expect(await verifyPassword(plain, hashed)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an incorrect password", async () => {
|
||||
const hashed = await hashPassword("correct-password");
|
||||
|
||||
expect(await verifyPassword("wrong-password", hashed)).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user