Phase1
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 21s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 11:31:54 +02:00
parent 4fe25e6ec3
commit 58961000eb
123 changed files with 4231 additions and 136 deletions

View 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"]}

View File

@@ -0,0 +1 @@
{"hash":"01aded72040e0955","duration":6480,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"03b38917e0caabc328cf004288c0be00386c5e17753015d7a61c3d1ab22411f8"}

BIN
.turbo/cache/01aded72040e0955.tar.zst vendored Normal file

Binary file not shown.

View 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"]}

View File

@@ -0,0 +1 @@
{"hash":"0ff4947f463631d9","duration":3577,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"dab8b0c461d299de5e05eef85179aa41f735ebc86a2dbd665aa7a8294d8e0f47"}

BIN
.turbo/cache/0ff4947f463631d9.tar.zst vendored Normal file

Binary file not shown.

View 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"]}

View File

@@ -0,0 +1 @@
{"hash":"25ebafb8a1f6566c","duration":6143,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"60d70f90be15846aa2d4e953502544454caacdd04fb637eccb080515fac9d602"}

BIN
.turbo/cache/25ebafb8a1f6566c.tar.zst vendored Normal file

Binary file not shown.

View 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"]}

View File

@@ -0,0 +1 @@
{"hash":"2ed462e18f772857","duration":5689,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"dab8b0c461d299de5e05eef85179aa41f735ebc86a2dbd665aa7a8294d8e0f47"}

BIN
.turbo/cache/2ed462e18f772857.tar.zst vendored Normal file

Binary file not shown.

View 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"]}

View File

@@ -0,0 +1 @@
{"hash":"47b151676e5a4060","duration":8707,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"dab8b0c461d299de5e05eef85179aa41f735ebc86a2dbd665aa7a8294d8e0f47"}

BIN
.turbo/cache/47b151676e5a4060.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"53c0d11a4e5fec2f","duration":31664,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"dab8b0c461d299de5e05eef85179aa41f735ebc86a2dbd665aa7a8294d8e0f47"}

BIN
.turbo/cache/53c0d11a4e5fec2f.tar.zst vendored Normal file

Binary file not shown.

View 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"]}

View File

@@ -0,0 +1 @@
{"hash":"7f3e39c686a4adef","duration":4194,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"03b38917e0caabc328cf004288c0be00386c5e17753015d7a61c3d1ab22411f8"}

BIN
.turbo/cache/7f3e39c686a4adef.tar.zst vendored Normal file

Binary file not shown.

View 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"]}

View File

@@ -0,0 +1 @@
{"hash":"9166eecf844f7fc3","duration":1118,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"03b38917e0caabc328cf004288c0be00386c5e17753015d7a61c3d1ab22411f8"}

BIN
.turbo/cache/9166eecf844f7fc3.tar.zst vendored Normal file

Binary file not shown.

View 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"]}

View File

@@ -0,0 +1 @@
{"hash":"95146894d818d73f","duration":1367,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"03b38917e0caabc328cf004288c0be00386c5e17753015d7a61c3d1ab22411f8"}

BIN
.turbo/cache/95146894d818d73f.tar.zst vendored Normal file

Binary file not shown.

View 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"]}

View File

@@ -0,0 +1 @@
{"hash":"b82b7e64d65adff3","duration":1487,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"03b38917e0caabc328cf004288c0be00386c5e17753015d7a61c3d1ab22411f8"}

BIN
.turbo/cache/b82b7e64d65adff3.tar.zst vendored Normal file

Binary file not shown.

View 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"]}

View File

@@ -0,0 +1 @@
{"hash":"c4f5349c646cc586","duration":2420,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"dab8b0c461d299de5e05eef85179aa41f735ebc86a2dbd665aa7a8294d8e0f47"}

BIN
.turbo/cache/c4f5349c646cc586.tar.zst vendored Normal file

Binary file not shown.

View 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"]}

View File

@@ -0,0 +1 @@
{"hash":"d0ee437f2933e1de","duration":4736,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"03b38917e0caabc328cf004288c0be00386c5e17753015d7a61c3d1ab22411f8"}

BIN
.turbo/cache/d0ee437f2933e1de.tar.zst vendored Normal file

Binary file not shown.

View 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"]}

View File

@@ -0,0 +1 @@
{"hash":"d13c0ab7d99bf010","duration":1848,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"dab8b0c461d299de5e05eef85179aa41f735ebc86a2dbd665aa7a8294d8e0f47"}

BIN
.turbo/cache/d13c0ab7d99bf010.tar.zst vendored Normal file

Binary file not shown.

View 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"]}

View File

@@ -0,0 +1 @@
{"hash":"e7696fafab0d8009","duration":8228,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"e6e4282d4fd323bd1a7662eaa7f02275fa33de505ff447ad71e6eb9efeeffde7"}

BIN
.turbo/cache/e7696fafab0d8009.tar.zst vendored Normal file

Binary file not shown.

View 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"]}

View File

@@ -0,0 +1 @@
{"hash":"fac30371717dc3c3","duration":3877,"sha":"4fe25e6ec30fee526feaec3c9ca27a8782961b50","dirty_hash":"e6e4282d4fd323bd1a7662eaa7f02275fa33de505ff447ad71e6eb9efeeffde7"}

BIN
.turbo/cache/fac30371717dc3c3.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -92,10 +92,28 @@ make build
## Aktueller Stand ## 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. 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 ## Lizenz
Proprietär — siehe Repository-Eigentümer. Proprietär — siehe Repository-Eigentümer.

View File

@@ -12,7 +12,9 @@
"test": "node --test test/**/*.test.js" "test": "node --test test/**/*.test.js"
}, },
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.0.4", "@fastify/static": "^8.0.4",
"@hexahost/auth": "workspace:*",
"@hexahost/config": "workspace:*", "@hexahost/config": "workspace:*",
"@hexahost/contracts": "workspace:*", "@hexahost/contracts": "workspace:*",
"@hexahost/database": "workspace:*", "@hexahost/database": "workspace:*",
@@ -21,12 +23,15 @@
"@nestjs/platform-fastify": "^11.0.7", "@nestjs/platform-fastify": "^11.0.7",
"@nestjs/swagger": "^11.0.3", "@nestjs/swagger": "^11.0.3",
"@nestjs/throttler": "^6.4.0", "@nestjs/throttler": "^6.4.0",
"bullmq": "^5.34.8",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"ioredis": "^5.4.2",
"nestjs-pino": "^4.3.0", "nestjs-pino": "^4.3.0",
"pino": "^9.6.0", "pino": "^9.6.0",
"pino-http": "^10.4.0", "pino-http": "^10.4.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1",
"zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@hexahost/typescript-config": "workspace:*", "@hexahost/typescript-config": "workspace:*",

View File

@@ -3,6 +3,8 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { RequestIdMiddleware } from './common/middleware/request-id.middleware'; import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.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 { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
@@ -32,7 +34,9 @@ import { LoggerModule } from 'nestjs-pino';
}, },
]), ]),
PrismaModule, PrismaModule,
AppConfigModule,
HealthModule, HealthModule,
AuthModule,
], ],
providers: [ providers: [
{ {

View 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,
},
});
}
}

View 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,
);
}
}

View 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 {}

View 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;
}
}

View 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;
},
);

View File

@@ -0,0 +1,10 @@
export {
registerRequestSchema,
loginRequestSchema,
verifyEmailRequestSchema,
forgotPasswordRequestSchema,
resetPasswordRequestSchema,
twoFactorConfirmRequestSchema,
twoFactorVerifyRequestSchema,
twoFactorDisableRequestSchema,
} from '@hexahost/contracts';

View 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;
}
}

View 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);
}
}

View 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],
};
}
}

View 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;
}
}

View 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 {}

View File

@@ -0,0 +1,5 @@
import type { AppConfig } from '@hexahost/config';
export const APP_CONFIG = Symbol('APP_CONFIG');
export type { AppConfig };

View File

@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import cookie from '@fastify/cookie';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { import {
FastifyAdapter, FastifyAdapter,
@@ -30,8 +31,16 @@ async function bootstrap(): Promise<void> {
app.useLogger(app.get(Logger)); app.useLogger(app.get(Logger));
app.setGlobalPrefix('api/v1'); app.setGlobalPrefix('api/v1');
app.enableCors({
origin: config.APP_URL,
credentials: true,
});
app.useGlobalFilters(new Rfc7807ExceptionFilter()); app.useGlobalFilters(new Rfc7807ExceptionFilter());
await app.register(cookie, {
secret: config.SESSION_SECRET,
});
const swaggerConfig = new DocumentBuilder() const swaggerConfig = new DocumentBuilder()
.setTitle(config.APP_NAME) .setTitle(config.APP_NAME)
.setDescription('HexaHost GameCloud REST API') .setDescription('HexaHost GameCloud REST API')

View 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

View File

@@ -44,12 +44,51 @@
"registerSubtitle": "Registrieren Sie sich für die Plattform.", "registerSubtitle": "Registrieren Sie sich für die Plattform.",
"email": "E-Mail", "email": "E-Mail",
"emailPlaceholder": "name@beispiel.de", "emailPlaceholder": "name@beispiel.de",
"username": "Benutzername",
"usernamePlaceholder": "spieler123",
"displayName": "Anzeigename",
"displayNamePlaceholder": "Ihr Name",
"optional": "optional",
"password": "Passwort", "password": "Passwort",
"passwordPlaceholder": "••••••••", "passwordPlaceholder": "••••••••",
"confirmPassword": "Passwort bestätigen", "confirmPassword": "Passwort bestätigen",
"noAccount": "Noch kein Konto?", "noAccount": "Noch kein Konto?",
"hasAccount": "Bereits registriert?", "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": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -61,8 +100,7 @@
"status": "Status", "status": "Status",
"resources": "Ressourcen", "resources": "Ressourcen",
"activity": "Aktivität", "activity": "Aktivität",
"activityEmpty": "Keine aktuellen Ereignisse", "activityEmpty": "Keine aktuellen Ereignisse"
"protectedNote": "Geschützter Bereich — Authentifizierung wird in Phase 1 aktiviert."
}, },
"validation": { "validation": {
"emailRequired": "E-Mail ist erforderlich", "emailRequired": "E-Mail ist erforderlich",
@@ -70,6 +108,9 @@
"passwordRequired": "Passwort ist erforderlich", "passwordRequired": "Passwort ist erforderlich",
"passwordMin": "Passwort muss mindestens 8 Zeichen haben", "passwordMin": "Passwort muss mindestens 8 Zeichen haben",
"confirmPasswordRequired": "Passwortbestätigung ist erforderlich", "confirmPasswordRequired": "Passwortbestätigung ist erforderlich",
"passwordMismatch": "Passwörter stimmen nicht überein" "passwordMismatch": "Passwörter stimmen nicht überein",
"usernameInvalid": "Benutzername: 332 Zeichen (Buchstaben, Zahlen, _ und -)",
"codeRequired": "Authentifizierungscode ist erforderlich",
"codeInvalid": "Geben Sie einen gültigen 6-stelligen Code ein"
} }
} }

View File

@@ -44,12 +44,51 @@
"registerSubtitle": "Register for the platform.", "registerSubtitle": "Register for the platform.",
"email": "Email", "email": "Email",
"emailPlaceholder": "name@example.com", "emailPlaceholder": "name@example.com",
"username": "Username",
"usernamePlaceholder": "player123",
"displayName": "Display name",
"displayNamePlaceholder": "Your name",
"optional": "optional",
"password": "Password", "password": "Password",
"passwordPlaceholder": "••••••••", "passwordPlaceholder": "••••••••",
"confirmPassword": "Confirm password", "confirmPassword": "Confirm password",
"noAccount": "No account yet?", "noAccount": "No account yet?",
"hasAccount": "Already registered?", "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": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -61,8 +100,7 @@
"status": "Status", "status": "Status",
"resources": "Resources", "resources": "Resources",
"activity": "Activity", "activity": "Activity",
"activityEmpty": "No recent events", "activityEmpty": "No recent events"
"protectedNote": "Protected area — authentication will be enabled in Phase 1."
}, },
"validation": { "validation": {
"emailRequired": "Email is required", "emailRequired": "Email is required",
@@ -70,6 +108,9 @@
"passwordRequired": "Password is required", "passwordRequired": "Password is required",
"passwordMin": "Password must be at least 8 characters", "passwordMin": "Password must be at least 8 characters",
"confirmPasswordRequired": "Password confirmation is required", "confirmPasswordRequired": "Password confirmation is required",
"passwordMismatch": "Passwords do not match" "passwordMismatch": "Passwords do not match",
"usernameInvalid": "Username must be 332 characters (letters, numbers, _ and -)",
"codeRequired": "Authentication code is required",
"codeInvalid": "Enter a valid 6-digit code"
} }
} }

View File

@@ -1,6 +1,7 @@
import { getTranslations, setRequestLocale } from "next-intl/server"; import { getTranslations, setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Link } from "@/i18n/navigation"; import { Link } from "@/i18n/navigation";
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
interface DashboardLayoutProps { interface DashboardLayoutProps {
children: ReactNode; children: ReactNode;
@@ -31,9 +32,7 @@ export default async function DashboardLayout({
</li> </li>
</ul> </ul>
</nav> </nav>
<p className="font-mono text-xs text-zinc-500"> <DashboardUserInfo />
auth: pending · session: none
</p>
</div> </div>
{children} {children}
</div> </div>

View 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>
);
}

View File

@@ -3,6 +3,7 @@ import { getMessages, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { SiteLayout } from "@/components/layout/site-layout"; import { SiteLayout } from "@/components/layout/site-layout";
import { AuthProvider } from "@/components/providers/auth-provider";
import { QueryProvider } from "@/components/providers/query-provider"; import { QueryProvider } from "@/components/providers/query-provider";
import { ThemeProvider } from "@/components/providers/theme-provider"; import { ThemeProvider } from "@/components/providers/theme-provider";
import { routing, type Locale } from "@/i18n/routing"; import { routing, type Locale } from "@/i18n/routing";
@@ -30,7 +31,9 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
<NextIntlClientProvider messages={messages}> <NextIntlClientProvider messages={messages}>
<ThemeProvider> <ThemeProvider>
<QueryProvider> <QueryProvider>
<AuthProvider>
<SiteLayout>{children}</SiteLayout> <SiteLayout>{children}</SiteLayout>
</AuthProvider>
</QueryProvider> </QueryProvider>
</ThemeProvider> </ThemeProvider>
</NextIntlClientProvider> </NextIntlClientProvider>

View 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>
);
}

View File

@@ -1,3 +1,4 @@
import { Suspense } from "react";
import { setRequestLocale } from "next-intl/server"; import { setRequestLocale } from "next-intl/server";
import { LoginForm } from "@/components/auth/login-form"; import { LoginForm } from "@/components/auth/login-form";
@@ -11,7 +12,9 @@ export default async function LoginPage({ params }: LoginPageProps) {
return ( 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"> <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 /> <LoginForm />
</Suspense>
</div> </div>
); );
} }

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -2,17 +2,25 @@
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Link } from "@/i18n/navigation"; import { Link, useRouter } from "@/i18n/navigation";
import { submitLogin } from "@/lib/api/auth"; import { ApiError, submitLogin } from "@/lib/api/auth";
import { createLoginSchema, type LoginFormValues } from "@/lib/schemas/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() { export function LoginForm() {
const t = useTranslations("auth"); const t = useTranslations("auth");
const tv = useTranslations("validation"); 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({ const schema = createLoginSchema({
emailRequired: tv("emailRequired"), emailRequired: tv("emailRequired"),
@@ -31,8 +39,26 @@ export function LoginForm() {
}); });
async function onSubmit(values: LoginFormValues) { async function onSubmit(values: LoginFormValues) {
await submitLogin(values); setApiError(null);
setSubmitted(true);
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 ( return (
@@ -54,7 +80,7 @@ export function LoginForm() {
placeholder={t("emailPlaceholder")} placeholder={t("emailPlaceholder")}
aria-invalid={errors.email ? "true" : "false"} aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined} 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")} {...register("email")}
/> />
{errors.email ? ( {errors.email ? (
@@ -65,9 +91,17 @@ export function LoginForm() {
</div> </div>
<div className="space-y-2"> <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"> <label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("password")} {t("password")}
</label> </label>
<Link
href="/forgot-password"
className="text-xs font-medium text-sky-600 hover:underline dark:text-sky-400"
>
{t("forgotPasswordLink")}
</Link>
</div>
<input <input
id="password" id="password"
type="password" type="password"
@@ -75,7 +109,7 @@ export function LoginForm() {
placeholder={t("passwordPlaceholder")} placeholder={t("passwordPlaceholder")}
aria-invalid={errors.password ? "true" : "false"} aria-invalid={errors.password ? "true" : "false"}
aria-describedby={errors.password ? "password-error" : undefined} 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")} {...register("password")}
/> />
{errors.password ? ( {errors.password ? (
@@ -85,9 +119,9 @@ export function LoginForm() {
) : null} ) : null}
</div> </div>
{submitted ? ( {apiError ? (
<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"> <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">
{t("apiPending")} {apiError}
</p> </p>
) : null} ) : null}

View File

@@ -5,14 +5,18 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Link } from "@/i18n/navigation"; import { Link, useRouter } from "@/i18n/navigation";
import { submitRegister } from "@/lib/api/auth"; import { ApiError, submitRegister } from "@/lib/api/auth";
import { createRegisterSchema, type RegisterFormValues } from "@/lib/schemas/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() { export function RegisterForm() {
const t = useTranslations("auth"); const t = useTranslations("auth");
const tv = useTranslations("validation"); const tv = useTranslations("validation");
const [submitted, setSubmitted] = useState(false); const router = useRouter();
const [apiError, setApiError] = useState<string | null>(null);
const schema = createRegisterSchema({ const schema = createRegisterSchema({
emailRequired: tv("emailRequired"), emailRequired: tv("emailRequired"),
@@ -21,6 +25,7 @@ export function RegisterForm() {
passwordMin: tv("passwordMin"), passwordMin: tv("passwordMin"),
confirmPasswordRequired: tv("confirmPasswordRequired"), confirmPasswordRequired: tv("confirmPasswordRequired"),
passwordMismatch: tv("passwordMismatch"), passwordMismatch: tv("passwordMismatch"),
usernameInvalid: tv("usernameInvalid"),
}); });
const { const {
@@ -29,12 +34,27 @@ export function RegisterForm() {
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<RegisterFormValues>({ } = useForm<RegisterFormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { email: "", password: "", confirmPassword: "" }, defaultValues: { email: "", username: "", displayName: "", password: "", confirmPassword: "" },
}); });
async function onSubmit(values: RegisterFormValues) { async function onSubmit(values: RegisterFormValues) {
await submitRegister({ email: values.email, password: values.password }); setApiError(null);
setSubmitted(true);
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 ( return (
@@ -56,7 +76,7 @@ export function RegisterForm() {
placeholder={t("emailPlaceholder")} placeholder={t("emailPlaceholder")}
aria-invalid={errors.email ? "true" : "false"} aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined} 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")} {...register("email")}
/> />
{errors.email ? ( {errors.email ? (
@@ -66,6 +86,44 @@ export function RegisterForm() {
) : null} ) : null}
</div> </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"> <div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100"> <label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("password")} {t("password")}
@@ -77,7 +135,7 @@ export function RegisterForm() {
placeholder={t("passwordPlaceholder")} placeholder={t("passwordPlaceholder")}
aria-invalid={errors.password ? "true" : "false"} aria-invalid={errors.password ? "true" : "false"}
aria-describedby={errors.password ? "password-error" : undefined} 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")} {...register("password")}
/> />
{errors.password ? ( {errors.password ? (
@@ -101,7 +159,7 @@ export function RegisterForm() {
placeholder={t("passwordPlaceholder")} placeholder={t("passwordPlaceholder")}
aria-invalid={errors.confirmPassword ? "true" : "false"} aria-invalid={errors.confirmPassword ? "true" : "false"}
aria-describedby={errors.confirmPassword ? "confirm-password-error" : undefined} 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")} {...register("confirmPassword")}
/> />
{errors.confirmPassword ? ( {errors.confirmPassword ? (
@@ -111,9 +169,9 @@ export function RegisterForm() {
) : null} ) : null}
</div> </div>
{submitted ? ( {apiError ? (
<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"> <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">
{t("apiPending")} {apiError}
</p> </p>
) : null} ) : null}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -26,7 +26,6 @@ export function DashboardContent() {
{t("title")} {t("title")}
</h1> </h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("welcome")}</p> <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>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">

View 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>
);
}

View 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;
}

View File

@@ -1,45 +1,211 @@
import { getApiUrl } from "../env"; 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 { export interface AuthCredentials {
email: string; email: string;
password: string; password: string;
} }
export interface AuthResponse { export interface RegisterPayload {
accessToken?: string; email: string;
refreshToken?: string; password: string;
username?: string;
displayName?: string;
} }
/** export interface LoginResponse {
* Prepared API client for Phase 1 authentication. twoFactorRequired?: boolean;
* Currently validates structure only; no network request is performed. challengeId?: string;
*/ }
export async function submitLogin(
credentials: AuthCredentials, export interface CurrentUser {
): Promise<AuthResponse> { id: string;
const endpoint = `${getApiUrl()}/auth/login`; 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")) { if (!endpoint.startsWith("http")) {
throw new Error("Invalid API URL configuration"); throw new Error("Invalid API URL configuration");
} }
void credentials; const headers = new Headers(options.headers);
void endpoint;
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( export async function submitRegister(
credentials: AuthCredentials, payload: RegisterPayload,
): Promise<AuthResponse> { ): Promise<void> {
const endpoint = `${getApiUrl()}/auth/register`; await apiFetch<void>("/auth/register", {
method: "POST",
if (!endpoint.startsWith("http")) { body: JSON.stringify(payload),
throw new Error("Invalid API URL configuration"); });
} }
void credentials; export async function submitLogout(): Promise<void> {
void endpoint; await apiFetch<void>("/auth/logout", {
method: "POST",
return Promise.resolve({}); });
}
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",
});
} }

View File

@@ -25,6 +25,7 @@ export function createRegisterSchema(messages: {
passwordMin: string; passwordMin: string;
confirmPasswordRequired: string; confirmPasswordRequired: string;
passwordMismatch: string; passwordMismatch: string;
usernameInvalid?: string;
}) { }) {
return z return z
.object({ .object({
@@ -32,6 +33,13 @@ export function createRegisterSchema(messages: {
.string() .string()
.min(1, messages.emailRequired) .min(1, messages.emailRequired)
.email(messages.emailInvalid), .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 password: z
.string() .string()
.min(1, messages.passwordRequired) .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 LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
export type RegisterFormValues = z.infer<ReturnType<typeof createRegisterSchema>>; 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>>;

View File

@@ -1,7 +1,50 @@
import createMiddleware from "next-intl/middleware"; import createMiddleware from "next-intl/middleware";
import { type NextRequest, NextResponse } from "next/server";
import { routing } from "./i18n/routing"; 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 = { export const config = {
matcher: ["/", "/(de|en)/:path*", "/((?!_next|_vercel|.*\\..*).*)"], matcher: ["/", "/(de|en)/:path*", "/((?!_next|_vercel|.*\\..*).*)"],

File diff suppressed because one or more lines are too long

View File

@@ -15,11 +15,14 @@
"@hexahost/config": "workspace:*", "@hexahost/config": "workspace:*",
"@hexahost/database": "workspace:*", "@hexahost/database": "workspace:*",
"bullmq": "^5.34.10", "bullmq": "^5.34.10",
"pino": "^9.6.0" "nodemailer": "^6.10.0",
"pino": "^9.6.0",
"zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@hexahost/typescript-config": "workspace:*", "@hexahost/typescript-config": "workspace:*",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/nodemailer": "^6.4.17",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }

38
apps/worker/src/config.ts Normal file
View 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
View 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();
}

View 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 };
}

View File

@@ -3,6 +3,7 @@ import { Job, Worker, type ConnectionOptions } from 'bullmq';
import { validateConfig } from '@hexahost/config'; import { validateConfig } from '@hexahost/config';
import { prisma } from '@hexahost/database'; import { prisma } from '@hexahost/database';
import { processNotificationJob } from './handlers/notifications';
import { startHealthServer } from './health'; import { startHealthServer } from './health';
import { logger } from './logger'; import { logger } from './logger';
import { WORKER_QUEUES, type WorkerQueueName } from './queues'; import { WORKER_QUEUES, type WorkerQueueName } from './queues';
@@ -19,6 +20,10 @@ function createQueueWorker(
'Processing job', 'Processing job',
); );
if (queueName === 'notifications') {
return processNotificationJob(job);
}
return { acknowledged: true, queue: queueName }; return { acknowledged: true, queue: queueName };
}, },
{ connection }, { connection },

File diff suppressed because one or more lines are too long

View File

@@ -2,65 +2,54 @@
Last updated: 2026-06-26 Last updated: 2026-06-26
## Current phase: Phase 0Repository and foundations ## Current phase: Phase 1Authentication & user panel
### Completed ### Phase 1 completed
| Area | Status | Notes | | Area | Status | Notes |
|------|--------|-------| |------|--------|-------|
| Monorepo layout | Done | pnpm workspace, Turborepo, shared TypeScript config | | Prisma auth models | Done | EmailVerificationToken, PasswordResetToken, TotpCredential, RecoveryCode |
| `@hexahost/database` | Done | Prisma 6, 14 Kernmodelle, UUID-IDs, optimistic locking | | Migration | Done | `20260626120000_phase1_auth` + platform role seed |
| `@hexahost/contracts` | Done | Zod-Schemas (Health, RFC7807, Pagination) + Vitest | | `@hexahost/auth` | Done | Argon2id, tokens, TOTP, recovery codes, username utils |
| `@hexahost/config` | Done | Typisierter Env-Loader mit Validierung | | API AuthModule | Done | Register, login, logout, verify, reset, 2FA, `/me`, sessions |
| `@hexahost/auth` | Done | Rollen-Enum und Typen (Stub für Phase 1) | | Session cookies | Done | `hgc_session`, HttpOnly, Secure in prod, SameSite lax |
| `@hexahost/ui` | Done | Button, Card (Tailwind, a11y) | | Account lockout | Done | 5 attempts → 15 min lock |
| `@hexahost/api` | Done | NestJS 11 + Fastify, Health, OpenAPI, Pino, Rate Limits | | Worker emails | Done | `notifications` queue → nodemailer (Mailpit in dev) |
| `@hexahost/worker` | Done | BullMQ-Queues (Stubs), Health HTTP :3002 | | Web auth UI | Done | Login, register, 2FA, forgot/reset, verify, security |
| `@hexahost/web` | Done | Next.js 15, i18n de/en, Dark Mode, Landing, Auth-UI, Dashboard | | Route protection | Done | Middleware cookie check for dashboard/security |
| Go node-agent | Done | Protokoll-Typen, Health, Agent-Loop, Unit-Tests | | Admin bootstrap | Done | `pnpm bootstrap:admin --email … --password …` |
| Go edge-gateway | Stub | Deaktiviert (Phase 8) | | Tests | Done | Auth package vitest, API health/auth tests, contracts |
| 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 |
### Abnahmekriterium Phase 0 ### Abnahmekriterium Phase 1
- [x] Monorepo-Struktur mit allen Grundverzeichnissen - [x] Registration with email verification flow
- [x] pnpm-workspace, Turborepo, Next.js, NestJS, Worker, Go-Module - [x] Login/logout with secure sessions
- [x] PostgreSQL/Prisma-Grundschema - [x] Password reset
- [x] Redis- und MinIO-Konfiguration in Compose - [x] TOTP 2FA setup and login challenge
- [x] Healthchecks (API, Worker, Node Agent) - [x] Session list and revoke
- [x] Strukturiertes Logging (Pino) - [x] Admin bootstrap CLI (no default credentials)
- [x] OpenAPI unter `/api/v1/docs` - [x] Audit events for security actions
- [x] Basis-CI - [x] Unit/integration tests pass
- [x] ADR und IMPLEMENTATION_STATUS
- [x] Build, Lint, Typecheck und Tests lokal grün
### Known limitations ### Known limitations
- Edge Gateway ist bewusst nicht implementiert (Join-to-Start = Phase 8). - E2E Playwright tests for auth flow not yet added (planned).
- Authentifizierung, Server-Lifecycle und Node-WebSocket folgen in Phase 12. - Email templates are minimal HTML (no branded layout yet).
- Traefik-, Ansible- und Produktions-Compose sind dokumentiert, nicht vollständig umgesetzt. - OIDC / Passkeys deferred to later phases.
- Docker Compose erfordert optional `docker network create traefik-network`. - CSRF relies on SameSite cookies + CORS origin check; no double-submit token yet.
- WHMCS-Integration ist nur als Verzeichnisstruktur vorbereitet.
### Test commands ### Test commands
```bash ```bash
pnpm install pnpm install
pnpm lint pnpm db:migrate # or db:push
pnpm typecheck
pnpm test pnpm test
pnpm build pnpm build
cd apps/node-agent && make test pnpm bootstrap:admin -- --email admin@localhost --password 'your-secure-password'
``` ```
### Next phase: Phase 1Authentication & user panel ### Next phase: Phase 2Single-Node Vertical Slice
- Registrierung, E-Mail-Verifizierung, Login, Sessions - GameNode enrollment, Go agent WebSocket
- Passwort-Reset, TOTP 2FA, Recovery Codes - Server create, provision, start/stop
- Admin-Bootstrap-CLI - First playable Minecraft server locally
- Vollständiger sicherer Login-Flow mit Tests

View 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.

View File

@@ -14,7 +14,8 @@
"test": "turbo run test", "test": "turbo run test",
"db:generate": "pnpm --filter @hexahost/database db:generate", "db:generate": "pnpm --filter @hexahost/database db:generate",
"db:migrate": "pnpm --filter @hexahost/database db:migrate", "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": { "devDependencies": {
"turbo": "^2.3.3", "turbo": "^2.3.3",

View File

@@ -6,10 +6,17 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@node-rs/argon2": "^2.0.2",
"otplib": "^12.0.1"
}, },
"devDependencies": { "devDependencies": {
"@hexahost/typescript-config": "workspace:*", "@hexahost/typescript-config": "workspace:*",
"typescript": "^5.7.3" "@types/node": "^22.10.2",
"typescript": "^5.7.3",
"vitest": "^3.0.8"
} }
} }

View File

@@ -10,6 +10,8 @@ export interface AuthenticatedUser {
id: string; id: string;
email: string; email: string;
username: string; username: string;
displayName: string | null;
emailVerified: boolean;
roles: UserRole[]; roles: UserRole[];
} }
@@ -27,3 +29,35 @@ export interface AuthTokenPayload {
iat?: number; iat?: number;
exp?: 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';

View 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