Oauth ending avant securisation

EBO <eric.bouhana@softalys.com> committé le 2026-03-10 15:58

175e26113342b63b3b6068e0d95d4bc95c7ddf1d

1 parent(s)

147 fichiers modifiés +15038 -10597
M Cargo.lock
+914 -475
@@ -3,6 +3,41 @@
3 3 version = 4
4 4
5 5 [[package]]
6 +name = "aead"
7 +version = "0.5.2"
8 +source = "registry+https://github.com/rust-lang/crates.io-index"
9 +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
10 +dependencies = [
11 + "crypto-common",
12 + "generic-array",
13 +]
14 +
15 +[[package]]
16 +name = "aes"
17 +version = "0.8.4"
18 +source = "registry+https://github.com/rust-lang/crates.io-index"
19 +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
20 +dependencies = [
21 + "cfg-if",
22 + "cipher",
23 + "cpufeatures",
24 +]
25 +
26 +[[package]]
27 +name = "aes-gcm"
28 +version = "0.10.3"
29 +source = "registry+https://github.com/rust-lang/crates.io-index"
30 +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
31 +dependencies = [
32 + "aead",
33 + "aes",
34 + "cipher",
35 + "ctr",
36 + "ghash",
37 + "subtle",
38 +]
39 +
40 +[[package]]
6 41 name = "ahash"
7 42 version = "0.7.8"
8 43 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -310,11 +345,11 @@ dependencies = [
310 345
311 346 [[package]]
312 347 name = "askama"
313 -version = "0.14.0"
348 +version = "0.15.4"
314 349 source = "registry+https://github.com/rust-lang/crates.io-index"
315 -checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
350 +checksum = "08e1676b346cadfec169374f949d7490fd80a24193d37d2afce0c047cf695e57"
316 351 dependencies = [
317 - "askama_derive",
352 + "askama_macros",
318 353 "itoa",
319 354 "percent-encoding",
320 355 "serde",
@@ -323,9 +358,9 @@ dependencies = [
323 358
324 359 [[package]]
325 360 name = "askama_derive"
326 -version = "0.14.0"
361 +version = "0.15.4"
327 362 source = "registry+https://github.com/rust-lang/crates.io-index"
328 -checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
363 +checksum = "7661ff56517787343f376f75db037426facd7c8d3049cef8911f1e75016f3a37"
329 364 dependencies = [
330 365 "askama_parser",
331 366 "basic-toml",
@@ -339,14 +374,24 @@ dependencies = [
339 374 ]
340 375
341 376 [[package]]
377 +name = "askama_macros"
378 +version = "0.15.4"
379 +source = "registry+https://github.com/rust-lang/crates.io-index"
380 +checksum = "713ee4dbfd1eb719c2dab859465b01fa1d21cb566684614a713a6b7a99a4e47b"
381 +dependencies = [
382 + "askama_derive",
383 +]
384 +
385 +[[package]]
342 386 name = "askama_parser"
343 -version = "0.14.0"
387 +version = "0.15.4"
344 388 source = "registry+https://github.com/rust-lang/crates.io-index"
345 -checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
389 +checksum = "1d62d674238a526418b30c0def480d5beadb9d8964e7f38d635b03bf639c704c"
346 390 dependencies = [
347 - "memchr",
391 + "rustc-hash",
348 392 "serde",
349 393 "serde_derive",
394 + "unicode-ident",
350 395 "winnow",
351 396 ]
352 397
@@ -406,39 +451,11 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
406 451
407 452 [[package]]
408 453 name = "axum"
409 -version = "0.7.9"
410 -source = "registry+https://github.com/rust-lang/crates.io-index"
411 -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
412 -dependencies = [
413 - "async-trait",
414 - "axum-core 0.4.5",
415 - "bytes",
416 - "futures-util",
417 - "http",
418 - "http-body",
419 - "http-body-util",
420 - "itoa",
421 - "matchit 0.7.3",
422 - "memchr",
423 - "mime",
424 - "percent-encoding",
425 - "pin-project-lite",
426 - "rustversion",
427 - "serde",
428 - "sync_wrapper",
429 - "tower",
430 - "tower-layer",
431 - "tower-service",
432 - "tracing",
433 -]
434 -
435 -[[package]]
436 -name = "axum"
437 454 version = "0.8.8"
438 455 source = "registry+https://github.com/rust-lang/crates.io-index"
439 456 checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
440 457 dependencies = [
441 - "axum-core 0.5.6",
458 + "axum-core",
442 459 "axum-macros",
443 460 "bytes",
444 461 "form_urlencoded",
@@ -449,7 +466,7 @@ dependencies = [
449 466 "hyper",
450 467 "hyper-util",
451 468 "itoa",
452 - "matchit 0.8.4",
469 + "matchit",
453 470 "memchr",
454 471 "mime",
455 472 "percent-encoding",
@@ -468,27 +485,6 @@ dependencies = [
468 485
469 486 [[package]]
470 487 name = "axum-core"
471 -version = "0.4.5"
472 -source = "registry+https://github.com/rust-lang/crates.io-index"
473 -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
474 -dependencies = [
475 - "async-trait",
476 - "bytes",
477 - "futures-util",
478 - "http",
479 - "http-body",
480 - "http-body-util",
481 - "mime",
482 - "pin-project-lite",
483 - "rustversion",
484 - "sync_wrapper",
485 - "tower-layer",
486 - "tower-service",
487 - "tracing",
488 -]
489 -
490 -[[package]]
491 -name = "axum-core"
492 488 version = "0.5.6"
493 489 source = "registry+https://github.com/rust-lang/crates.io-index"
494 490 checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
@@ -508,45 +504,24 @@ dependencies = [
508 504
509 505 [[package]]
510 506 name = "axum-extra"
511 -version = "0.9.6"
507 +version = "0.12.5"
512 508 source = "registry+https://github.com/rust-lang/crates.io-index"
513 -checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
509 +checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76"
514 510 dependencies = [
515 - "axum 0.7.9",
516 - "axum-core 0.4.5",
511 + "axum",
512 + "axum-core",
517 513 "bytes",
518 514 "cookie",
519 - "fastrand",
515 + "futures-core",
520 516 "futures-util",
521 517 "http",
522 518 "http-body",
523 519 "http-body-util",
524 520 "mime",
525 - "multer",
526 521 "pin-project-lite",
527 - "serde",
528 - "tower",
529 - "tower-layer",
530 - "tower-service",
531 -]
532 -
533 -[[package]]
534 -name = "axum-login"
535 -version = "0.18.0"
536 -source = "registry+https://github.com/rust-lang/crates.io-index"
537 -checksum = "964ea6eb764a227baa8c3368e45c94d23b6863cc7b880c6c9e341c143c5a5ff7"
538 -dependencies = [
539 - "axum 0.8.8",
540 - "form_urlencoded",
541 - "serde",
542 - "subtle",
543 - "thiserror 2.0.18",
544 - "tower-cookies",
545 522 "tower-layer",
546 523 "tower-service",
547 - "tower-sessions",
548 524 "tracing",
549 - "urlencoding",
550 525 ]
551 526
552 527 [[package]]
@@ -561,6 +536,12 @@ dependencies = [
561 536 ]
562 537
563 538 [[package]]
539 +name = "base16ct"
540 +version = "0.2.0"
541 +source = "registry+https://github.com/rust-lang/crates.io-index"
542 +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
543 +
544 +[[package]]
564 545 name = "base64"
565 546 version = "0.13.1"
566 547 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -568,6 +549,12 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
568 549
569 550 [[package]]
570 551 name = "base64"
552 +version = "0.21.7"
553 +source = "registry+https://github.com/rust-lang/crates.io-index"
554 +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
555 +
556 +[[package]]
557 +name = "base64"
571 558 version = "0.22.1"
572 559 source = "registry+https://github.com/rust-lang/crates.io-index"
573 560 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
@@ -589,13 +576,13 @@ dependencies = [
589 576
590 577 [[package]]
591 578 name = "bcrypt"
592 -version = "0.15.1"
579 +version = "0.19.0"
593 580 source = "registry+https://github.com/rust-lang/crates.io-index"
594 -checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7"
581 +checksum = "523ab528ce3a7ada6597f8ccf5bd8d85ebe26d5edf311cad4d1d3cfb2d357ac6"
595 582 dependencies = [
596 583 "base64 0.22.1",
597 584 "blowfish",
598 - "getrandom 0.2.17",
585 + "getrandom 0.4.2",
599 586 "subtle",
600 587 "zeroize",
601 588 ]
@@ -984,23 +971,82 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
984 971 checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
985 972
986 973 [[package]]
974 +name = "crypto-bigint"
975 +version = "0.5.5"
976 +source = "registry+https://github.com/rust-lang/crates.io-index"
977 +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
978 +dependencies = [
979 + "generic-array",
980 + "rand_core 0.6.4",
981 + "subtle",
982 + "zeroize",
983 +]
984 +
985 +[[package]]
987 986 name = "crypto-common"
988 987 version = "0.1.7"
989 988 source = "registry+https://github.com/rust-lang/crates.io-index"
990 989 checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
991 990 dependencies = [
992 991 "generic-array",
992 + "rand_core 0.6.4",
993 993 "typenum",
994 994 ]
995 995
996 996 [[package]]
997 +name = "ctr"
998 +version = "0.9.2"
999 +source = "registry+https://github.com/rust-lang/crates.io-index"
1000 +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
1001 +dependencies = [
1002 + "cipher",
1003 +]
1004 +
1005 +[[package]]
1006 +name = "curve25519-dalek"
1007 +version = "4.1.3"
1008 +source = "registry+https://github.com/rust-lang/crates.io-index"
1009 +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
1010 +dependencies = [
1011 + "cfg-if",
1012 + "cpufeatures",
1013 + "curve25519-dalek-derive",
1014 + "digest",
1015 + "fiat-crypto",
1016 + "rustc_version",
1017 + "subtle",
1018 + "zeroize",
1019 +]
1020 +
1021 +[[package]]
1022 +name = "curve25519-dalek-derive"
1023 +version = "0.1.1"
1024 +source = "registry+https://github.com/rust-lang/crates.io-index"
1025 +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
1026 +dependencies = [
1027 + "proc-macro2",
1028 + "quote",
1029 + "syn 2.0.117",
1030 +]
1031 +
1032 +[[package]]
997 1033 name = "darling"
998 1034 version = "0.20.11"
999 1035 source = "registry+https://github.com/rust-lang/crates.io-index"
1000 1036 checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
1001 1037 dependencies = [
1002 - "darling_core",
1003 - "darling_macro",
1038 + "darling_core 0.20.11",
1039 + "darling_macro 0.20.11",
1040 +]
1041 +
1042 +[[package]]
1043 +name = "darling"
1044 +version = "0.21.3"
1045 +source = "registry+https://github.com/rust-lang/crates.io-index"
1046 +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
1047 +dependencies = [
1048 + "darling_core 0.21.3",
1049 + "darling_macro 0.21.3",
1004 1050 ]
1005 1051
1006 1052 [[package]]
@@ -1018,12 +1064,37 @@ dependencies = [
1018 1064 ]
1019 1065
1020 1066 [[package]]
1067 +name = "darling_core"
1068 +version = "0.21.3"
1069 +source = "registry+https://github.com/rust-lang/crates.io-index"
1070 +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
1071 +dependencies = [
1072 + "fnv",
1073 + "ident_case",
1074 + "proc-macro2",
1075 + "quote",
1076 + "strsim",
1077 + "syn 2.0.117",
1078 +]
1079 +
1080 +[[package]]
1021 1081 name = "darling_macro"
1022 1082 version = "0.20.11"
1023 1083 source = "registry+https://github.com/rust-lang/crates.io-index"
1024 1084 checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
1025 1085 dependencies = [
1026 - "darling_core",
1086 + "darling_core 0.20.11",
1087 + "quote",
1088 + "syn 2.0.117",
1089 +]
1090 +
1091 +[[package]]
1092 +name = "darling_macro"
1093 +version = "0.21.3"
1094 +source = "registry+https://github.com/rust-lang/crates.io-index"
1095 +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
1096 +dependencies = [
1097 + "darling_core 0.21.3",
1027 1098 "quote",
1028 1099 "syn 2.0.117",
1029 1100 ]
@@ -1115,6 +1186,50 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1115 1186 checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
1116 1187
1117 1188 [[package]]
1189 +name = "dyn-clone"
1190 +version = "1.0.20"
1191 +source = "registry+https://github.com/rust-lang/crates.io-index"
1192 +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
1193 +
1194 +[[package]]
1195 +name = "ecdsa"
1196 +version = "0.16.9"
1197 +source = "registry+https://github.com/rust-lang/crates.io-index"
1198 +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
1199 +dependencies = [
1200 + "der",
1201 + "digest",
1202 + "elliptic-curve",
1203 + "rfc6979",
1204 + "signature",
1205 + "spki",
1206 +]
1207 +
1208 +[[package]]
1209 +name = "ed25519"
1210 +version = "2.2.3"
1211 +source = "registry+https://github.com/rust-lang/crates.io-index"
1212 +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
1213 +dependencies = [
1214 + "pkcs8",
1215 + "signature",
1216 +]
1217 +
1218 +[[package]]
1219 +name = "ed25519-dalek"
1220 +version = "2.2.0"
1221 +source = "registry+https://github.com/rust-lang/crates.io-index"
1222 +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
1223 +dependencies = [
1224 + "curve25519-dalek",
1225 + "ed25519",
1226 + "serde",
1227 + "sha2",
1228 + "subtle",
1229 + "zeroize",
1230 +]
1231 +
1232 +[[package]]
1118 1233 name = "either"
1119 1234 version = "1.15.0"
1120 1235 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1124,6 +1239,27 @@ dependencies = [
1124 1239 ]
1125 1240
1126 1241 [[package]]
1242 +name = "elliptic-curve"
1243 +version = "0.13.8"
1244 +source = "registry+https://github.com/rust-lang/crates.io-index"
1245 +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
1246 +dependencies = [
1247 + "base16ct",
1248 + "crypto-bigint",
1249 + "digest",
1250 + "ff",
1251 + "generic-array",
1252 + "group",
1253 + "hkdf",
1254 + "pem-rfc7468",
1255 + "pkcs8",
1256 + "rand_core 0.6.4",
1257 + "sec1",
1258 + "subtle",
1259 + "zeroize",
1260 +]
1261 +
1262 +[[package]]
1127 1263 name = "email-encoding"
1128 1264 version = "0.4.1"
1129 1265 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1199,6 +1335,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1199 1335 checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
1200 1336
1201 1337 [[package]]
1338 +name = "ff"
1339 +version = "0.13.1"
1340 +source = "registry+https://github.com/rust-lang/crates.io-index"
1341 +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
1342 +dependencies = [
1343 + "rand_core 0.6.4",
1344 + "subtle",
1345 +]
1346 +
1347 +[[package]]
1348 +name = "fiat-crypto"
1349 +version = "0.2.9"
1350 +source = "registry+https://github.com/rust-lang/crates.io-index"
1351 +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
1352 +
1353 +[[package]]
1202 1354 name = "find-msvc-tools"
1203 1355 version = "0.1.9"
1204 1356 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1282,7 +1434,7 @@ dependencies = [
1282 1434 "futures",
1283 1435 "log",
1284 1436 "parking_lot",
1285 - "rand",
1437 + "rand 0.8.5",
1286 1438 "redis-protocol",
1287 1439 "semver",
1288 1440 "socket2 0.5.10",
@@ -1426,6 +1578,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
1426 1578 dependencies = [
1427 1579 "typenum",
1428 1580 "version_check",
1581 + "zeroize",
1429 1582 ]
1430 1583
1431 1584 [[package]]
@@ -1448,9 +1601,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1448 1601 checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
1449 1602 dependencies = [
1450 1603 "cfg-if",
1604 + "js-sys",
1451 1605 "libc",
1452 1606 "r-efi 5.3.0",
1453 1607 "wasip2",
1608 + "wasm-bindgen",
1454 1609 ]
1455 1610
1456 1611 [[package]]
@@ -1467,12 +1622,33 @@ dependencies = [
1467 1622 ]
1468 1623
1469 1624 [[package]]
1625 +name = "ghash"
1626 +version = "0.5.1"
1627 +source = "registry+https://github.com/rust-lang/crates.io-index"
1628 +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
1629 +dependencies = [
1630 + "opaque-debug",
1631 + "polyval",
1632 +]
1633 +
1634 +[[package]]
1470 1635 name = "glob"
1471 1636 version = "0.3.3"
1472 1637 source = "registry+https://github.com/rust-lang/crates.io-index"
1473 1638 checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
1474 1639
1475 1640 [[package]]
1641 +name = "group"
1642 +version = "0.13.0"
1643 +source = "registry+https://github.com/rust-lang/crates.io-index"
1644 +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
1645 +dependencies = [
1646 + "ff",
1647 + "rand_core 0.6.4",
1648 + "subtle",
1649 +]
1650 +
1651 +[[package]]
1476 1652 name = "h2"
1477 1653 version = "0.4.13"
1478 1654 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1484,7 +1660,7 @@ dependencies = [
1484 1660 "futures-core",
1485 1661 "futures-sink",
1486 1662 "http",
1487 - "indexmap",
1663 + "indexmap 2.13.0",
1488 1664 "slab",
1489 1665 "tokio",
1490 1666 "tokio-util",
@@ -1691,28 +1867,61 @@ dependencies = [
1691 1867 ]
1692 1868
1693 1869 [[package]]
1694 -name = "hyper-util"
1695 -version = "0.1.20"
1870 +name = "hyper-rustls"
1871 +version = "0.27.7"
1696 1872 source = "registry+https://github.com/rust-lang/crates.io-index"
1697 -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
1873 +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
1698 1874 dependencies = [
1699 - "base64 0.22.1",
1700 - "bytes",
1701 - "futures-channel",
1702 - "futures-util",
1703 1875 "http",
1704 - "http-body",
1705 1876 "hyper",
1706 - "ipnet",
1707 - "libc",
1708 - "percent-encoding",
1709 - "pin-project-lite",
1710 - "socket2 0.6.2",
1711 - "system-configuration",
1877 + "hyper-util",
1878 + "rustls",
1879 + "rustls-pki-types",
1712 1880 "tokio",
1713 - "tower-layer",
1881 + "tokio-rustls",
1714 1882 "tower-service",
1715 - "tracing",
1883 + "webpki-roots",
1884 +]
1885 +
1886 +[[package]]
1887 +name = "hyper-tls"
1888 +version = "0.6.0"
1889 +source = "registry+https://github.com/rust-lang/crates.io-index"
1890 +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
1891 +dependencies = [
1892 + "bytes",
1893 + "http-body-util",
1894 + "hyper",
1895 + "hyper-util",
1896 + "native-tls",
1897 + "tokio",
1898 + "tokio-native-tls",
1899 + "tower-service",
1900 +]
1901 +
1902 +[[package]]
1903 +name = "hyper-util"
1904 +version = "0.1.20"
1905 +source = "registry+https://github.com/rust-lang/crates.io-index"
1906 +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
1907 +dependencies = [
1908 + "base64 0.22.1",
1909 + "bytes",
1910 + "futures-channel",
1911 + "futures-util",
1912 + "http",
1913 + "http-body",
1914 + "hyper",
1915 + "ipnet",
1916 + "libc",
1917 + "percent-encoding",
1918 + "pin-project-lite",
1919 + "socket2 0.6.3",
1920 + "system-configuration",
1921 + "tokio",
1922 + "tower-layer",
1923 + "tower-service",
1924 + "tracing",
1716 1925 "windows-registry",
1717 1926 ]
1718 1927
@@ -1881,6 +2090,17 @@ dependencies = [
1881 2090
1882 2091 [[package]]
1883 2092 name = "indexmap"
2093 +version = "1.9.3"
2094 +source = "registry+https://github.com/rust-lang/crates.io-index"
2095 +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
2096 +dependencies = [
2097 + "autocfg",
2098 + "hashbrown 0.12.3",
2099 + "serde",
2100 +]
2101 +
2102 +[[package]]
2103 +name = "indexmap"
1884 2104 version = "2.13.0"
1885 2105 source = "registry+https://github.com/rust-lang/crates.io-index"
1886 2106 checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
@@ -1927,6 +2147,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1927 2147 checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
1928 2148
1929 2149 [[package]]
2150 +name = "iri-string"
2151 +version = "0.7.10"
2152 +source = "registry+https://github.com/rust-lang/crates.io-index"
2153 +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
2154 +dependencies = [
2155 + "memchr",
2156 + "serde",
2157 +]
2158 +
2159 +[[package]]
1930 2160 name = "is_terminal_polyfill"
1931 2161 version = "1.70.2"
1932 2162 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1934,6 +2164,15 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
1934 2164
1935 2165 [[package]]
1936 2166 name = "itertools"
2167 +version = "0.10.5"
2168 +source = "registry+https://github.com/rust-lang/crates.io-index"
2169 +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
2170 +dependencies = [
2171 + "either",
2172 +]
2173 +
2174 +[[package]]
2175 +name = "itertools"
1937 2176 version = "0.14.0"
1938 2177 source = "registry+https://github.com/rust-lang/crates.io-index"
1939 2178 checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
@@ -1959,16 +2198,24 @@ dependencies = [
1959 2198
1960 2199 [[package]]
1961 2200 name = "jsonwebtoken"
1962 -version = "9.3.1"
2201 +version = "10.3.0"
1963 2202 source = "registry+https://github.com/rust-lang/crates.io-index"
1964 -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
2203 +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1"
1965 2204 dependencies = [
1966 2205 "base64 0.22.1",
2206 + "ed25519-dalek",
2207 + "getrandom 0.2.17",
2208 + "hmac",
1967 2209 "js-sys",
2210 + "p256",
2211 + "p384",
1968 2212 "pem",
1969 - "ring",
2213 + "rand 0.8.5",
2214 + "rsa",
1970 2215 "serde",
1971 2216 "serde_json",
2217 + "sha2",
2218 + "signature",
1972 2219 "simple_asn1",
1973 2220 ]
1974 2221
@@ -2009,7 +2256,7 @@ dependencies = [
2009 2256 "nom 8.0.0",
2010 2257 "percent-encoding",
2011 2258 "quoted_printable",
2012 - "socket2 0.6.2",
2259 + "socket2 0.6.3",
2013 2260 "tokio",
2014 2261 "tokio-native-tls",
2015 2262 "url",
@@ -2087,9 +2334,9 @@ dependencies = [
2087 2334
2088 2335 [[package]]
2089 2336 name = "libc"
2090 -version = "0.2.182"
2337 +version = "0.2.183"
2091 2338 source = "registry+https://github.com/rust-lang/crates.io-index"
2092 -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
2339 +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
2093 2340
2094 2341 [[package]]
2095 2342 name = "libm"
@@ -2148,6 +2395,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2148 2395 checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
2149 2396
2150 2397 [[package]]
2398 +name = "lru-slab"
2399 +version = "0.1.2"
2400 +source = "registry+https://github.com/rust-lang/crates.io-index"
2401 +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
2402 +
2403 +[[package]]
2404 +name = "mac_address"
2405 +version = "1.1.8"
2406 +source = "registry+https://github.com/rust-lang/crates.io-index"
2407 +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
2408 +dependencies = [
2409 + "nix",
2410 + "serde",
2411 + "winapi",
2412 +]
2413 +
2414 +[[package]]
2151 2415 name = "matchers"
2152 2416 version = "0.2.0"
2153 2417 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2158,12 +2422,6 @@ dependencies = [
2158 2422
2159 2423 [[package]]
2160 2424 name = "matchit"
2161 -version = "0.7.3"
2162 -source = "registry+https://github.com/rust-lang/crates.io-index"
2163 -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
2164 -
2165 -[[package]]
2166 -name = "matchit"
2167 2425 version = "0.8.4"
2168 2426 source = "registry+https://github.com/rust-lang/crates.io-index"
2169 2427 checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
@@ -2185,6 +2443,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2185 2443 checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
2186 2444
2187 2445 [[package]]
2446 +name = "memoffset"
2447 +version = "0.9.1"
2448 +source = "registry+https://github.com/rust-lang/crates.io-index"
2449 +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
2450 +dependencies = [
2451 + "autocfg",
2452 +]
2453 +
2454 +[[package]]
2188 2455 name = "mime"
2189 2456 version = "0.3.17"
2190 2457 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2218,23 +2485,6 @@ dependencies = [
2218 2485 ]
2219 2486
2220 2487 [[package]]
2221 -name = "multer"
2222 -version = "3.1.0"
2223 -source = "registry+https://github.com/rust-lang/crates.io-index"
2224 -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
2225 -dependencies = [
2226 - "bytes",
2227 - "encoding_rs",
2228 - "futures-util",
2229 - "http",
2230 - "httparse",
2231 - "memchr",
2232 - "mime",
2233 - "spin",
2234 - "version_check",
2235 -]
2236 -
2237 -[[package]]
2238 2488 name = "native-tls"
2239 2489 version = "0.2.18"
2240 2490 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2252,6 +2502,19 @@ dependencies = [
2252 2502 ]
2253 2503
2254 2504 [[package]]
2505 +name = "nix"
2506 +version = "0.29.0"
2507 +source = "registry+https://github.com/rust-lang/crates.io-index"
2508 +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
2509 +dependencies = [
2510 + "bitflags 2.11.0",
2511 + "cfg-if",
2512 + "cfg_aliases",
2513 + "libc",
2514 + "memoffset",
2515 +]
2516 +
2517 +[[package]]
2255 2518 name = "nom"
2256 2519 version = "5.1.3"
2257 2520 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2311,7 +2574,7 @@ dependencies = [
2311 2574 "num-integer",
2312 2575 "num-iter",
2313 2576 "num-traits",
2314 - "rand",
2577 + "rand 0.8.5",
2315 2578 "smallvec",
2316 2579 "zeroize",
2317 2580 ]
@@ -2372,6 +2635,26 @@ dependencies = [
2372 2635 ]
2373 2636
2374 2637 [[package]]
2638 +name = "oauth2"
2639 +version = "5.0.0"
2640 +source = "registry+https://github.com/rust-lang/crates.io-index"
2641 +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
2642 +dependencies = [
2643 + "base64 0.22.1",
2644 + "chrono",
2645 + "getrandom 0.2.17",
2646 + "http",
2647 + "rand 0.8.5",
2648 + "reqwest",
2649 + "serde",
2650 + "serde_json",
2651 + "serde_path_to_error",
2652 + "sha2",
2653 + "thiserror 1.0.69",
2654 + "url",
2655 +]
2656 +
2657 +[[package]]
2375 2658 name = "object"
2376 2659 version = "0.37.3"
2377 2660 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2393,6 +2676,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2393 2676 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
2394 2677
2395 2678 [[package]]
2679 +name = "opaque-debug"
2680 +version = "0.3.1"
2681 +source = "registry+https://github.com/rust-lang/crates.io-index"
2682 +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
2683 +
2684 +[[package]]
2685 +name = "openidconnect"
2686 +version = "4.0.1"
2687 +source = "registry+https://github.com/rust-lang/crates.io-index"
2688 +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2"
2689 +dependencies = [
2690 + "base64 0.21.7",
2691 + "chrono",
2692 + "dyn-clone",
2693 + "ed25519-dalek",
2694 + "hmac",
2695 + "http",
2696 + "itertools 0.10.5",
2697 + "log",
2698 + "oauth2",
2699 + "p256",
2700 + "p384",
2701 + "rand 0.8.5",
2702 + "rsa",
2703 + "serde",
2704 + "serde-value",
2705 + "serde_json",
2706 + "serde_path_to_error",
2707 + "serde_plain",
2708 + "serde_with",
2709 + "sha2",
2710 + "subtle",
2711 + "thiserror 1.0.69",
2712 + "url",
2713 +]
2714 +
2715 +[[package]]
2396 2716 name = "openssl"
2397 2717 version = "0.10.75"
2398 2718 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2438,6 +2758,15 @@ dependencies = [
2438 2758
2439 2759 [[package]]
2440 2760 name = "ordered-float"
2761 +version = "2.10.1"
2762 +source = "registry+https://github.com/rust-lang/crates.io-index"
2763 +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
2764 +dependencies = [
2765 + "num-traits",
2766 +]
2767 +
2768 +[[package]]
2769 +name = "ordered-float"
2441 2770 version = "4.6.0"
2442 2771 source = "registry+https://github.com/rust-lang/crates.io-index"
2443 2772 checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
@@ -2470,6 +2799,30 @@ dependencies = [
2470 2799 ]
2471 2800
2472 2801 [[package]]
2802 +name = "p256"
2803 +version = "0.13.2"
2804 +source = "registry+https://github.com/rust-lang/crates.io-index"
2805 +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
2806 +dependencies = [
2807 + "ecdsa",
2808 + "elliptic-curve",
2809 + "primeorder",
2810 + "sha2",
2811 +]
2812 +
2813 +[[package]]
2814 +name = "p384"
2815 +version = "0.13.1"
2816 +source = "registry+https://github.com/rust-lang/crates.io-index"
2817 +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
2818 +dependencies = [
2819 + "ecdsa",
2820 + "elliptic-curve",
2821 + "primeorder",
2822 + "sha2",
2823 +]
2824 +
2825 +[[package]]
2473 2826 name = "parking"
2474 2827 version = "2.2.1"
2475 2828 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2588,6 +2941,18 @@ dependencies = [
2588 2941 ]
2589 2942
2590 2943 [[package]]
2944 +name = "polyval"
2945 +version = "0.6.2"
2946 +source = "registry+https://github.com/rust-lang/crates.io-index"
2947 +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
2948 +dependencies = [
2949 + "cfg-if",
2950 + "cpufeatures",
2951 + "opaque-debug",
2952 + "universal-hash",
2953 +]
2954 +
2955 +[[package]]
2591 2956 name = "potential_utf"
2592 2957 version = "0.1.4"
2593 2958 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2622,6 +2987,15 @@ dependencies = [
2622 2987 ]
2623 2988
2624 2989 [[package]]
2990 +name = "primeorder"
2991 +version = "0.13.6"
2992 +source = "registry+https://github.com/rust-lang/crates.io-index"
2993 +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
2994 +dependencies = [
2995 + "elliptic-curve",
2996 +]
2997 +
2998 +[[package]]
2625 2999 name = "proc-macro-crate"
2626 3000 version = "3.5.0"
2627 3001 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2705,6 +3079,61 @@ dependencies = [
2705 3079 ]
2706 3080
2707 3081 [[package]]
3082 +name = "quinn"
3083 +version = "0.11.9"
3084 +source = "registry+https://github.com/rust-lang/crates.io-index"
3085 +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
3086 +dependencies = [
3087 + "bytes",
3088 + "cfg_aliases",
3089 + "pin-project-lite",
3090 + "quinn-proto",
3091 + "quinn-udp",
3092 + "rustc-hash",
3093 + "rustls",
3094 + "socket2 0.6.3",
3095 + "thiserror 2.0.18",
3096 + "tokio",
3097 + "tracing",
3098 + "web-time",
3099 +]
3100 +
3101 +[[package]]
3102 +name = "quinn-proto"
3103 +version = "0.11.14"
3104 +source = "registry+https://github.com/rust-lang/crates.io-index"
3105 +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
3106 +dependencies = [
3107 + "bytes",
3108 + "getrandom 0.3.4",
3109 + "lru-slab",
3110 + "rand 0.9.2",
3111 + "ring",
3112 + "rustc-hash",
3113 + "rustls",
3114 + "rustls-pki-types",
3115 + "slab",
3116 + "thiserror 2.0.18",
3117 + "tinyvec",
3118 + "tracing",
3119 + "web-time",
3120 +]
3121 +
3122 +[[package]]
3123 +name = "quinn-udp"
3124 +version = "0.5.14"
3125 +source = "registry+https://github.com/rust-lang/crates.io-index"
3126 +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
3127 +dependencies = [
3128 + "cfg_aliases",
3129 + "libc",
3130 + "once_cell",
3131 + "socket2 0.6.3",
3132 + "tracing",
3133 + "windows-sys 0.59.0",
3134 +]
3135 +
3136 +[[package]]
2708 3137 name = "quote"
2709 3138 version = "1.0.45"
2710 3139 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2744,8 +3173,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2744 3173 checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
2745 3174 dependencies = [
2746 3175 "libc",
2747 - "rand_chacha",
2748 - "rand_core",
3176 + "rand_chacha 0.3.1",
3177 + "rand_core 0.6.4",
3178 +]
3179 +
3180 +[[package]]
3181 +name = "rand"
3182 +version = "0.9.2"
3183 +source = "registry+https://github.com/rust-lang/crates.io-index"
3184 +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
3185 +dependencies = [
3186 + "rand_chacha 0.9.0",
3187 + "rand_core 0.9.5",
2749 3188 ]
2750 3189
2751 3190 [[package]]
@@ -2755,7 +3194,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2755 3194 checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
2756 3195 dependencies = [
2757 3196 "ppv-lite86",
2758 - "rand_core",
3197 + "rand_core 0.6.4",
3198 +]
3199 +
3200 +[[package]]
3201 +name = "rand_chacha"
3202 +version = "0.9.0"
3203 +source = "registry+https://github.com/rust-lang/crates.io-index"
3204 +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
3205 +dependencies = [
3206 + "ppv-lite86",
3207 + "rand_core 0.9.5",
2759 3208 ]
2760 3209
2761 3210 [[package]]
@@ -2768,6 +3217,15 @@ dependencies = [
2768 3217 ]
2769 3218
2770 3219 [[package]]
3220 +name = "rand_core"
3221 +version = "0.9.5"
3222 +source = "registry+https://github.com/rust-lang/crates.io-index"
3223 +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
3224 +dependencies = [
3225 + "getrandom 0.3.4",
3226 +]
3227 +
3228 +[[package]]
2771 3229 name = "redis-protocol"
2772 3230 version = "5.0.1"
2773 3231 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2800,6 +3258,26 @@ dependencies = [
2800 3258 ]
2801 3259
2802 3260 [[package]]
3261 +name = "ref-cast"
3262 +version = "1.0.25"
3263 +source = "registry+https://github.com/rust-lang/crates.io-index"
3264 +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
3265 +dependencies = [
3266 + "ref-cast-impl",
3267 +]
3268 +
3269 +[[package]]
3270 +name = "ref-cast-impl"
3271 +version = "1.0.25"
3272 +source = "registry+https://github.com/rust-lang/crates.io-index"
3273 +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
3274 +dependencies = [
3275 + "proc-macro2",
3276 + "quote",
3277 + "syn 2.0.117",
3278 +]
3279 +
3280 +[[package]]
2803 3281 name = "regex"
2804 3282 version = "1.12.3"
2805 3283 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2838,6 +3316,60 @@ dependencies = [
2838 3316 ]
2839 3317
2840 3318 [[package]]
3319 +name = "reqwest"
3320 +version = "0.12.28"
3321 +source = "registry+https://github.com/rust-lang/crates.io-index"
3322 +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
3323 +dependencies = [
3324 + "base64 0.22.1",
3325 + "bytes",
3326 + "encoding_rs",
3327 + "futures-core",
3328 + "h2",
3329 + "http",
3330 + "http-body",
3331 + "http-body-util",
3332 + "hyper",
3333 + "hyper-rustls",
3334 + "hyper-tls",
3335 + "hyper-util",
3336 + "js-sys",
3337 + "log",
3338 + "mime",
3339 + "native-tls",
3340 + "percent-encoding",
3341 + "pin-project-lite",
3342 + "quinn",
3343 + "rustls",
3344 + "rustls-pki-types",
3345 + "serde",
3346 + "serde_json",
3347 + "serde_urlencoded",
3348 + "sync_wrapper",
3349 + "tokio",
3350 + "tokio-native-tls",
3351 + "tokio-rustls",
3352 + "tower",
3353 + "tower-http",
3354 + "tower-service",
3355 + "url",
3356 + "wasm-bindgen",
3357 + "wasm-bindgen-futures",
3358 + "web-sys",
3359 + "webpki-roots",
3360 +]
3361 +
3362 +[[package]]
3363 +name = "rfc6979"
3364 +version = "0.4.0"
3365 +source = "registry+https://github.com/rust-lang/crates.io-index"
3366 +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
3367 +dependencies = [
3368 + "hmac",
3369 + "subtle",
3370 +]
3371 +
3372 +[[package]]
2841 3373 name = "ring"
2842 3374 version = "0.17.14"
2843 3375 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2912,7 +3444,7 @@ dependencies = [
2912 3444 "num-traits",
2913 3445 "pkcs1",
2914 3446 "pkcs8",
2915 - "rand_core",
3447 + "rand_core 0.6.4",
2916 3448 "signature",
2917 3449 "spki",
2918 3450 "subtle",
@@ -2929,7 +3461,7 @@ dependencies = [
2929 3461 "borsh",
2930 3462 "bytes",
2931 3463 "num-traits",
2932 - "rand",
3464 + "rand 0.8.5",
2933 3465 "rkyv",
2934 3466 "serde",
2935 3467 "serde_json",
@@ -2983,6 +3515,7 @@ version = "1.14.0"
2983 3515 source = "registry+https://github.com/rust-lang/crates.io-index"
2984 3516 checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
2985 3517 dependencies = [
3518 + "web-time",
2986 3519 "zeroize",
2987 3520 ]
2988 3521
@@ -3009,12 +3542,11 @@ version = "0.1.0"
3009 3542 dependencies = [
3010 3543 "anyhow",
3011 3544 "async-trait",
3012 - "axum 0.8.8",
3013 - "axum-extra",
3545 + "axum",
3014 3546 "dotenvy",
3015 3547 "rustwarden-core",
3016 3548 "rustwarden-ui",
3017 - "sea-orm 2.0.0-rc.36",
3549 + "sea-orm",
3018 3550 "serde_json",
3019 3551 "tokio",
3020 3552 "tower",
@@ -3030,19 +3562,19 @@ dependencies = [
3030 3562 "clap",
3031 3563 "console",
3032 3564 "dialoguer",
3033 - "rand",
3565 + "rand 0.9.2",
3034 3566 ]
3035 3567
3036 3568 [[package]]
3037 3569 name = "rustwarden-core"
3038 3570 version = "0.1.0"
3039 3571 dependencies = [
3572 + "aes-gcm",
3040 3573 "anyhow",
3041 3574 "askama",
3042 3575 "async-trait",
3043 - "axum 0.8.8",
3576 + "axum",
3044 3577 "axum-extra",
3045 - "axum-login",
3046 3578 "base64 0.22.1",
3047 3579 "bcrypt",
3048 3580 "chrono",
@@ -3055,22 +3587,21 @@ dependencies = [
3055 3587 "lettre",
3056 3588 "native-tls",
3057 3589 "num-format",
3058 - "rand",
3059 - "sea-orm 2.0.0-rc.36",
3060 - "sea-orm-migration 2.0.0-rc.36",
3590 + "openidconnect",
3591 + "rand 0.9.2",
3592 + "reqwest",
3593 + "sea-orm",
3594 + "sea-orm-migration",
3061 3595 "serde",
3062 3596 "serde_json",
3063 3597 "sha2",
3064 - "tempfile",
3065 - "thiserror 1.0.69",
3598 + "subtle",
3599 + "thiserror 2.0.18",
3066 3600 "time",
3067 3601 "tokio",
3068 3602 "tower",
3069 - "tower-csrf",
3070 - "tower-http",
3071 3603 "tower-sessions",
3072 3604 "tower-sessions-redis-store",
3073 - "tower-sessions-seaorm-store",
3074 3605 "tracing",
3075 3606 "tracing-subscriber",
3076 3607 "uuid",
@@ -3084,12 +3615,12 @@ version = "0.1.0"
3084 3615 dependencies = [
3085 3616 "askama",
3086 3617 "async-trait",
3087 - "axum 0.8.8",
3618 + "axum",
3088 3619 "axum-extra",
3089 3620 "chrono",
3090 3621 "num-format",
3091 3622 "rustwarden-core",
3092 - "sea-orm 2.0.0-rc.36",
3623 + "sea-orm",
3093 3624 "serde",
3094 3625 "serde_json",
3095 3626 "time",
@@ -3118,6 +3649,30 @@ dependencies = [
3118 3649 ]
3119 3650
3120 3651 [[package]]
3652 +name = "schemars"
3653 +version = "0.9.0"
3654 +source = "registry+https://github.com/rust-lang/crates.io-index"
3655 +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
3656 +dependencies = [
3657 + "dyn-clone",
3658 + "ref-cast",
3659 + "serde",
3660 + "serde_json",
3661 +]
3662 +
3663 +[[package]]
3664 +name = "schemars"
3665 +version = "1.2.1"
3666 +source = "registry+https://github.com/rust-lang/crates.io-index"
3667 +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
3668 +dependencies = [
3669 + "dyn-clone",
3670 + "ref-cast",
3671 + "serde",
3672 + "serde_json",
3673 +]
3674 +
3675 +[[package]]
3121 3676 name = "scopeguard"
3122 3677 version = "1.2.0"
3123 3678 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3138,9 +3693,9 @@ dependencies = [
3138 3693
3139 3694 [[package]]
3140 3695 name = "sea-orm"
3141 -version = "1.1.19"
3696 +version = "2.0.0-rc.37"
3142 3697 source = "registry+https://github.com/rust-lang/crates.io-index"
3143 -checksum = "6d945f62558fac19e5988680d2fdf747b734c2dbc6ce2cb81ba33ed8dde5b103"
3698 +checksum = "4b846dc1c7fefbea372c03765ff08307d68894bbad8c73b66176dcd53a3ee131"
3144 3699 dependencies = [
3145 3700 "async-stream",
3146 3701 "async-trait",
@@ -3148,50 +3703,21 @@ dependencies = [
3148 3703 "chrono",
3149 3704 "derive_more",
3150 3705 "futures-util",
3706 + "itertools 0.14.0",
3151 3707 "log",
3152 - "ouroboros",
3153 - "pgvector",
3154 - "rust_decimal",
3155 - "sea-orm-macros 1.1.19",
3156 - "sea-query 0.32.7",
3157 - "sea-query-binder",
3158 - "serde",
3159 - "serde_json",
3160 - "sqlx",
3161 - "strum 0.26.3",
3162 - "thiserror 2.0.18",
3163 - "time",
3164 - "tracing",
3165 - "url",
3166 - "uuid",
3167 -]
3168 -
3169 -[[package]]
3170 -name = "sea-orm"
3171 -version = "2.0.0-rc.36"
3172 -source = "registry+https://github.com/rust-lang/crates.io-index"
3173 -checksum = "844d97615ecd2661fe4aba04df28b80a91ad34cc6c476e683f3103381b0c33bd"
3174 -dependencies = [
3175 - "async-stream",
3176 - "async-trait",
3177 - "bigdecimal",
3178 - "chrono",
3179 - "derive_more",
3180 - "futures-util",
3181 - "itertools",
3182 - "log",
3708 + "mac_address",
3183 3709 "ouroboros",
3184 3710 "pgvector",
3185 3711 "rust_decimal",
3186 3712 "sea-orm-arrow",
3187 - "sea-orm-macros 2.0.0-rc.36",
3188 - "sea-query 1.0.0-rc.31",
3713 + "sea-orm-macros",
3714 + "sea-query",
3189 3715 "sea-query-sqlx",
3190 - "sea-schema 0.17.0-rc.17",
3716 + "sea-schema",
3191 3717 "serde",
3192 3718 "serde_json",
3193 3719 "sqlx",
3194 - "strum 0.27.2",
3720 + "strum",
3195 3721 "thiserror 2.0.18",
3196 3722 "time",
3197 3723 "tracing",
@@ -3200,40 +3726,21 @@ dependencies = [
3200 3726 ]
3201 3727
3202 3728 [[package]]
3203 -name = "sea-orm-arrow"
3204 -version = "2.0.0-rc.3"
3205 -source = "registry+https://github.com/rust-lang/crates.io-index"
3206 -checksum = "5c2eee8405f16c1f337fe3a83389361caea83c928d14dbd666a480407072c365"
3207 -dependencies = [
3208 - "arrow",
3209 - "sea-query 1.0.0-rc.31",
3210 - "thiserror 2.0.18",
3211 -]
3212 -
3213 -[[package]]
3214 -name = "sea-orm-cli"
3215 -version = "1.1.19"
3216 -source = "registry+https://github.com/rust-lang/crates.io-index"
3217 -checksum = "c94492e2ab6c045b4cc38013809ce255d14c3d352c9f0d11e6b920e2adc948ad"
3218 -dependencies = [
3219 - "chrono",
3220 - "clap",
3221 - "dotenvy",
3222 - "glob",
3223 - "regex",
3224 - "sea-schema 0.16.2",
3225 - "sqlx",
3226 - "tokio",
3227 - "tracing",
3228 - "tracing-subscriber",
3229 - "url",
3729 +name = "sea-orm-arrow"
3730 +version = "2.0.0-rc.3"
3731 +source = "registry+https://github.com/rust-lang/crates.io-index"
3732 +checksum = "5c2eee8405f16c1f337fe3a83389361caea83c928d14dbd666a480407072c365"
3733 +dependencies = [
3734 + "arrow",
3735 + "sea-query",
3736 + "thiserror 2.0.18",
3230 3737 ]
3231 3738
3232 3739 [[package]]
3233 3740 name = "sea-orm-cli"
3234 -version = "2.0.0-rc.36"
3741 +version = "2.0.0-rc.37"
3235 3742 source = "registry+https://github.com/rust-lang/crates.io-index"
3236 -checksum = "9abbe4316df118fb0edf18b2c32635b504ee5c9295ec7b5905af1c1051ef809a"
3743 +checksum = "cd9b34d4c8e615079c04eb7863a429c2d2a8bf9c934eb9eeb580f51f36367124"
3237 3744 dependencies = [
3238 3745 "chrono",
3239 3746 "clap",
@@ -3241,7 +3748,7 @@ dependencies = [
3241 3748 "glob",
3242 3749 "indoc",
3243 3750 "regex",
3244 - "sea-schema 0.17.0-rc.17",
3751 + "sea-schema",
3245 3752 "sqlx",
3246 3753 "tokio",
3247 3754 "tracing",
@@ -3251,26 +3758,12 @@ dependencies = [
3251 3758
3252 3759 [[package]]
3253 3760 name = "sea-orm-macros"
3254 -version = "1.1.19"
3255 -source = "registry+https://github.com/rust-lang/crates.io-index"
3256 -checksum = "84c2e64a50a9cc8339f10a27577e10062c7f995488e469f2c95762c5ee847832"
3257 -dependencies = [
3258 - "heck 0.5.0",
3259 - "proc-macro2",
3260 - "quote",
3261 - "sea-bae",
3262 - "syn 2.0.117",
3263 - "unicode-ident",
3264 -]
3265 -
3266 -[[package]]
3267 -name = "sea-orm-macros"
3268 -version = "2.0.0-rc.36"
3761 +version = "2.0.0-rc.37"
3269 3762 source = "registry+https://github.com/rust-lang/crates.io-index"
3270 -checksum = "60b22cd368ed2053ee7b9ccae5ef33621a55c4499dd3b64cdb518e69661b83e4"
3763 +checksum = "b449fe660e4d365f335222025df97ae01e670ef7ad788b3c67db9183b6cb0474"
3271 3764 dependencies = [
3272 3765 "heck 0.5.0",
3273 - "itertools",
3766 + "itertools 0.14.0",
3274 3767 "pluralizer",
3275 3768 "proc-macro2",
3276 3769 "quote",
@@ -3281,106 +3774,43 @@ dependencies = [
3281 3774
3282 3775 [[package]]
3283 3776 name = "sea-orm-migration"
3284 -version = "1.1.19"
3285 -source = "registry+https://github.com/rust-lang/crates.io-index"
3286 -checksum = "7315c0cadb7e60fb17ee2bb282aa27d01911fc2a7e5836ec1d4ac37d19250bb4"
3287 -dependencies = [
3288 - "async-trait",
3289 - "clap",
3290 - "dotenvy",
3291 - "sea-orm 1.1.19",
3292 - "sea-orm-cli 1.1.19",
3293 - "sea-schema 0.16.2",
3294 - "tracing",
3295 - "tracing-subscriber",
3296 -]
3297 -
3298 -[[package]]
3299 -name = "sea-orm-migration"
3300 -version = "2.0.0-rc.36"
3777 +version = "2.0.0-rc.37"
3301 3778 source = "registry+https://github.com/rust-lang/crates.io-index"
3302 -checksum = "71514315296d22c8ee48f3bfb6baa5804a2bb4ee547bf886df0ceb67c7195898"
3779 +checksum = "b3ceb928aac8be83332d34d1fdbc827d43696135a800723ffeb2e0b33b7b495e"
3303 3780 dependencies = [
3304 3781 "async-trait",
3305 3782 "clap",
3306 3783 "dotenvy",
3307 - "sea-orm 2.0.0-rc.36",
3308 - "sea-orm-cli 2.0.0-rc.36",
3309 - "sea-schema 0.17.0-rc.17",
3784 + "sea-orm",
3785 + "sea-orm-cli",
3786 + "sea-schema",
3310 3787 "tracing",
3311 3788 "tracing-subscriber",
3312 3789 ]
3313 3790
3314 3791 [[package]]
3315 3792 name = "sea-query"
3316 -version = "0.32.7"
3317 -source = "registry+https://github.com/rust-lang/crates.io-index"
3318 -checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c"
3319 -dependencies = [
3320 - "bigdecimal",
3321 - "chrono",
3322 - "inherent",
3323 - "ordered-float",
3324 - "rust_decimal",
3325 - "sea-query-derive 0.4.3",
3326 - "serde_json",
3327 - "time",
3328 - "uuid",
3329 -]
3330 -
3331 -[[package]]
3332 -name = "sea-query"
3333 3793 version = "1.0.0-rc.31"
3334 3794 source = "registry+https://github.com/rust-lang/crates.io-index"
3335 3795 checksum = "58decdaaaf2a698170af2fa1b2e8f7b43a970e7768bf18aebaab113bada46354"
3336 3796 dependencies = [
3337 3797 "chrono",
3338 3798 "inherent",
3339 - "ordered-float",
3340 - "rust_decimal",
3341 - "sea-query-derive 1.0.0-rc.12",
3342 - "serde_json",
3343 - "time",
3344 - "uuid",
3345 -]
3346 -
3347 -[[package]]
3348 -name = "sea-query-binder"
3349 -version = "0.7.0"
3350 -source = "registry+https://github.com/rust-lang/crates.io-index"
3351 -checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608"
3352 -dependencies = [
3353 - "bigdecimal",
3354 - "chrono",
3799 + "ordered-float 4.6.0",
3355 3800 "rust_decimal",
3356 - "sea-query 0.32.7",
3801 + "sea-query-derive",
3357 3802 "serde_json",
3358 - "sqlx",
3359 3803 "time",
3360 3804 "uuid",
3361 3805 ]
3362 3806
3363 3807 [[package]]
3364 3808 name = "sea-query-derive"
3365 -version = "0.4.3"
3366 -source = "registry+https://github.com/rust-lang/crates.io-index"
3367 -checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab"
3368 -dependencies = [
3369 - "darling",
3370 - "heck 0.4.1",
3371 - "proc-macro2",
3372 - "quote",
3373 - "syn 2.0.117",
3374 - "thiserror 2.0.18",
3375 -]
3376 -
3377 -[[package]]
3378 -name = "sea-query-derive"
3379 3809 version = "1.0.0-rc.12"
3380 3810 source = "registry+https://github.com/rust-lang/crates.io-index"
3381 3811 checksum = "8d88ad44b6ad9788c8b9476b6b91f94c7461d1e19d39cd8ea37838b1e6ff5aa8"
3382 3812 dependencies = [
3383 - "darling",
3813 + "darling 0.20.11",
3384 3814 "heck 0.4.1",
3385 3815 "proc-macro2",
3386 3816 "quote",
@@ -3394,20 +3824,7 @@ version = "0.8.0-rc.14"
3394 3824 source = "registry+https://github.com/rust-lang/crates.io-index"
3395 3825 checksum = "e4377164b09a11bb692dec6966eb0e6908d63d768defef0be689b39e02cf8544"
3396 3826 dependencies = [
3397 - "sea-query 1.0.0-rc.31",
3398 - "sqlx",
3399 -]
3400 -
3401 -[[package]]
3402 -name = "sea-schema"
3403 -version = "0.16.2"
3404 -source = "registry+https://github.com/rust-lang/crates.io-index"
3405 -checksum = "2239ff574c04858ca77485f112afea1a15e53135d3097d0c86509cef1def1338"
3406 -dependencies = [
3407 - "futures",
3408 - "sea-query 0.32.7",
3409 - "sea-query-binder",
3410 - "sea-schema-derive",
3827 + "sea-query",
3411 3828 "sqlx",
3412 3829 ]
3413 3830
@@ -3418,7 +3835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3418 3835 checksum = "b363dd21c20fe4d1488819cb2bc7f8d4696c62dd9f39554f97639f54d57dd0ab"
3419 3836 dependencies = [
3420 3837 "async-trait",
3421 - "sea-query 1.0.0-rc.31",
3838 + "sea-query",
3422 3839 "sea-query-sqlx",
3423 3840 "sea-schema-derive",
3424 3841 "sqlx",
@@ -3443,6 +3860,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3443 3860 checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
3444 3861
3445 3862 [[package]]
3863 +name = "sec1"
3864 +version = "0.7.3"
3865 +source = "registry+https://github.com/rust-lang/crates.io-index"
3866 +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
3867 +dependencies = [
3868 + "base16ct",
3869 + "der",
3870 + "generic-array",
3871 + "pkcs8",
3872 + "subtle",
3873 + "zeroize",
3874 +]
3875 +
3876 +[[package]]
3446 3877 name = "security-framework"
3447 3878 version = "3.7.0"
3448 3879 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3482,6 +3913,16 @@ dependencies = [
3482 3913 ]
3483 3914
3484 3915 [[package]]
3916 +name = "serde-value"
3917 +version = "0.7.0"
3918 +source = "registry+https://github.com/rust-lang/crates.io-index"
3919 +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
3920 +dependencies = [
3921 + "ordered-float 2.10.1",
3922 + "serde",
3923 +]
3924 +
3925 +[[package]]
3485 3926 name = "serde_core"
3486 3927 version = "1.0.228"
3487 3928 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3526,6 +3967,15 @@ dependencies = [
3526 3967 ]
3527 3968
3528 3969 [[package]]
3970 +name = "serde_plain"
3971 +version = "1.0.2"
3972 +source = "registry+https://github.com/rust-lang/crates.io-index"
3973 +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
3974 +dependencies = [
3975 + "serde",
3976 +]
3977 +
3978 +[[package]]
3529 3979 name = "serde_urlencoded"
3530 3980 version = "0.7.1"
3531 3981 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3538,6 +3988,37 @@ dependencies = [
3538 3988 ]
3539 3989
3540 3990 [[package]]
3991 +name = "serde_with"
3992 +version = "3.17.0"
3993 +source = "registry+https://github.com/rust-lang/crates.io-index"
3994 +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9"
3995 +dependencies = [
3996 + "base64 0.22.1",
3997 + "chrono",
3998 + "hex",
3999 + "indexmap 1.9.3",
4000 + "indexmap 2.13.0",
4001 + "schemars 0.9.0",
4002 + "schemars 1.2.1",
4003 + "serde_core",
4004 + "serde_json",
4005 + "serde_with_macros",
4006 + "time",
4007 +]
4008 +
4009 +[[package]]
4010 +name = "serde_with_macros"
4011 +version = "3.17.0"
4012 +source = "registry+https://github.com/rust-lang/crates.io-index"
4013 +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
4014 +dependencies = [
4015 + "darling 0.21.3",
4016 + "proc-macro2",
4017 + "quote",
4018 + "syn 2.0.117",
4019 +]
4020 +
4021 +[[package]]
3541 4022 name = "sha1"
3542 4023 version = "0.10.6"
3543 4024 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3597,7 +4078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3597 4078 checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
3598 4079 dependencies = [
3599 4080 "digest",
3600 - "rand_core",
4081 + "rand_core 0.6.4",
3601 4082 ]
3602 4083
3603 4084 [[package]]
@@ -3645,12 +4126,12 @@ dependencies = [
3645 4126
3646 4127 [[package]]
3647 4128 name = "socket2"
3648 -version = "0.6.2"
4129 +version = "0.6.3"
3649 4130 source = "registry+https://github.com/rust-lang/crates.io-index"
3650 -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
4131 +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
3651 4132 dependencies = [
3652 4133 "libc",
3653 - "windows-sys 0.60.2",
4134 + "windows-sys 0.61.2",
3654 4135 ]
3655 4136
3656 4137 [[package]]
@@ -3692,7 +4173,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3692 4173 checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
3693 4174 dependencies = [
3694 4175 "base64 0.22.1",
3695 - "bigdecimal",
3696 4176 "bytes",
3697 4177 "chrono",
3698 4178 "crc",
@@ -3705,14 +4185,13 @@ dependencies = [
3705 4185 "futures-util",
3706 4186 "hashbrown 0.15.5",
3707 4187 "hashlink 0.10.0",
3708 - "indexmap",
4188 + "indexmap 2.13.0",
3709 4189 "log",
3710 4190 "memchr",
3711 4191 "native-tls",
3712 4192 "once_cell",
3713 4193 "percent-encoding",
3714 4194 "rust_decimal",
3715 - "rustls",
3716 4195 "serde",
3717 4196 "serde_json",
3718 4197 "sha2",
@@ -3724,7 +4203,6 @@ dependencies = [
3724 4203 "tracing",
3725 4204 "url",
3726 4205 "uuid",
3727 - "webpki-roots 0.26.11",
3728 4206 ]
3729 4207
3730 4208 [[package]]
@@ -3773,7 +4251,6 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
3773 4251 dependencies = [
3774 4252 "atoi",
3775 4253 "base64 0.22.1",
3776 - "bigdecimal",
3777 4254 "bitflags 2.11.0",
3778 4255 "byteorder",
3779 4256 "bytes",
@@ -3796,7 +4273,7 @@ dependencies = [
3796 4273 "memchr",
3797 4274 "once_cell",
3798 4275 "percent-encoding",
3799 - "rand",
4276 + "rand 0.8.5",
3800 4277 "rsa",
3801 4278 "rust_decimal",
3802 4279 "serde",
@@ -3820,7 +4297,6 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
3820 4297 dependencies = [
3821 4298 "atoi",
3822 4299 "base64 0.22.1",
3823 - "bigdecimal",
3824 4300 "bitflags 2.11.0",
3825 4301 "byteorder",
3826 4302 "chrono",
@@ -3838,9 +4314,8 @@ dependencies = [
3838 4314 "log",
3839 4315 "md-5",
3840 4316 "memchr",
3841 - "num-bigint",
3842 4317 "once_cell",
3843 - "rand",
4318 + "rand 0.8.5",
3844 4319 "rust_decimal",
3845 4320 "serde",
3846 4321 "serde_json",
@@ -3926,12 +4401,6 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
3926 4401
3927 4402 [[package]]
3928 4403 name = "strum"
3929 -version = "0.26.3"
3930 -source = "registry+https://github.com/rust-lang/crates.io-index"
3931 -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
3932 -
3933 -[[package]]
3934 -name = "strum"
3935 4404 version = "0.27.2"
3936 4405 source = "registry+https://github.com/rust-lang/crates.io-index"
3937 4406 checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
@@ -3969,6 +4438,9 @@ name = "sync_wrapper"
3969 4438 version = "1.0.2"
3970 4439 source = "registry+https://github.com/rust-lang/crates.io-index"
3971 4440 checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
4441 +dependencies = [
4442 + "futures-core",
4443 +]
3972 4444
3973 4445 [[package]]
3974 4446 name = "synstructure"
@@ -4147,7 +4619,7 @@ dependencies = [
4147 4619 "parking_lot",
4148 4620 "pin-project-lite",
4149 4621 "signal-hook-registry",
4150 - "socket2 0.6.2",
4622 + "socket2 0.6.3",
4151 4623 "tokio-macros",
4152 4624 "windows-sys 0.61.2",
4153 4625 ]
@@ -4174,6 +4646,16 @@ dependencies = [
4174 4646 ]
4175 4647
4176 4648 [[package]]
4649 +name = "tokio-rustls"
4650 +version = "0.26.4"
4651 +source = "registry+https://github.com/rust-lang/crates.io-index"
4652 +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
4653 +dependencies = [
4654 + "rustls",
4655 + "tokio",
4656 +]
4657 +
4658 +[[package]]
4177 4659 name = "tokio-stream"
4178 4660 version = "0.1.18"
4179 4661 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4212,7 +4694,7 @@ version = "0.25.4+spec-1.1.0"
4212 4694 source = "registry+https://github.com/rust-lang/crates.io-index"
4213 4695 checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
4214 4696 dependencies = [
4215 - "indexmap",
4697 + "indexmap 2.13.0",
4216 4698 "toml_datetime",
4217 4699 "toml_parser",
4218 4700 "winnow",
@@ -4250,7 +4732,7 @@ version = "0.11.0"
4250 4732 source = "registry+https://github.com/rust-lang/crates.io-index"
4251 4733 checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"
4252 4734 dependencies = [
4253 - "axum-core 0.5.6",
4735 + "axum-core",
4254 4736 "cookie",
4255 4737 "futures-util",
4256 4738 "http",
@@ -4261,19 +4743,6 @@ dependencies = [
4261 4743 ]
4262 4744
4263 4745 [[package]]
4264 -name = "tower-csrf"
4265 -version = "0.1.0"
4266 -source = "registry+https://github.com/rust-lang/crates.io-index"
4267 -checksum = "d38be628f9ef990a4714803c31a9ecc45b20e4a11401ec9121a8109de541a4aa"
4268 -dependencies = [
4269 - "http",
4270 - "thiserror 2.0.18",
4271 - "tower",
4272 - "tracing",
4273 - "url",
4274 -]
4275 -
4276 -[[package]]
4277 4746 name = "tower-http"
4278 4747 version = "0.6.8"
4279 4748 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4288,12 +4757,14 @@ dependencies = [
4288 4757 "http-body-util",
4289 4758 "http-range-header",
4290 4759 "httpdate",
4760 + "iri-string",
4291 4761 "mime",
4292 4762 "mime_guess",
4293 4763 "percent-encoding",
4294 4764 "pin-project-lite",
4295 4765 "tokio",
4296 4766 "tokio-util",
4767 + "tower",
4297 4768 "tower-layer",
4298 4769 "tower-service",
4299 4770 "tracing",
@@ -4340,7 +4811,7 @@ dependencies = [
4340 4811 "futures",
4341 4812 "http",
4342 4813 "parking_lot",
4343 - "rand",
4814 + "rand 0.8.5",
4344 4815 "serde",
4345 4816 "serde_json",
4346 4817 "thiserror 1.0.69",
@@ -4356,12 +4827,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
4356 4827 checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b"
4357 4828 dependencies = [
4358 4829 "async-trait",
4359 - "axum-core 0.5.6",
4830 + "axum-core",
4360 4831 "base64 0.22.1",
4361 4832 "futures",
4362 4833 "http",
4363 4834 "parking_lot",
4364 - "rand",
4835 + "rand 0.8.5",
4365 4836 "serde",
4366 4837 "serde_json",
4367 4838 "thiserror 2.0.18",
@@ -4397,22 +4868,6 @@ dependencies = [
4397 4868 ]
4398 4869
4399 4870 [[package]]
4400 -name = "tower-sessions-seaorm-store"
4401 -version = "0.1.1"
4402 -source = "registry+https://github.com/rust-lang/crates.io-index"
4403 -checksum = "f4a040faf57f83908407d641fae15c4ba6a09b1e26edb602c866402a1ad47849"
4404 -dependencies = [
4405 - "async-trait",
4406 - "chrono",
4407 - "rmp-serde",
4408 - "sea-orm 1.1.19",
4409 - "sea-orm-migration 1.1.19",
4410 - "thiserror 2.0.18",
4411 - "time",
4412 - "tower-sessions",
4413 -]
4414 -
4415 -[[package]]
4416 4871 name = "tracing"
4417 4872 version = "0.1.44"
4418 4873 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4532,6 +4987,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
4532 4987 checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
4533 4988
4534 4989 [[package]]
4990 +name = "universal-hash"
4991 +version = "0.5.1"
4992 +source = "registry+https://github.com/rust-lang/crates.io-index"
4993 +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
4994 +dependencies = [
4995 + "crypto-common",
4996 + "subtle",
4997 +]
4998 +
4999 +[[package]]
4535 5000 name = "untrusted"
4536 5001 version = "0.9.0"
4537 5002 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4547,6 +5012,7 @@ dependencies = [
4547 5012 "idna",
4548 5013 "percent-encoding",
4549 5014 "serde",
5015 + "serde_derive",
4550 5016 ]
4551 5017
4552 5018 [[package]]
@@ -4569,9 +5035,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
4569 5035
4570 5036 [[package]]
4571 5037 name = "uuid"
4572 -version = "1.21.0"
5038 +version = "1.22.0"
4573 5039 source = "registry+https://github.com/rust-lang/crates.io-index"
4574 -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
5040 +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
4575 5041 dependencies = [
4576 5042 "getrandom 0.4.2",
4577 5043 "js-sys",
@@ -4601,7 +5067,7 @@ version = "0.20.0"
4601 5067 source = "registry+https://github.com/rust-lang/crates.io-index"
4602 5068 checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca"
4603 5069 dependencies = [
4604 - "darling",
5070 + "darling 0.20.11",
4605 5071 "once_cell",
4606 5072 "proc-macro-error2",
4607 5073 "proc-macro2",
@@ -4680,6 +5146,20 @@ dependencies = [
4680 5146 ]
4681 5147
4682 5148 [[package]]
5149 +name = "wasm-bindgen-futures"
5150 +version = "0.4.64"
5151 +source = "registry+https://github.com/rust-lang/crates.io-index"
5152 +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
5153 +dependencies = [
5154 + "cfg-if",
5155 + "futures-util",
5156 + "js-sys",
5157 + "once_cell",
5158 + "wasm-bindgen",
5159 + "web-sys",
5160 +]
5161 +
5162 +[[package]]
4683 5163 name = "wasm-bindgen-macro"
4684 5164 version = "0.2.114"
4685 5165 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4728,7 +5208,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
4728 5208 checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
4729 5209 dependencies = [
4730 5210 "anyhow",
4731 - "indexmap",
5211 + "indexmap 2.13.0",
4732 5212 "wasm-encoder",
4733 5213 "wasmparser",
4734 5214 ]
@@ -4741,17 +5221,28 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
4741 5221 dependencies = [
4742 5222 "bitflags 2.11.0",
4743 5223 "hashbrown 0.15.5",
4744 - "indexmap",
5224 + "indexmap 2.13.0",
4745 5225 "semver",
4746 5226 ]
4747 5227
4748 5228 [[package]]
4749 -name = "webpki-roots"
4750 -version = "0.26.11"
5229 +name = "web-sys"
5230 +version = "0.3.91"
5231 +source = "registry+https://github.com/rust-lang/crates.io-index"
5232 +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
5233 +dependencies = [
5234 + "js-sys",
5235 + "wasm-bindgen",
5236 +]
5237 +
5238 +[[package]]
5239 +name = "web-time"
5240 +version = "1.1.0"
4751 5241 source = "registry+https://github.com/rust-lang/crates.io-index"
4752 -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
5242 +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
4753 5243 dependencies = [
4754 - "webpki-roots 1.0.6",
5244 + "js-sys",
5245 + "wasm-bindgen",
4755 5246 ]
4756 5247
4757 5248 [[package]]
@@ -4774,6 +5265,28 @@ dependencies = [
4774 5265 ]
4775 5266
4776 5267 [[package]]
5268 +name = "winapi"
5269 +version = "0.3.9"
5270 +source = "registry+https://github.com/rust-lang/crates.io-index"
5271 +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
5272 +dependencies = [
5273 + "winapi-i686-pc-windows-gnu",
5274 + "winapi-x86_64-pc-windows-gnu",
5275 +]
5276 +
5277 +[[package]]
5278 +name = "winapi-i686-pc-windows-gnu"
5279 +version = "0.4.0"
5280 +source = "registry+https://github.com/rust-lang/crates.io-index"
5281 +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
5282 +
5283 +[[package]]
5284 +name = "winapi-x86_64-pc-windows-gnu"
5285 +version = "0.4.0"
5286 +source = "registry+https://github.com/rust-lang/crates.io-index"
5287 +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
5288 +
5289 +[[package]]
4777 5290 name = "windows-core"
4778 5291 version = "0.62.2"
4779 5292 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4872,15 +5385,6 @@ dependencies = [
4872 5385
4873 5386 [[package]]
4874 5387 name = "windows-sys"
4875 -version = "0.60.2"
4876 -source = "registry+https://github.com/rust-lang/crates.io-index"
4877 -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
4878 -dependencies = [
4879 - "windows-targets 0.53.5",
4880 -]
4881 -
4882 -[[package]]
4883 -name = "windows-sys"
4884 5388 version = "0.61.2"
4885 5389 source = "registry+https://github.com/rust-lang/crates.io-index"
4886 5390 checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
@@ -4912,7 +5416,7 @@ dependencies = [
4912 5416 "windows_aarch64_gnullvm 0.52.6",
4913 5417 "windows_aarch64_msvc 0.52.6",
4914 5418 "windows_i686_gnu 0.52.6",
4915 - "windows_i686_gnullvm 0.52.6",
5419 + "windows_i686_gnullvm",
4916 5420 "windows_i686_msvc 0.52.6",
4917 5421 "windows_x86_64_gnu 0.52.6",
4918 5422 "windows_x86_64_gnullvm 0.52.6",
@@ -4920,23 +5424,6 @@ dependencies = [
4920 5424 ]
4921 5425
4922 5426 [[package]]
4923 -name = "windows-targets"
4924 -version = "0.53.5"
4925 -source = "registry+https://github.com/rust-lang/crates.io-index"
4926 -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
4927 -dependencies = [
4928 - "windows-link",
4929 - "windows_aarch64_gnullvm 0.53.1",
4930 - "windows_aarch64_msvc 0.53.1",
4931 - "windows_i686_gnu 0.53.1",
4932 - "windows_i686_gnullvm 0.53.1",
4933 - "windows_i686_msvc 0.53.1",
4934 - "windows_x86_64_gnu 0.53.1",
4935 - "windows_x86_64_gnullvm 0.53.1",
4936 - "windows_x86_64_msvc 0.53.1",
4937 -]
4938 -
4939 -[[package]]
4940 5427 name = "windows_aarch64_gnullvm"
4941 5428 version = "0.48.5"
4942 5429 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4949,12 +5436,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
4949 5436 checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
4950 5437
4951 5438 [[package]]
4952 -name = "windows_aarch64_gnullvm"
4953 -version = "0.53.1"
4954 -source = "registry+https://github.com/rust-lang/crates.io-index"
4955 -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
4956 -
4957 -[[package]]
4958 5439 name = "windows_aarch64_msvc"
4959 5440 version = "0.48.5"
4960 5441 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4967,12 +5448,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
4967 5448 checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
4968 5449
4969 5450 [[package]]
4970 -name = "windows_aarch64_msvc"
4971 -version = "0.53.1"
4972 -source = "registry+https://github.com/rust-lang/crates.io-index"
4973 -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
4974 -
4975 -[[package]]
4976 5451 name = "windows_i686_gnu"
4977 5452 version = "0.48.5"
4978 5453 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4985,24 +5460,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
4985 5460 checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
4986 5461
4987 5462 [[package]]
4988 -name = "windows_i686_gnu"
4989 -version = "0.53.1"
4990 -source = "registry+https://github.com/rust-lang/crates.io-index"
4991 -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
4992 -
4993 -[[package]]
4994 5463 name = "windows_i686_gnullvm"
4995 5464 version = "0.52.6"
4996 5465 source = "registry+https://github.com/rust-lang/crates.io-index"
4997 5466 checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
4998 5467
4999 5468 [[package]]
5000 -name = "windows_i686_gnullvm"
5001 -version = "0.53.1"
5002 -source = "registry+https://github.com/rust-lang/crates.io-index"
5003 -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
5004 -
5005 -[[package]]
5006 5469 name = "windows_i686_msvc"
5007 5470 version = "0.48.5"
5008 5471 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5015,12 +5478,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5015 5478 checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
5016 5479
5017 5480 [[package]]
5018 -name = "windows_i686_msvc"
5019 -version = "0.53.1"
5020 -source = "registry+https://github.com/rust-lang/crates.io-index"
5021 -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
5022 -
5023 -[[package]]
5024 5481 name = "windows_x86_64_gnu"
5025 5482 version = "0.48.5"
5026 5483 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5033,12 +5490,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5033 5490 checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
5034 5491
5035 5492 [[package]]
5036 -name = "windows_x86_64_gnu"
5037 -version = "0.53.1"
5038 -source = "registry+https://github.com/rust-lang/crates.io-index"
5039 -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
5040 -
5041 -[[package]]
5042 5493 name = "windows_x86_64_gnullvm"
5043 5494 version = "0.48.5"
5044 5495 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5051,12 +5502,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5051 5502 checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
5052 5503
5053 5504 [[package]]
5054 -name = "windows_x86_64_gnullvm"
5055 -version = "0.53.1"
5056 -source = "registry+https://github.com/rust-lang/crates.io-index"
5057 -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
5058 -
5059 -[[package]]
5060 5505 name = "windows_x86_64_msvc"
5061 5506 version = "0.48.5"
5062 5507 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5069,12 +5514,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5069 5514 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
5070 5515
5071 5516 [[package]]
5072 -name = "windows_x86_64_msvc"
5073 -version = "0.53.1"
5074 -source = "registry+https://github.com/rust-lang/crates.io-index"
5075 -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
5076 -
5077 -[[package]]
5078 5517 name = "winnow"
5079 5518 version = "0.7.15"
5080 5519 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5111,7 +5550,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
5111 5550 dependencies = [
5112 5551 "anyhow",
5113 5552 "heck 0.5.0",
5114 - "indexmap",
5553 + "indexmap 2.13.0",
5115 5554 "prettyplease",
5116 5555 "syn 2.0.117",
5117 5556 "wasm-metadata",
@@ -5142,7 +5581,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
5142 5581 dependencies = [
5143 5582 "anyhow",
5144 5583 "bitflags 2.11.0",
5145 - "indexmap",
5584 + "indexmap 2.13.0",
5146 5585 "log",
5147 5586 "serde",
5148 5587 "serde_derive",
@@ -5161,7 +5600,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
5161 5600 dependencies = [
5162 5601 "anyhow",
5163 5602 "id-arena",
5164 - "indexmap",
5603 + "indexmap 2.13.0",
5165 5604 "log",
5166 5605 "semver",
5167 5606 "serde",
@@ -5228,18 +5667,18 @@ dependencies = [
5228 5667
5229 5668 [[package]]
5230 5669 name = "zerocopy"
5231 -version = "0.8.40"
5670 +version = "0.8.42"
5232 5671 source = "registry+https://github.com/rust-lang/crates.io-index"
5233 -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5"
5672 +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
5234 5673 dependencies = [
5235 5674 "zerocopy-derive",
5236 5675 ]
5237 5676
5238 5677 [[package]]
5239 5678 name = "zerocopy-derive"
5240 -version = "0.8.40"
5679 +version = "0.8.42"
5241 5680 source = "registry+https://github.com/rust-lang/crates.io-index"
5242 -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
5681 +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
5243 5682 dependencies = [
5244 5683 "proc-macro2",
5245 5684 "quote",
M DEVELOPER.md
+507 -50
@@ -16,66 +16,375 @@ Ce document est le guide de reference pour comprendre, etendre et contribuer au
16 16 8. [Personnaliser les templates](#personnaliser-les-templates)
17 17 9. [Authentification et autorisation](#authentification-et-autorisation)
18 18 10. [RBAC : roles et permissions](#rbac--roles-et-permissions)
19 -11. [Hooks : intercepter les evenements](#hooks--intercepter-les-evenements)
20 -12. [Internationalisation (i18n)](#internationalisation-i18n)
21 -13. [Taches de fond](#taches-de-fond)
22 -14. [Middleware](#middleware)
23 -15. [Configuration](#configuration)
24 -16. [Commandes binaires](#commandes-binaires)
25 -17. [Le CLI wizard](#le-cli-wizard)
26 -18. [Tests](#tests)
27 -19. [Production](#production)
19 +11. [Authentification OAuth / SSO](#authentification-oauth--sso)
20 +12. [Hooks : intercepter les evenements](#hooks--intercepter-les-evenements)
21 +13. [Internationalisation (i18n)](#internationalisation-i18n)
22 +14. [Taches de fond](#taches-de-fond)
23 +15. [Middleware](#middleware)
24 +16. [Configuration](#configuration)
25 +17. [Commandes binaires](#commandes-binaires)
26 +18. [Le CLI wizard](#le-cli-wizard)
27 +19. [Tests](#tests)
28 +20. [Production](#production)
28 29
29 30 ---
30 31
31 32 ## Architecture globale
32 33
34 +Le projet est organise en **Cargo workspace** avec 4 membres : le coeur du framework
35 +(`rustwarden-core`), l'interface web (`rustwarden-ui`), l'application de reference (`app`)
36 +et l'outil CLI (`rustwarden-cli`).
37 +
38 +> **Sources de verite** :
39 +> - **Templates** : `crates/rustwarden-ui/templates/` (compile par Askama)
40 +> - **Migrations** : `crates/rustwarden-core/migrations/` (compile par SeaORM)
41 +> - **Traductions** : `locales/` a la racine (charge a l'execution par I18nService)
42 +>
43 +> Les anciens miroirs ont ete renommes `templates_old/`, `migrations_old/`,
44 +> `crates/rustwarden-ui/locales_old/` et peuvent etre supprimes.
45 +
46 +---
47 +
48 +### Arborescence complete et role de chaque fichier
49 +
33 50 ```
34 51 rustwarden/
35 -├── Cargo.toml # Workspace racine
52 +│
53 +├── Cargo.toml # Manifeste du workspace (members, resolver, lints)
54 +├── Cargo.lock # Verrouillage des versions de dependances
55 +├── .env # Variables d'environnement locales (non versionne)
56 +├── .env.sample # Exemple de .env distribue avec le projet
57 +├── .gitignore # Exclusions Git (target/, .env, data/, etc.)
58 +├── README.md # Presentation du projet, quick-start, variables d'env
59 +├── DEVELOPER.md # Ce guide developpeur
60 +├── constitution.md # Constitution du projet (regles, vision)
61 +├── tailwind.config.js # Configuration Tailwind CSS + DaisyUI
62 +├── package.json # Dependances Node (tailwind, daisyui)
63 +├── package-lock.json # Lock Node
64 +├── certificat_imap.pem # Certificat TLS pour la connexion IMAP
65 +├── Todo.old # Archive de l'ancien fichier todo
66 +│
67 +├── app/ ──── APPLICATION DE REFERENCE ────
68 +│ ├── Cargo.toml # Dependances : rustwarden-core, rustwarden-ui, tokio, anyhow
69 +│ ├── src/
70 +│ │ └── main.rs # Point d'entree minimal : RustwardenApp::builder().from_env()
71 +│ └── examples/
72 +│ ├── minimal_headless.rs # Exemple sans UI (API-only, headless)
73 +│ ├── with_default_ui.rs # Exemple avec l'UI par defaut
74 +│ └── custom_hooks.rs # Exemple d'implementation de RustwardenHooks
75 +│
36 76 ├── crates/
37 -│ ├── rustwarden-core/ # Framework : auth, models, services, hooks, middleware, i18n
77 +│ │
78 +│ ├── rustwarden-core/ ──── COEUR DU FRAMEWORK ────
79 +│ │ ├── Cargo.toml # Dependances (axum, sea-orm, jsonwebtoken, bcrypt, aes-gcm...)
80 +│ │ │ feature "oauth" active openidconnect + reqwest
81 +│ │ │
38 82 │ │ ├── src/
39 -│ │ │ ├── app.rs # Builder RustwardenApp
40 -│ │ │ ├── hooks.rs # Trait RustwardenHooks
41 -│ │ │ ├── lib.rs # Re-exports, prelude
42 -│ │ │ ├── migrations.rs # Exposition des migrations
43 -│ │ │ ├── error.rs # Types d'erreur
44 -│ │ │ ├── auth/ # JWT, sessions, extracteurs
45 -│ │ │ ├── config/ # Configuration (env, DB, serveur, email, admin)
46 -│ │ │ ├── middleware/ # Auth, admin, CSRF, rate-limit, etc.
47 -│ │ │ ├── models/ # Entites SeaORM
48 -│ │ │ ├── services/ # Logique metier
49 -│ │ │ ├── tasks/ # Taches de fond
50 -│ │ │ ├── templates/ # LayoutContext, templates email
51 -│ │ │ ├── i18n/ # Traduction, extraction locale
52 -│ │ │ └── bin/ # Binaires utilitaires (seed, migrate, etc.)
53 -│ │ ├── templates/ # Templates email HTML
54 -│ │ └── migrations/ # Fichiers de migration SQL
55 -│ └── rustwarden-ui/ # Interface web
83 +│ │ │ ├── lib.rs # Point d'entree de la lib : re-exports de tous les modules
84 +│ │ │ │ + module `prelude` (RustwardenApp, AuthUser, AppError, etc.)
85 +│ │ │ ├── app.rs # Builder pattern RustwardenApp : configure DB, routes, hooks,
86 +│ │ │ │ middleware, i18n, taches de fond, puis lance le serveur
87 +│ │ │ ├── hooks.rs # Trait RustwardenHooks (on_user_registered, on_user_login,
88 +│ │ │ │ on_password_changed, on_oauth_login, on_resource_shared...)
89 +│ │ │ │ + struct NoopHooks (implementation par defaut)
90 +│ │ │ ├── error.rs # Enum AppError : types d'erreur unifies (400, 401, 403, 404, 500)
91 +│ │ │ ├── migrations.rs # Re-export du Migrator SeaORM pour les applications externes
92 +│ │ │ │
93 +│ │ │ ├── auth/ ── Authentification ──
94 +│ │ │ │ ├── mod.rs # Re-exports du module auth
95 +│ │ │ │ ├── jwt.rs # JwtService : generation/validation des tokens JWT (access + refresh)
96 +│ │ │ │ ├── jwt_extractor.rs # Extracteur Axum pour valider le JWT depuis les cookies/headers
97 +│ │ │ │ ├── session.rs # SessionConfig : configuration des sessions (duree, cookie, etc.)
98 +│ │ │ │ ├── extractors.rs # AuthUser : extracteur qui injecte l'utilisateur authentifie
99 +│ │ │ │ ├── handlers.rs # Handlers API REST : login, register, logout, refresh_token,
100 +│ │ │ │ │ forgot_password, reset_password, verify_email, resend_verification
101 +│ │ │ │ └── oauth_handlers.rs # [feature: oauth] Handlers OAuth : initier le flow,
102 +│ │ │ │ callback provider, lier/delier un compte
103 +│ │ │ │
104 +│ │ │ ├── config/ ── Configuration ──
105 +│ │ │ │ ├── mod.rs # Re-exports des sous-modules config
106 +│ │ │ │ ├── app.rs # APP_NAME, APP_DOMAIN, APP_DESCRIPTION (depuis env ou defaut)
107 +│ │ │ │ ├── server.rs # ServerConfig : HOST, PORT, RUST_LOG
108 +│ │ │ │ ├── database.rs # Connexion a PostgreSQL via DATABASE_URL
109 +│ │ │ │ ├── auth.rs # AuthConfig : JWT_SECRET, durees de token, bcrypt cost
110 +│ │ │ │ ├── email.rs # EmailConfig : SMTP + IMAP, parametres d'envoi
111 +│ │ │ │ ├── admin.rs # DEFAULT_ADMIN_* : creation auto du premier admin
112 +│ │ │ │ ├── tasks.rs # TasksConfig : intervalles des taches de fond
113 +│ │ │ │ └── oauth.rs # [feature: oauth] OAuthConfig : providers, client_id/secret,
114 +│ │ │ │ chargement depuis DB (dechiffrement AES-256-GCM) + fallback env
115 +│ │ │ │
116 +│ │ │ ├── middleware/ ── Middleware Axum ──
117 +│ │ │ │ ├── mod.rs # Re-exports
118 +│ │ │ │ ├── auth_required.rs # Layer qui exige un JWT valide
119 +│ │ │ │ ├── auth_service.rs # Service qui attache AuthUser a la requete
120 +│ │ │ │ ├── admin_required.rs # Layer qui exige le role "admin"
121 +│ │ │ │ ├── admin_service.rs # Service pour la verification admin
122 +│ │ │ │ ├── admin_extractor.rs # Extracteur AdminUser
123 +│ │ │ │ ├── admin_helper.rs # Fonctions utilitaires admin
124 +│ │ │ │ ├── csrf.rs # Protection CSRF (generation + validation de tokens)
125 +│ │ │ │ ├── rate_limit.rs # Rate limiting par IP (configurable)
126 +│ │ │ │ ├── password_complexity.rs # Validation de complexite de mot de passe
127 +│ │ │ │ ├── update_last_login.rs # Met a jour last_login_at apres chaque login
128 +│ │ │ │ └── error_handler.rs # Middleware de gestion globale des erreurs
129 +│ │ │ │
130 +│ │ │ ├── models/ ── Entites SeaORM (ORM PostgreSQL) ──
131 +│ │ │ │ ├── mod.rs # Re-exports de toutes les entites
132 +│ │ │ │ ├── user.rs # Utilisateur : id, username, email, password_hash,
133 +│ │ │ │ │ must_change_password, email_verified, timestamps
134 +│ │ │ │ ├── role.rs # Role : id, name, description
135 +│ │ │ │ ├── user_role.rs # Relation N:N users <-> roles
136 +│ │ │ │ ├── permission.rs # Permission : id, name, description
137 +│ │ │ │ ├── role_permission.rs # Relation N:N roles <-> permissions
138 +│ │ │ │ ├── app_settings.rs # Parametres applicatifs cle/valeur persistants
139 +│ │ │ │ ├── audit_log.rs # Journal d'audit (action, user_id, ip, details)
140 +│ │ │ │ ├── refresh_token.rs # Tokens de rafraichissement JWT (hashes SHA-256)
141 +│ │ │ │ ├── jwt_blacklist.rs # Blacklist des JWT revoques
142 +│ │ │ │ ├── email_queue.rs # File d'attente d'envoi d'emails
143 +│ │ │ │ ├── email_delivery_status.rs # Statut de livraison des emails
144 +│ │ │ │ ├── email_validation_token.rs # Tokens de validation d'email
145 +│ │ │ │ ├── password_change_request.rs # Demandes de changement de mot de passe (double confirm.)
146 +│ │ │ │ ├── password_reset_token.rs # Tokens de reinitialisation de mot de passe
147 +│ │ │ │ ├── user_preferences.rs # Preferences utilisateur (theme, langue, timezone)
148 +│ │ │ │ ├── resource.rs # Ressource generique (type, owner_id, metadata JSON)
149 +│ │ │ │ ├── resource_share.rs # Partage de ressource (resource_id, user_id, permission_level)
150 +│ │ │ │ └── oauth_account.rs # [feature: oauth] Compte OAuth lie (provider, provider_user_id)
151 +│ │ │ │
152 +│ │ │ ├── services/ ── Logique metier ──
153 +│ │ │ │ ├── mod.rs # Re-exports de tous les services
154 +│ │ │ │ ├── user_service.rs # CRUD utilisateurs, recherche, listing pagine
155 +│ │ │ │ ├── auth_service.rs # Logique d'authentification (login, register, verify)
156 +│ │ │ │ ├── auth_logging.rs # Journalisation des evenements d'authentification
157 +│ │ │ │ ├── role_service.rs # CRUD roles, assignation aux utilisateurs
158 +│ │ │ │ ├── permission_service.rs # CRUD permissions, verification d'acces
159 +│ │ │ │ ├── password_service.rs # Hachage bcrypt, verification de mot de passe
160 +│ │ │ │ ├── password_change_service.rs # Workflow de changement de mdp (double confirmation email)
161 +│ │ │ │ ├── password_reset_service.rs # Workflow "mot de passe oublie" (token par email)
162 +│ │ │ │ ├── email_service.rs # Envoi d'emails via SMTP (lettre)
163 +│ │ │ │ ├── email_queue_service.rs # Gestion de la file d'attente d'emails
164 +│ │ │ │ ├── email_validation_service.rs # Validation d'adresse email (token + verification)
165 +│ │ │ │ ├── email_sanitization.rs # Normalisation et nettoyage des adresses email
166 +│ │ │ │ ├── imap_bounce_service.rs # Detection des bounces via IMAP
167 +│ │ │ │ ├── jwt_blacklist_service.rs # Gestion de la blacklist JWT
168 +│ │ │ │ ├── refresh_token_service.rs # CRUD refresh tokens (stockage hash SHA-256)
169 +│ │ │ │ ├── app_settings_service.rs # Lecture/ecriture des parametres applicatifs (DB)
170 +│ │ │ │ ├── default_admin_service.rs # Creation automatique du premier admin au demarrage
171 +│ │ │ │ ├── crypto_service.rs # Chiffrement AES-256-GCM (secrets OAuth en DB)
172 +│ │ │ │ │ utilise OAUTH_ENCRYPTION_KEY (hex 64 chars)
173 +│ │ │ │ ├── user_preferences_service.rs # CRUD preferences utilisateur
174 +│ │ │ │ ├── resource_service.rs # CRUD ressources generiques + partage
175 +│ │ │ │ └── oauth/ # [feature: oauth] ── Services OAuth ──
176 +│ │ │ │ ├── mod.rs # Re-exports OAuthProvider, OAuthService
177 +│ │ │ │ ├── provider_trait.rs # Trait OAuthProvider + struct ProviderUserInfo
178 +│ │ │ │ ├── service.rs # OAuthService : orchestration du flow OAuth/OIDC
179 +│ │ │ │ │ (generation URL, echange de code, creation/liaison compte)
180 +│ │ │ │ └── providers/
181 +│ │ │ │ ├── mod.rs # ProviderRegistry : resolution dynamique des providers
182 +│ │ │ │ ├── google.rs # GoogleProvider (OpenID Connect)
183 +│ │ │ │ ├── github.rs # GitHubProvider (OAuth2 + API /user)
184 +│ │ │ │ ├── discord.rs # DiscordProvider (OAuth2 + API /users/@me)
185 +│ │ │ │ └── microsoft.rs # MicrosoftProvider (OpenID Connect, multi-tenant)
186 +│ │ │ │
187 +│ │ │ ├── tasks/ ── Taches de fond (tokio::spawn) ──
188 +│ │ │ │ ├── mod.rs # Re-exports
189 +│ │ │ │ ├── email_queue_processor.rs # Traitement periodique de la file d'emails
190 +│ │ │ │ ├── password_change_cleanup.rs # Nettoyage des demandes de changement expirees
191 +│ │ │ │ └── password_change_email_retry.rs # Retry d'envoi d'emails de confirmation
192 +│ │ │ │
193 +│ │ │ ├── templates/ ── Utilitaires templates ──
194 +│ │ │ │ ├── mod.rs # Macro impl_template_response!, struct LayoutContext
195 +│ │ │ │ └── email_templates.rs # Structures Askama pour les templates d'emails
196 +│ │ │ │
197 +│ │ │ ├── i18n/ ── Internationalisation ──
198 +│ │ │ │ ├── mod.rs # Re-exports (Locale, I18nService)
199 +│ │ │ │ ├── service.rs # I18nService : traduction par cle, interpolation, pluralisation
200 +│ │ │ │ ├── loader.rs # Chargement des fichiers YAML de traduction
201 +│ │ │ │ ├── extractor.rs # Extracteur Axum Locale (cookie, header Accept-Language, defaut)
202 +│ │ │ │ ├── helpers.rs # Fonctions utilitaires i18n
203 +│ │ │ │ └── middleware.rs # Middleware d'injection de la locale dans la requete
204 +│ │ │ │
205 +│ │ │ └── bin/ ── Binaires utilitaires ──
206 +│ │ │ ├── seed.rs # Peuplement initial de la base (roles, permissions, admin)
207 +│ │ │ ├── hash_password.rs # Outil CLI pour hasher un mot de passe bcrypt
208 +│ │ │ └── change_password.rs # Outil CLI pour changer un mot de passe en DB
209 +│ │ │
210 +│ │ ├── templates/ ── Templates email HTML (compiles par Askama) ──
211 +│ │ │ └── emails/
212 +│ │ │ ├── welcome.html # Email de bienvenue
213 +│ │ │ ├── email_validation.html # Email de validation d'adresse
214 +│ │ │ ├── password_reset.html # Email de reinitialisation de mot de passe
215 +│ │ │ ├── password_changed.html # Notification de changement effectue
216 +│ │ │ ├── password_change_confirmation.html # Demande de confirmation de changement
217 +│ │ │ ├── account_modified.html # Notification de modification de compte
218 +│ │ │ └── admin_new_user.html # Notification admin : nouvel utilisateur
219 +│ │ │
220 +│ │ └── migrations/ ── Migrations SeaORM ──
221 +│ │ └── src/
222 +│ │ ├── lib.rs # Migrator : liste ordonnee des migrations
223 +│ │ ├── main.rs # Binaire pour executer les migrations manuellement
224 +│ │ ├── m20260305_000001_initial_schema.rs # Schema initial : users, roles, user_roles,
225 +│ │ │ refresh_tokens, jwt_blacklist, email_queue,
226 +│ │ │ email_delivery_status, email_validation_tokens,
227 +│ │ │ password_reset_tokens, password_change_requests,
228 +│ │ │ audit_log, user_preferences
229 +│ │ ├── m20260306_000002_create_app_settings_table.rs # Table app_settings (cle/valeur)
230 +│ │ ├── m20260309_000003_create_permissions_tables.rs # Tables permissions, role_permissions
231 +│ │ ├── m20260309_000004_create_resources_tables.rs # Tables resources, resource_shares
232 +│ │ └── m20260310_000005_create_oauth_accounts_table.rs # Table oauth_accounts
233 +│ │
234 +│ └── rustwarden-ui/ ──── INTERFACE WEB (UI) ────
235 +│ ├── Cargo.toml # Dependances : rustwarden-core, askama, axum, tower-http
236 +│ │
56 237 │ ├── src/
57 -│ │ ├── lib.rs # default_routes(), default_static_service()
58 -│ │ ├── routes.rs # Toutes les routes UI et API
59 -│ │ ├── handlers/ # Un handler par page/fonctionnalite
60 -│ │ ├── dto/ # Data Transfer Objects pour les templates
61 -│ │ ├── extractors.rs # Extracteurs UI
62 -│ │ └── template_helpers.rs # Fonctions d'aide pour les templates
63 -│ └── templates/ # Templates HTML (Askama)
64 -│ ├── base.html # Layout de base
65 -│ ├── home.html # Page d'accueil
66 -│ ├── login.html # Connexion / inscription
67 -│ ├── components/ # Navbar, header, composants reutilisables
68 -│ ├── settings/ # Pages profil, mot de passe, sessions, preferences
69 -│ ├── admin/ # Dashboard, users CRUD, roles CRUD, settings
70 -│ ├── emails/ # Templates d'emails
71 -│ └── errors/ # Pages 401, 403, 500
72 -├── app/ # Application de reference
73 -├── tools/
74 -│ └── rustwarden-cli/ # Wizard CLI (voir tools/rustwarden-cli/README.md)
75 -├── locales/ # Traductions YAML (fr, en, de, es)
76 -├── static/ # Fichiers statiques
77 -├── database/ # Docker Compose PostgreSQL
78 -└── docs/ # Documentation complementaire
238 +│ │ ├── lib.rs # Exports : default_routes(), default_static_service()
239 +│ │ ├── routes.rs # Definition de toutes les routes HTTP (API + pages)
240 +│ │ │ GET/POST/PUT/DELETE pour auth, users, admin, settings, public
241 +│ │ ├── extractors.rs # Extracteurs specifiques a l'UI
242 +│ │ ├── template_helpers.rs # Fonctions utilitaires pour les templates Askama
243 +│ │ │
244 +│ │ ├── handlers/ ── Handlers Axum (un par domaine fonctionnel) ──
245 +│ │ │ ├── mod.rs # Re-exports de tous les handlers
246 +│ │ │ ├── root_handler.rs # Redirection / → /home ou /login
247 +│ │ │ ├── home_handler.rs # Page d'accueil authentifiee /home
248 +│ │ │ ├── login_handler.rs # Pages /login, /resend-verification, /reset-password
249 +│ │ │ ├── settings_handler.rs # Pages /profile, /settings/password, /settings/sessions,
250 +│ │ │ │ /settings/preferences + handlers PUT associes
251 +│ │ │ ├── admin_handler.rs # Dashboard admin, CRUD users, CRUD roles, settings
252 +│ │ │ │ app_settings (GET/PUT) incluant la section OAuth
253 +│ │ │ ├── dashboard_handler.rs # Donnees du dashboard admin (stats, graphiques)
254 +│ │ │ ├── user_handler.rs # API CRUD utilisateurs (/api/v1/users)
255 +│ │ │ ├── email_handler.rs # Logique liee aux emails dans l'UI
256 +│ │ │ ├── navigation_handler.rs # Construction dynamique de la navigation
257 +│ │ │ └── health.rs # Endpoint /health (healthcheck)
258 +│ │ │
259 +│ │ └── dto/ ── Data Transfer Objects ──
260 +│ │ ├── mod.rs # Re-exports de tous les DTO
261 +│ │ ├── admin_dto.rs # DashboardStats, UserInfo, AppSettingsResponse, etc.
262 +│ │ ├── auth_dto.rs # LoginRequest, RegisterRequest, TokenResponse, etc.
263 +│ │ ├── home_dto.rs # HomePageData
264 +│ │ ├── navigation_dto.rs # NavItem, NavigationData
265 +│ │ ├── sidebar_dto.rs # SidebarItem, SidebarSection
266 +│ │ ├── breadcrumb_dto.rs # BreadcrumbItem
267 +│ │ ├── settings_dto.rs # UpdatePreferencesRequest, UserPreferencesResponse
268 +│ │ ├── email_dto.rs # EmailTemplateData
269 +│ │ ├── user_dto.rs # UserCreateRequest, UserUpdateRequest, etc.
270 +│ │ └── user_profile_dto.rs # UserProfileDTO
271 +│ │
272 +│ ├── templates/ ── Templates HTML Askama (ceux-ci font foi) ──
273 +│ │ ├── base.html # Layout de base : <html>, <head>, <body>, DaisyUI/Tailwind
274 +│ │ ├── base_with_theme.html # Variante de base.html avec selection de theme
275 +│ │ ├── home.html # Page d'accueil (/home) apres connexion
276 +│ │ ├── login.html # Connexion + inscription + boutons OAuth
277 +│ │ ├── profile.html # Page de profil utilisateur
278 +│ │ ├── resend_verification.html # Re-envoi d'email de verification
279 +│ │ ├── reset_password.html # Formulaire de reinitialisation de mot de passe
280 +│ │ ├── email_verified.html # Page de confirmation d'email verifie
281 +│ │ │
282 +│ │ ├── components/ # Composants reutilisables
283 +│ │ │ ├── navbar.html # Barre de navigation principale
284 +│ │ │ ├── header.html # En-tete de page
285 +│ │ │ ├── navigation.html # Menu de navigation lateral
286 +│ │ │ ├── sidebar.html # Barre laterale (admin, settings)
287 +│ │ │ ├── breadcrumb.html # Fil d'Ariane
288 +│ │ │ ├── user_menu.html # Menu utilisateur (avatar, deconnexion)
289 +│ │ │ └── password_change_badge.html # Badge "changement de mdp en attente"
290 +│ │ │
291 +│ │ ├── settings/ # Pages de parametres utilisateur
292 +│ │ │ ├── password.html # Changement de mot de passe
293 +│ │ │ ├── preferences.html # Preferences (theme, langue, timezone)
294 +│ │ │ └── sessions.html # Gestion des sessions actives
295 +│ │ │
296 +│ │ ├── admin/ # Pages d'administration
297 +│ │ │ ├── dashboard.html # Tableau de bord admin (stats)
298 +│ │ │ ├── users_list.html # Liste des utilisateurs (pagination, recherche)
299 +│ │ │ ├── user_form.html # Formulaire creation/edition d'utilisateur
300 +│ │ │ ├── roles_list.html # Liste des roles
301 +│ │ │ ├── role_form.html # Formulaire creation/edition de role + permissions
302 +│ │ │ ├── settings.html # Parametres applicatifs (general, securite, audit, OAuth)
303 +│ │ │ └── errors.html # Page de visualisation des erreurs admin
304 +│ │ │
305 +│ │ ├── emails/ # Templates d'emails (miroir de rustwarden-core/templates/emails)
306 +│ │ │ ├── welcome.html
307 +│ │ │ ├── email_validation.html
308 +│ │ │ ├── password_reset.html
309 +│ │ │ ├── password_changed.html
310 +│ │ │ ├── password_change_confirmation.html
311 +│ │ │ ├── account_modified.html
312 +│ │ │ └── admin_new_user.html
313 +│ │ │
314 +│ │ ├── errors/ # Pages d'erreur HTTP
315 +│ │ │ ├── 401.html # Non authentifie
316 +│ │ │ ├── 403.html # Acces interdit
317 +│ │ │ └── 500.html # Erreur serveur interne
318 +│ │ │
319 +│ │ ├── fragments/ # Fragments HTML (HTMX / inclusion partielle)
320 +│ │ │ ├── form_message.html # Message flash (succes/erreur) dans un formulaire
321 +│ │ │ └── user_menu_dropdown.html # Dropdown du menu utilisateur
322 +│ │ │
323 +│ │ └── sharing/ # Templates de partage de ressources (a venir)
324 +│ │
325 +│ └── locales/ ── Traductions YAML (crate UI) ──
326 +│ ├── fr.yml # Francais
327 +│ ├── en.yml # Anglais
328 +│ ├── de.yml # Allemand
329 +│ └── es.yml # Espagnol
330 +│
331 +├── locales/ ──── TRADUCTIONS YAML (SOURCE DE VERITE) ────
332 +│ ├── fr.yml # Francais
333 +│ ├── en.yml # Anglais
334 +│ ├── de.yml # Allemand
335 +│ └── es.yml # Espagnol
336 +│ NOTE: Ce dossier est charge a l'execution par le I18nService.
337 +│ C'est la source de verite pour les traductions.
338 +│
339 +├── static/ ──── FICHIERS STATIQUES ────
340 +│ ├── css/
341 +│ │ ├── tailwind.src.css # Source Tailwind (directives @tailwind)
342 +│ │ ├── tailwind.css # CSS compile (genere par `npx tailwindcss`)
343 +│ │ └── custom.css # Styles personnalises supplementaires
344 +│ ├── js/
345 +│ │ ├── htmx.min.js # HTMX (interactions dynamiques sans JS custom)
346 +│ │ └── alpine.min.js # Alpine.js (reactivite legere cote client)
347 +│ └── images/
348 +│ ├── logo.svg # Logo de l'application
349 +│ └── favicon.svg # Favicon
350 +│
351 +├── templates_old/ ──── ARCHIVE (ancien miroir templates, ne pas utiliser) ────
352 +├── migrations_old/ ──── ARCHIVE (ancien miroir migrations, ne pas utiliser) ────
353 +│
354 +├── database/ ──── BASE DE DONNEES (dev) ────
355 +│ ├── docker-compose.yml # Docker Compose pour PostgreSQL local
356 +│ ├── .env # Variables d'env pour le conteneur Docker
357 +│ ├── env.sample # Exemple de .env Docker
358 +│ └── data/
359 +│ └── postgres_data/ # Donnees PostgreSQL persistantes (volume Docker)
360 +│
361 +├── template/ ──── TEMPLATE cargo-generate ────
362 +│ ├── cargo-generate.toml # Configuration cargo-generate (variables, placeholders)
363 +│ ├── Cargo.toml # Manifeste template avec {{project-name}}
364 +│ ├── .env.sample # Exemple de .env pour les projets generes
365 +│ └── src/
366 +│ └── main.rs # main.rs template avec builder pattern
367 +│
368 +├── tools/ ──── OUTILS ────
369 +│ └── rustwarden-cli/
370 +│ ├── Cargo.toml # Dependances CLI : clap, dialoguer, console, rand
371 +│ ├── README.md # Documentation du CLI wizard
372 +│ └── src/
373 +│ ├── main.rs # Point d'entree CLI (commande `start-project`)
374 +│ ├── start_project.rs # Wizard interactif : nom, features, DB, OAuth, generation
375 +│ └── templates.rs # Generateur de fichiers (Cargo.toml, main.rs, .env, etc.)
376 +│
377 +├── docs/ ──── DOCUMENTATION ────
378 +│ ├── configuration-flows.md # Variables d'environnement, diagrammes de sequence
379 +│ ├── CSS_STYLE_GUIDE.md # Guide de style CSS (DaisyUI, conventions)
380 +│ └── ANSSI_COMPLIANCE_REPORT.md # Rapport de conformite ANSSI
381 +│
382 +└── .cursor/ ──── CONFIGURATION IDE ────
383 + └── commands/
384 + ├── speckit.analyze.md # Commande Cursor : analyse de specification
385 + ├── speckit.plan.md # Commande Cursor : planification
386 + ├── speckit.implement.md # Commande Cursor : implementation
387 + └── (...) # Autres commandes speckit
79 388 ```
80 389
81 390 ### Flux de demarrage
@@ -110,6 +419,7 @@ main.rs
110 419 | `auth/session.rs` | Configuration des sessions (memory, seaorm, redis) |
111 420 | `auth/handlers.rs` | Handlers REST : login, register, refresh, logout, forgot/reset password, verify email |
112 421 | `auth/extractors.rs` | `AuthUser` extracteur de haut niveau |
422 +| `auth/oauth_handlers.rs` | Handlers OAuth : redirect, callback, list_providers, list/unlink accounts *(feature `oauth`)* |
113 423
114 424 ### Modules config
115 425
@@ -122,6 +432,7 @@ main.rs
122 432 | `config/server.rs` | `ServerConfig` : host, port |
123 433 | `config/admin.rs` | `AdminConfig` : identifiants admin initial |
124 434 | `config/tasks.rs` | Intervalles des taches de fond |
435 +| `config/oauth.rs` | `OAuthConfig` : providers, client IDs, redirect URLs *(feature `oauth`)* |
125 436
126 437 ### Modules models
127 438
@@ -146,6 +457,7 @@ main.rs
146 457 | `UserPreferences` | `user_preferences` | Preferences utilisateur (theme, locale) |
147 458 | `Resource` | `resources` | Registre generique de ressources (type + ID + owner) |
148 459 | `ResourceShare` | `resource_shares` | Partage d'une ressource avec un utilisateur |
460 +| `OauthAccount` | `oauth_accounts` | Comptes OAuth lies a un utilisateur *(feature `oauth`)* |
149 461
150 462 ### Modules services
151 463
@@ -170,6 +482,9 @@ main.rs
170 482 | `AuthLogging` | Journalisation des evenements d'authentification |
171 483 | `EmailSanitization` | Nettoyage et validation des adresses email |
172 484 | `ResourceService` | Registre generique de ressources, partage, verification d'acces |
485 +| `OAuthService` | Liaison/deliaison comptes OAuth, find_or_create_user *(feature `oauth`)* |
486 +| `OAuthProvider` (trait) | Interface generique pour un provider OAuth/OIDC *(feature `oauth`)* |
487 +| `ProviderRegistry` | Registre des providers actifs (Google, GitHub, Discord, Microsoft) *(feature `oauth`)* |
173 488
174 489 ---
175 490
@@ -913,6 +1228,143 @@ impl RustwardenHooks for MyHooks {
913 1228
914 1229 ---
915 1230
1231 +## Authentification OAuth / SSO
1232 +
1233 +Le framework supporte l'authentification via OAuth2 et OpenID Connect. Cette fonctionnalite est compilee conditionnellement via le feature flag `oauth`.
1234 +
1235 +### Activation
1236 +
1237 +Dans le `Cargo.toml` de votre application :
1238 +
1239 +```toml
1240 +rustwarden-core = { path = "../crates/rustwarden-core", features = ["oauth"] }
1241 +```
1242 +
1243 +### Variables d'environnement
1244 +
1245 +| Variable | Description | Defaut |
1246 +|----------|-------------|--------|
1247 +| `OAUTH_ENCRYPTION_KEY` | Cle AES-256-GCM (hex 64 chars) pour chiffrer les secrets OAuth en DB | - |
1248 +| `OAUTH_ENABLED` | Active globalement OAuth | `false` |
1249 +| `OAUTH_REDIRECT_BASE_URL` | URL de base pour les callbacks | `http://localhost:3000` |
1250 +| `OAUTH_GOOGLE_CLIENT_ID` | Google OAuth Client ID | - |
1251 +| `OAUTH_GOOGLE_CLIENT_SECRET` | Google OAuth Client Secret | - |
1252 +| `OAUTH_GOOGLE_ENABLED` | Active Google | `false` |
1253 +| `OAUTH_GITHUB_CLIENT_ID` | GitHub OAuth Client ID | - |
1254 +| `OAUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | - |
1255 +| `OAUTH_GITHUB_ENABLED` | Active GitHub | `false` |
1256 +| `OAUTH_DISCORD_CLIENT_ID` | Discord OAuth Client ID | - |
1257 +| `OAUTH_DISCORD_CLIENT_SECRET` | Discord OAuth Client Secret | - |
1258 +| `OAUTH_DISCORD_ENABLED` | Active Discord | `false` |
1259 +| `OAUTH_MICROSOFT_CLIENT_ID` | Microsoft OAuth Client ID | - |
1260 +| `OAUTH_MICROSOFT_CLIENT_SECRET` | Microsoft OAuth Client Secret | - |
1261 +| `OAUTH_MICROSOFT_ENABLED` | Active Microsoft | `false` |
1262 +| `OAUTH_MICROSOFT_TENANT` | Azure AD tenant | `common` |
1263 +
1264 +> **Note** : Les variables d'environnement sont utilises en fallback. La configuration
1265 +> principale se fait via **Admin > Settings > OAuth / SSO** dans l'interface web.
1266 +> Les Client Secrets sont chiffres (AES-256-GCM) en base de donnees. La cle de
1267 +> chiffrement `OAUTH_ENCRYPTION_KEY` doit etre definie pour pouvoir enregistrer des
1268 +> secrets via l'interface admin. Generer avec `openssl rand -hex 32`.
1269 +
1270 +### Configuration via Admin UI
1271 +
1272 +L'interface d'administration (`/admin/settings`) permet de configurer OAuth sans
1273 +redemarrer l'application pour les toggles (enable/disable), mais les modifications
1274 +de credentials (Client ID, Client Secret, Tenant) necessitent un redemarrage.
1275 +
1276 +L'ecran admin permet de :
1277 +- Activer/desactiver OAuth globalement
1278 +- Configurer `oauth_redirect_base_url`, `oauth_auto_register`, `oauth_link_existing_account`
1279 +- Pour chaque provider : activer/desactiver, saisir Client ID et Client Secret
1280 +- Afficher l'URI de redirection a enregistrer chez le provider
1281 +- Voir le statut de configuration de chaque secret (configure / non configure)
1282 +
1283 +### Flux OAuth
1284 +
1285 +1. L'utilisateur clique sur "Se connecter avec Google" sur `/login`
1286 +2. Le handler `oauth_redirect` genere une URL d'autorisation avec un `state` CSRF, un `nonce` OIDC et un `pkce_verifier`, les stocke en session, puis redirige vers le provider
1287 +3. L'utilisateur s'authentifie chez le provider
1288 +4. Le provider redirige vers `/api/v1/auth/oauth/{provider}/callback` avec un `code` et le `state`
1289 +5. Le handler `oauth_callback` :
1290 + - Valide le `state` en comparaison constant-time
1291 + - Echange le `code` contre un token via PKCE
1292 + - Extrait les informations utilisateur (email, nom, avatar)
1293 + - Appelle `OAuthService::find_or_create_user` pour lier ou creer l'utilisateur
1294 + - Genere un JWT + refresh token
1295 + - Regenere le session ID (anti-fixation)
1296 + - Redirige vers `/home`
1297 +
1298 +### Routes OAuth
1299 +
1300 +| Methode | Route | Description |
1301 +|---------|-------|-------------|
1302 +| `GET` | `/api/v1/auth/oauth/{provider}/redirect` | Redirige vers le provider |
1303 +| `GET` | `/api/v1/auth/oauth/{provider}/callback` | Traite le retour du provider |
1304 +| `GET` | `/api/v1/auth/oauth/providers` | Liste les providers actifs (JSON) |
1305 +| `GET` | `/api/v1/auth/oauth/accounts` | Liste les comptes OAuth de l'utilisateur |
1306 +| `DELETE` | `/api/v1/auth/oauth/accounts/{id}` | Delie un compte OAuth |
1307 +
1308 +### Providers integres
1309 +
1310 +| Provider | Protocole | Scopes | PKCE |
1311 +|----------|-----------|--------|------|
1312 +| Google | OpenID Connect | `openid email profile` | S256 |
1313 +| GitHub | OAuth2 + API REST | `user:email` | Non |
1314 +| Discord | OAuth2 + API REST | `identify email` | Non |
1315 +| Microsoft | OpenID Connect | `openid email profile` | S256 |
1316 +
1317 +### Ajouter un provider custom
1318 +
1319 +Implementez le trait `OAuthProvider` :
1320 +
1321 +```rust
1322 +use rustwarden_core::services::oauth::{OAuthProvider, ProviderUserInfo};
1323 +use rustwarden_core::services::oauth::provider_trait::AuthorizationUrl;
1324 +use async_trait::async_trait;
1325 +
1326 +pub struct MyProvider {
1327 + client_id: String,
1328 + client_secret: String,
1329 +}
1330 +
1331 +#[async_trait]
1332 +impl OAuthProvider for MyProvider {
1333 + fn name(&self) -> &str { "my_provider" }
1334 + fn display_name(&self) -> &str { "My Provider" }
1335 +
1336 + async fn authorization_url(&self, redirect_uri: &str) -> anyhow::Result<AuthorizationUrl> {
1337 + // Construire l'URL d'autorisation
1338 + todo!()
1339 + }
1340 +
1341 + async fn exchange_code(
1342 + &self,
1343 + code: &str,
1344 + redirect_uri: &str,
1345 + pkce_verifier: Option<&str>,
1346 + nonce: Option<&str>,
1347 + ) -> anyhow::Result<ProviderUserInfo> {
1348 + // Echanger le code et recuperer les infos utilisateur
1349 + todo!()
1350 + }
1351 +}
1352 +```
1353 +
1354 +Puis enregistrez-le dans le `ProviderRegistry` (dans `app.rs` ou votre `main.rs`).
1355 +
1356 +### Securite OAuth
1357 +
1358 +- **CSRF** : Parametre `state` genere aleatoirement, stocke en session, valide en comparaison constant-time (`subtle::ConstantTimeEq`)
1359 +- **PKCE** : Proof Key for Code Exchange (S256) pour les providers OIDC (Google, Microsoft)
1360 +- **Nonce OIDC** : Genere et verifie dans l'ID token pour prevenir les attaques de replay
1361 +- **Email verifie requis** : `OAuthService::find_or_create_user` refuse les comptes sans email verifie par le provider
1362 +- **Rate limiting** : Le callback est protege par `check_auth_rate_limit` (10 req/min par IP)
1363 +- **Session fixation** : `session.cycle_id()` apres chaque login OAuth reussi
1364 +- **Deliaison securisee** : Impossible de supprimer le dernier moyen d'authentification (si pas de mot de passe)
1365 +
1366 +---
1367 +
916 1368 ## Hooks : intercepter les evenements
917 1369
918 1370 ### Implementer le trait
@@ -971,6 +1423,7 @@ RustwardenApp::builder()
971 1423 | `on_role_changed` | `(db, user_id, roles)` | Apres modification des roles |
972 1424 | `on_resource_shared` | `(db, type, id, user, level, by)` | Apres partage d'une ressource |
973 1425 | `on_resource_unshared` | `(db, type, id, user)` | Apres revocation d'un partage |
1426 +| `on_oauth_login` | `(db, user_id, provider, is_new_user)` | Apres login OAuth reussi |
974 1427
975 1428 Tous les hooks ont une implementation par defaut (no-op), implementez uniquement ceux dont vous avez besoin.
976 1429
@@ -1187,6 +1640,10 @@ cargo test -- --nocapture
1187 1640 - [ ] `ALLOW_REGISTRATION=false` (sauf si necessaire)
1188 1641 - [ ] `RUST_LOG=info` (pas `debug`)
1189 1642 - [ ] `SESSION_BACKEND=seaorm` ou `redis` (pas `memory`)
1643 +- [ ] Si OAuth active : `OAUTH_ENCRYPTION_KEY` est definie (64 caracteres hex)
1644 +- [ ] Si OAuth active : `OAUTH_REDIRECT_BASE_URL` pointe vers le domaine HTTPS de production
1645 +- [ ] Si OAuth active : les Client ID/Secret de chaque provider sont configures (via Admin UI ou env vars)
1646 +- [ ] Si OAuth active : les redirect URIs sont enregistrees dans la console de chaque provider
1190 1647
1191 1648 ### Build optimise
1192 1649
M README.md
+37 -0
@@ -17,6 +17,7 @@ Permet de creer rapidement des applications web avec authentification JWT, RBAC
17 17 - Internationalisation (fr, en, de, es)
18 18 - Middleware d'authentification et d'autorisation
19 19 - Conformite ANSSI (complexite des mots de passe, HttpOnly cookies, etc.)
20 +- OAuth2 / OpenID Connect (Google, GitHub, Discord, Microsoft) avec architecture extensible
20 21 - Variable `APP_NAME` pour personnaliser le branding partout dans l'UI
21 22
22 23 ## Architecture
@@ -199,6 +200,16 @@ RustwardenApp::builder()
199 200 | POST | `/api/v1/auth/resend-verification` | Renvoyer le mail de verification |
200 201 | POST | `/api/v1/auth/resend-verification/by-username` | Idem, par username |
201 202
203 +### API REST (OAuth) *(feature `oauth`)*
204 +
205 +| Methode | Chemin | Description |
206 +|---------|--------|-------------|
207 +| GET | `/api/v1/auth/oauth/{provider}/redirect` | Redirige vers le provider OAuth |
208 +| GET | `/api/v1/auth/oauth/{provider}/callback` | Callback du provider OAuth |
209 +| GET | `/api/v1/auth/oauth/providers` | Liste les providers actifs (JSON) |
210 +| GET | `/api/v1/auth/oauth/accounts` | Liste les comptes OAuth de l'utilisateur |
211 +| DELETE | `/api/v1/auth/oauth/accounts/{id}` | Delie un compte OAuth |
212 +
202 213 ### API REST (utilisateurs)
203 214
204 215 | Methode | Chemin | Description |
@@ -294,6 +305,9 @@ RustwardenApp::builder()
294 305 | `on_password_changed` | Apres changement de mot de passe |
295 306 | `on_email_verified` | Apres verification de l'email |
296 307 | `on_role_changed` | Apres modification des roles |
308 +| `on_resource_shared` | Apres partage d'une ressource |
309 +| `on_resource_unshared` | Apres revocation d'un partage |
310 +| `on_oauth_login` | Apres login OAuth reussi (provider + is_new_user) |
297 311
298 312 ---
299 313
@@ -434,6 +448,29 @@ Le panneau d'administration (`/admin/roles`) permet de :
434 448 | `IMAP_MAILBOX` | Boite mail a surveiller | `INBOX` |
435 449 | `IMAP_POLL_INTERVAL_SECONDS` | Intervalle de verification | `300` |
436 450
451 +### OAuth / SSO *(feature `oauth`)*
452 +
453 +| Variable | Description | Defaut |
454 +|----------|-------------|--------|
455 +| `OAUTH_ENCRYPTION_KEY` | Cle AES-256-GCM (hex 64 chars) pour chiffrer les secrets en DB | — |
456 +| `OAUTH_ENABLED` | Active globalement OAuth | `false` |
457 +| `OAUTH_REDIRECT_BASE_URL` | URL de base pour les callbacks | `http://localhost:3000` |
458 +| `OAUTH_GOOGLE_CLIENT_ID` | Google Client ID | — |
459 +| `OAUTH_GOOGLE_CLIENT_SECRET` | Google Client Secret | — |
460 +| `OAUTH_GOOGLE_ENABLED` | Active Google | `false` |
461 +| `OAUTH_GITHUB_CLIENT_ID` | GitHub Client ID | — |
462 +| `OAUTH_GITHUB_CLIENT_SECRET` | GitHub Client Secret | — |
463 +| `OAUTH_GITHUB_ENABLED` | Active GitHub | `false` |
464 +| `OAUTH_DISCORD_CLIENT_ID` | Discord Client ID | — |
465 +| `OAUTH_DISCORD_CLIENT_SECRET` | Discord Client Secret | — |
466 +| `OAUTH_DISCORD_ENABLED` | Active Discord | `false` |
467 +| `OAUTH_MICROSOFT_CLIENT_ID` | Microsoft Client ID | — |
468 +| `OAUTH_MICROSOFT_CLIENT_SECRET` | Microsoft Client Secret | — |
469 +| `OAUTH_MICROSOFT_ENABLED` | Active Microsoft | `false` |
470 +| `OAUTH_MICROSOFT_TENANT` | Azure AD tenant | `common` |
471 +
472 +> Les credentials OAuth peuvent etre configures via l'interface Admin (`/admin/settings`) ou via ces variables d'environnement (fallback).
473 +
437 474 ---
438 475
439 476 ## Migrations
M app/Cargo.toml
+1 -2
@@ -9,10 +9,9 @@ name = "rustwarden"
9 9 path = "src/main.rs"
10 10
11 11 [dependencies]
12 -rustwarden-core = { path = "../crates/rustwarden-core" }
12 +rustwarden-core = { path = "../crates/rustwarden-core", features = ["oauth"] }
13 13 rustwarden-ui = { path = "../crates/rustwarden-ui" }
14 14 axum = { version = "0.8", features = ["macros"] }
15 -axum-extra = { version = "0.9", features = ["cookie"] }
16 15 tokio = { version = "1", features = ["full"] }
17 16 tower = { version = "0.5", features = ["limit", "util"] }
18 17 sea-orm = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] }
A controles_securite.md
+79 -0
@@ -0,0 +1,79 @@
1 +L'audit de securite est termine. Voici le rapport consolide avec **toutes les vulnerabilites trouvees**, classees par priorite.
2 +
3 +---
4 +
5 +## Rapport d'Audit de Securite -- Rustwarden Framework
6 +
7 +### Resume executif
8 +
9 +Le framework a de **solides fondations** (AES-256-GCM, bcrypt, comparaison constant-time CSRF, rotation refresh tokens, JWT blacklist, PKCE Google/Microsoft, session fixation protection). Cependant, **4 vulnerabilites critiques** et **9 hautes** doivent etre corrigees avant de generer un projet en production.
10 +
11 +---
12 +
13 +### CRITIQUE (a corriger avant generation)
14 +
15 +| # | Probleme | Fichier |
16 +|---|----------|---------|
17 +| **C1** | **Timing attack** : `verify_credentials` retourne immediatement si l'utilisateur n'existe pas (0ms vs ~200ms bcrypt) -- permet l'enumeration de comptes | `auth_service.rs:38-43` |
18 +| **C2** | **Enumeration via inscription** : messages d'erreur explicites "L'email X est deja utilise" | `handlers.rs:1168-1188` |
19 +| **C3** | **Bypass CSRF total** pour toutes les routes `/api/*` (le middleware laisse passer, aucun handler ne valide) | `csrf.rs:168-179` |
20 +| **C4** | **Open Redirect** : l'URI n'est pas sanitisee dans `auth_required.rs` et `admin_required.rs` avant la redirection vers `/login?redirect=...` | `auth_required.rs:130`, `admin_required.rs:75,114` |
21 +
22 +### HAUTE
23 +
24 +| # | Probleme | Fichier |
25 +|---|----------|---------|
26 +| **H1** | **Aucun Security Header HTTP** (X-Frame-Options, CSP, HSTS, X-Content-Type-Options, Referrer-Policy) | `app.rs` |
27 +| **H2** | **Open Redirect cote client** : `login.html` utilise `window.location.href = redirect` sans validation JS | `login.html:340,426` |
28 +| **H3** | **IP Spoofing** : `X-Forwarded-For` accepte sans validation, contourne le rate limiting | `rate_limit.rs:179-189` |
29 +| **H4** | **Methode TRACE autorisee** dans les exemptions CSRF (attaque XST) | `csrf.rs:133` |
30 +| **H5** | **CSRF non valide** dans les handlers formulaires (admin_settings, update_profile, change_password) | `admin_handler.rs`, `settings_handler.rs` |
31 +| **H6** | **Pas de PKCE** pour GitHub et Discord OAuth | `github.rs:49-67`, `discord.rs:44-62` |
32 +| **H7** | **redirect_uri non encode** dans les URLs OAuth (injection de parametres possible) | Tous les providers OAuth |
33 +| **H8** | **Nonce vide accepte** pour Google/Microsoft OIDC si la session est perdue | `google.rs:110`, `microsoft.rs:118` |
34 +| **H9** | **`optional_auth`** ne propage pas les claims (bug fonctionnel de securite) | `auth_required.rs:202-225` |
35 +
36 +### MOYENNE
37 +
38 +| # | Probleme | Fichier |
39 +|---|----------|---------|
40 +| M1 | `AuthConfig::from_env()` relu a chaque requete (perf + risque) | `extractors.rs:194` |
41 +| M2 | Rate limiter en memoire (non distribue, fuite memoire, Mutex standard) | `rate_limit.rs` |
42 +| M3 | Pas de longueur max mot de passe (bcrypt tronque a 72 octets, DoS possible) | `password_complexity.rs` |
43 +| M4 | Pas de limite de refresh tokens actifs par utilisateur | `refresh_token_service.rs` |
44 +| M5 | Bcrypt cost factor non configurable (DEFAULT_COST=12, OWASP recommande 13+) | `password_service.rs:28` |
45 +| M6 | JWT dans corps JSON ET cookie simultanement (XSS peut capturer le body) | `handlers.rs:310-347` |
46 +| M7 | Pas d'invalidation JWT au changement de mot de passe (15 min d'exposition) | Architecture |
47 +| M8 | OAuth secrets non dechiffres dans `from_env()` (fallback) | `oauth.rs:156-172` |
48 +| M9 | `COOKIE_SECURE=false` dans `.env` dev (risque si copie en prod) | `auth.rs:110` |
49 +| M10 | Middleware `error_handler` et `password_complexity_validation` sont des no-ops | `error_handler.rs:117`, `password_complexity.rs:108` |
50 +
51 +### BASSE
52 +
53 +| # | Probleme | Fichier |
54 +|---|----------|---------|
55 +| L1 | JWT_SECRET de l'exemple non rejete au demarrage | `.env.sample:16` |
56 +| L2 | Absence de claims `jti` et `aud` dans les JWT | `jwt.rs:54-77` |
57 +| L3 | `generate_default_password` trop previsible (32 bits d'entropie) | `password_service.rs:73-77` |
58 +| L4 | Regles de complexite mot de passe toutes desactivees par defaut | `password_complexity.rs:26` |
59 +| L5 | Usernames logges en clair lors des echecs de login | `auth_service.rs:41` |
60 +| L6 | Cookie session sans nom personnalise (revele la technologie) | `session.rs` |
61 +| L7 | Validation email trop simpliste (`user@.com` accepte) | `email_sanitization.rs:46` |
62 +| L8 | SameSite doc contradictoire (Lax vs Strict) | `auth.rs:115` vs `handlers.rs:328` |
63 +
64 +### Points positifs confirmes
65 +
66 +- Rotation des refresh tokens + detection de reutilisation
67 +- SHA-256 des refresh tokens avant stockage
68 +- Blacklist JWT effective
69 +- Comparaison constant-time pour CSRF et OAuth state
70 +- Protection fixation de session (`session.cycle_id()`)
71 +- PKCE S256 pour Google et Microsoft
72 +- Chiffrement AES-256-GCM des secrets OAuth (nonce aleatoire, cle validee)
73 +- HttpOnly cookies pour le JWT
74 +- Email verification obligatoire
75 +- Minimum 32 bytes pour JWT_SECRET
76 +
77 +---
78 +
79 +Voulez-vous que je corrige les vulnerabilites **critiques et hautes** avant que vous generiez votre projet ? Je peux traiter les C1-C4 et H1-H9 en priorite.
79 < \ No newline at end of file
M crates/rustwarden-core/Cargo.toml
+14 -12
@@ -14,7 +14,6 @@ serde = { version = "1.0", features = ["derive"] }
14 14 serde_json = "1.0"
15 15 # Middleware
16 16 tower = { version = "0.5", features = ["limit", "util"] }
17 -tower-http = { version = "0.6", features = ["cors", "trace", "limit", "fs"] }
18 17 # Logging
19 18 tracing = "0.1"
20 19 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
@@ -24,7 +23,7 @@ dotenvy = "0.15"
24 23 sea-orm = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] }
25 24 sea-orm-migration = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls"] }
26 25 # Password hashing
27 -bcrypt = "0.15"
26 +bcrypt = "0.19"
28 27 # Validation
29 28 validator = { version = "0.20", features = ["derive"] }
30 29 # UUID & Datetime
@@ -32,21 +31,19 @@ uuid = { version = "1.0", features = ["v4", "serde"] }
32 31 chrono = { version = "0.4", features = ["serde"] }
33 32 time = { version = "0.3", features = ["macros", "serde"] }
34 33 # Error handling
35 -thiserror = "1.0"
34 +thiserror = "2.0"
36 35 anyhow = "1.0"
37 36 # Authentication — JWT & Sessions
38 -jsonwebtoken = "9.3"
39 -axum-login = "0.18"
37 +jsonwebtoken = { version = "10", features = ["rust_crypto"] }
40 38 tower-sessions = "0.14"
41 -tower-sessions-seaorm-store = "0.1.1"
42 39 tower-sessions-redis-store = { version = "0.14", optional = true }
43 -tower-csrf = "0.1"
44 -axum-extra = { version = "0.9", features = ["cookie"] }
45 -rand = { version = "0.8", features = ["std"] }
46 -# JWT Blacklist
40 +axum-extra = { version = "0.12", features = ["cookie"] }
41 +rand = { version = "0.9", features = ["std"] }
42 +# JWT Blacklist & crypto utilities
47 43 sha2 = "0.10"
48 44 base64 = "0.22"
49 45 hex = "0.4"
46 +subtle = "2.6"
50 47 # Email
51 48 lettre = { version = "0.11", features = ["tokio1-native-tls", "builder"] }
52 49 # IMAP client for bounce processing
@@ -57,11 +54,17 @@ yaml-rust2 = "0.11.0"
57 54 # Formatting
58 55 num-format = "0.4.4"
59 56 # Templates (macro + LayoutContext)
60 -askama = "0.14"
57 +askama = "0.15"
58 +# Encryption (for storing OAuth secrets in DB)
59 +aes-gcm = "0.10"
60 +# OAuth2 / OpenID Connect (optional)
61 +openidconnect = { version = "4", optional = true }
62 +reqwest = { version = "0.12", features = ["json", "native-tls"], optional = true }
61 63
62 64 [features]
63 65 default = []
64 66 redis-session = ["tower-sessions-redis-store"]
67 +oauth = ["openidconnect", "reqwest"]
65 68
66 69 [lints.clippy]
67 70 empty_line_after_doc_comments = "allow"
@@ -86,4 +89,3 @@ path = "src/bin/change_password.rs"
86 89 hyper = { version = "1", features = ["full"] }
87 90 hyper-util = { version = "0.1", features = ["full"] }
88 91 tower = "0.5"
89 -tempfile = "3.10"
M crates/rustwarden-core/migrations/src/lib.rs
+2 -0
@@ -3,6 +3,7 @@ pub use sea_orm_migration::prelude::*;
3 3 pub mod m20260305_000001_initial_schema;
4 4 pub mod m20260306_000002_create_app_settings_table;
5 5 pub mod m20260309_000003_create_permissions_tables;
6 +pub mod m20260310_000005_create_oauth_accounts_table;
6 7
7 8 pub struct Migrator;
8 9
@@ -13,6 +14,7 @@ impl MigratorTrait for Migrator {
13 14 Box::new(m20260305_000001_initial_schema::Migration),
14 15 Box::new(m20260306_000002_create_app_settings_table::Migration),
15 16 Box::new(m20260309_000003_create_permissions_tables::Migration),
17 + Box::new(m20260310_000005_create_oauth_accounts_table::Migration),
16 18 ]
17 19 }
18 20 }
A crates/rustwarden-core/migrations/src/m20260310_000005_create_oauth_accounts_table.rs
+129 -0
@@ -0,0 +1,129 @@
1 +use sea_orm_migration::prelude::*;
2 +
3 +#[derive(DeriveMigrationName)]
4 +pub struct Migration;
5 +
6 +#[async_trait::async_trait]
7 +impl MigrationTrait for Migration {
8 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
9 + manager
10 + .create_table(
11 + Table::create()
12 + .table(OauthAccounts::Table)
13 + .if_not_exists()
14 + .col(
15 + ColumnDef::new(OauthAccounts::Id)
16 + .uuid()
17 + .not_null()
18 + .primary_key(),
19 + )
20 + .col(
21 + ColumnDef::new(OauthAccounts::UserId)
22 + .uuid()
23 + .not_null(),
24 + )
25 + .col(
26 + ColumnDef::new(OauthAccounts::Provider)
27 + .string()
28 + .string_len(50)
29 + .not_null(),
30 + )
31 + .col(
32 + ColumnDef::new(OauthAccounts::ProviderUserId)
33 + .string()
34 + .string_len(255)
35 + .not_null(),
36 + )
37 + .col(
38 + ColumnDef::new(OauthAccounts::Email)
39 + .string()
40 + .string_len(255)
41 + .null(),
42 + )
43 + .col(
44 + ColumnDef::new(OauthAccounts::DisplayName)
45 + .string()
46 + .string_len(255)
47 + .null(),
48 + )
49 + .col(
50 + ColumnDef::new(OauthAccounts::AvatarUrl)
51 + .string()
52 + .string_len(1024)
53 + .null(),
54 + )
55 + .col(
56 + ColumnDef::new(OauthAccounts::CreatedAt)
57 + .timestamp_with_time_zone()
58 + .not_null()
59 + .default(Expr::current_timestamp()),
60 + )
61 + .col(
62 + ColumnDef::new(OauthAccounts::UpdatedAt)
63 + .timestamp_with_time_zone()
64 + .not_null()
65 + .default(Expr::current_timestamp()),
66 + )
67 + .foreign_key(
68 + ForeignKey::create()
69 + .name("fk_oauth_accounts_user_id")
70 + .from(OauthAccounts::Table, OauthAccounts::UserId)
71 + .to(Users::Table, Users::Id)
72 + .on_delete(ForeignKeyAction::Cascade),
73 + )
74 + .to_owned(),
75 + )
76 + .await?;
77 +
78 + // Unique constraint: one provider account per user per provider
79 + manager
80 + .create_index(
81 + Index::create()
82 + .name("idx_oauth_accounts_provider_user")
83 + .table(OauthAccounts::Table)
84 + .col(OauthAccounts::Provider)
85 + .col(OauthAccounts::ProviderUserId)
86 + .unique()
87 + .to_owned(),
88 + )
89 + .await?;
90 +
91 + manager
92 + .create_index(
93 + Index::create()
94 + .name("idx_oauth_accounts_user_id")
95 + .table(OauthAccounts::Table)
96 + .col(OauthAccounts::UserId)
97 + .to_owned(),
98 + )
99 + .await?;
100 +
101 + Ok(())
102 + }
103 +
104 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
105 + manager
106 + .drop_table(Table::drop().table(OauthAccounts::Table).to_owned())
107 + .await
108 + }
109 +}
110 +
111 +#[derive(Iden)]
112 +enum OauthAccounts {
113 + Table,
114 + Id,
115 + UserId,
116 + Provider,
117 + ProviderUserId,
118 + Email,
119 + DisplayName,
120 + AvatarUrl,
121 + CreatedAt,
122 + UpdatedAt,
123 +}
124 +
125 +#[derive(Iden)]
126 +enum Users {
127 + Table,
128 + Id,
129 +}
M crates/rustwarden-core/src/app.rs
+92 -0
@@ -266,6 +266,63 @@ impl RustwardenBuilder {
266 266 // 6. I18n
267 267 let i18n_service = Self::init_i18n(&self.locale_dirs).await;
268 268
269 + // 6b. OAuth provider registry (if oauth feature enabled)
270 + #[cfg(feature = "oauth")]
271 + let oauth_registry = {
272 + use crate::config::oauth::OAuthConfig;
273 + use crate::services::oauth::providers::{
274 + ProviderRegistry,
275 + google::GoogleProvider,
276 + github::GitHubProvider,
277 + discord::DiscordProvider,
278 + microsoft::MicrosoftProvider,
279 + };
280 +
281 + let oauth_config = OAuthConfig::load(&db).await;
282 + let mut registry = ProviderRegistry::new();
283 +
284 + if oauth_config.enabled {
285 + for provider_cfg in oauth_config.enabled_providers() {
286 + match provider_cfg.name.as_str() {
287 + "google" => {
288 + registry.register(Arc::new(GoogleProvider::new(
289 + provider_cfg.client_id.clone(),
290 + provider_cfg.client_secret.clone(),
291 + )));
292 + tracing::info!("OAuth provider registered: Google");
293 + }
294 + "github" => {
295 + registry.register(Arc::new(GitHubProvider::new(
296 + provider_cfg.client_id.clone(),
297 + provider_cfg.client_secret.clone(),
298 + )));
299 + tracing::info!("OAuth provider registered: GitHub");
300 + }
301 + "discord" => {
302 + registry.register(Arc::new(DiscordProvider::new(
303 + provider_cfg.client_id.clone(),
304 + provider_cfg.client_secret.clone(),
305 + )));
306 + tracing::info!("OAuth provider registered: Discord");
307 + }
308 + "microsoft" => {
309 + registry.register(Arc::new(MicrosoftProvider::new(
310 + provider_cfg.client_id.clone(),
311 + provider_cfg.client_secret.clone(),
312 + provider_cfg.tenant.clone(),
313 + )));
314 + tracing::info!("OAuth provider registered: Microsoft");
315 + }
316 + _ => {
317 + tracing::warn!("Unknown OAuth provider: {}", provider_cfg.name);
318 + }
319 + }
320 + }
321 + }
322 +
323 + Arc::new(registry)
324 + };
325 +
269 326 // 7. Build router
270 327 let mut router: Router<DatabaseConnection> = Router::new();
271 328
@@ -277,6 +334,36 @@ impl RustwardenBuilder {
277 334 router = router.merge(custom);
278 335 }
279 336
337 + // 7b. OAuth routes (conditional on feature + config)
338 + #[cfg(feature = "oauth")]
339 + {
340 + use axum::routing::get;
341 + use crate::auth::oauth_handlers;
342 +
343 + let oauth_routes = Router::new()
344 + .route(
345 + "/api/v1/auth/oauth/{provider}/redirect",
346 + get(oauth_handlers::oauth_redirect),
347 + )
348 + .route(
349 + "/api/v1/auth/oauth/{provider}/callback",
350 + get(oauth_handlers::oauth_callback),
351 + )
352 + .route(
353 + "/api/v1/auth/oauth/providers",
354 + get(oauth_handlers::list_providers),
355 + )
356 + .route(
357 + "/api/v1/auth/oauth/accounts",
358 + get(oauth_handlers::list_user_oauth_accounts),
359 + )
360 + .route(
361 + "/api/v1/auth/oauth/accounts/{id}",
362 + axum::routing::delete(oauth_handlers::unlink_oauth_account),
363 + );
364 + router = router.merge(oauth_routes);
365 + }
366 +
280 367 // 8. Apply middleware stack
281 368 let supported = self.supported_locales.clone();
282 369 let default = self.default_locale.clone();
@@ -284,6 +371,7 @@ impl RustwardenBuilder {
284 371 let db_for_auth = db.clone();
285 372
286 373 let hooks = self.hooks.clone();
374 + let db_for_ext = db.clone();
287 375 let app = router.with_state(db).layer(
288 376 ServiceBuilder::new()
289 377 .layer_fn(move |service| AdminService::new(service, db_for_admin.clone()))
@@ -296,9 +384,13 @@ impl RustwardenBuilder {
296 384 }))
297 385 .layer(axum::Extension(i18n_service))
298 386 .layer(axum::Extension(hooks))
387 + .layer(axum::Extension(db_for_ext))
299 388 .into_inner(),
300 389 );
301 390
391 + #[cfg(feature = "oauth")]
392 + let app = app.layer(axum::Extension(oauth_registry));
393 +
302 394 // 9. Resolve address
303 395 let address = self.server_address
304 396 .unwrap_or_else(|| ServerConfig::from_env().address());
M crates/rustwarden-core/src/auth/extractors.rs
+16 -0
@@ -8,11 +8,13 @@ use axum::{
8 8 extract::FromRequestParts,
9 9 http::{request::Parts, StatusCode},
10 10 };
11 +use sea_orm::DatabaseConnection;
11 12 use uuid::Uuid;
12 13
13 14 use crate::auth::jwt::JwtService;
14 15 use crate::config::auth::AuthConfig;
15 16 use crate::error::AppError;
17 +use crate::services::jwt_blacklist_service::JwtBlacklistService;
16 18
17 19 /// Authenticated user extractor
18 20 ///
@@ -197,6 +199,20 @@ where
197 199 )
198 200 })?;
199 201
202 + // SEC-C1: Check JWT blacklist if DatabaseConnection is available in extensions
203 + if let Some(db) = parts.extensions.get::<DatabaseConnection>() {
204 + if JwtBlacklistService::is_blacklisted(db, &token)
205 + .await
206 + .unwrap_or(false)
207 + {
208 + tracing::warn!("AuthUser: JWT token is blacklisted");
209 + return Err((
210 + StatusCode::UNAUTHORIZED,
211 + "Token has been revoked".to_string(),
212 + ));
213 + }
214 + }
215 +
200 216 // Validate token and extract claims
201 217 let claims = JwtService::validate_token(&auth_config, &token).map_err(|e| {
202 218 let (status, message) = match e {
M crates/rustwarden-core/src/auth/handlers.rs
+40 -6
@@ -222,10 +222,16 @@ impl IntoResponse for EmailVerifiedTemplate {
222 222 /// ```
223 223 pub async fn login(
224 224 State(db): State<DatabaseConnection>,
225 + headers: HeaderMap,
225 226 _user_session: Session,
226 227 hooks: Option<Extension<Arc<dyn RustwardenHooks>>>,
227 228 Json(payload): Json<LoginRequest>,
228 229 ) -> Result<Response, AppError> {
230 + // SEC-C2: Per-IP rate limiting
231 + if let Err(resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) {
232 + return Ok(resp);
233 + }
234 +
229 235 tracing::info!("Login request received for user: {}", payload.username);
230 236
231 237 // Validate input
@@ -291,15 +297,14 @@ pub async fn login(
291 297 // No need to store in session serveur - JWT is stateless
292 298 tracing::debug!("JWT token generated for user {}", user.username);
293 299
294 - // Generate refresh token (Phase 7 - US5)
295 - let refresh_token_model = RefreshTokenService::create_refresh_token(
300 + let created_rt = RefreshTokenService::create_refresh_token(
296 301 &db,
297 302 user.id,
298 303 auth_config.refresh_token_expiration_days,
299 304 )
300 305 .await?;
301 306
302 - let refresh_token = Some(refresh_token_model.token);
307 + let refresh_token = Some(created_rt.plain_token);
303 308
304 309 // Build response
305 310 let response = LoginResponse {
@@ -351,6 +356,11 @@ pub async fn login(
351 356 .map_err(|e| AppError::Internal(format!("Failed to create cookie header: {}", e)))?,
352 357 );
353 358
359 + // SEC-M1: Regenerate session ID to prevent session fixation attacks
360 + if let Err(e) = _user_session.cycle_id().await {
361 + tracing::warn!("Failed to cycle session ID: {}", e);
362 + }
363 +
354 364 tracing::info!(
355 365 "User {} logged in successfully with JWT token (cookie)",
356 366 user.username
@@ -710,8 +720,14 @@ pub async fn logout(
710 720 /// ```
711 721 pub async fn refresh_token(
712 722 State(db): State<DatabaseConnection>,
723 + headers: HeaderMap,
713 724 Json(payload): Json<RefreshRequest>,
714 725 ) -> Result<Json<RefreshResponse>, AppError> {
726 + // SEC-C2: Per-IP rate limiting
727 + if let Err(_resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) {
728 + return Err(AppError::Validation("Too many requests. Please try again later.".to_string()));
729 + }
730 +
715 731 // Validate input
716 732 payload.validate().map_err(|e| {
717 733 tracing::warn!("Refresh request validation failed: {}", e);
@@ -765,7 +781,7 @@ pub async fn refresh_token(
765 781 JwtService::generate_token(&auth_config, user.id, &user.username, roles.clone(), permissions)?;
766 782
767 783 // Generate new refresh token (rotation)
768 - let new_refresh_token = RefreshTokenService::create_refresh_token(
784 + let new_created_rt = RefreshTokenService::create_refresh_token(
769 785 &db,
770 786 user.id,
771 787 auth_config.refresh_token_expiration_days,
@@ -773,7 +789,7 @@ pub async fn refresh_token(
773 789 .await?;
774 790
775 791 // Mark old token as replaced (completes rotation)
776 - RefreshTokenService::mark_as_replaced(&db, old_token.id, new_refresh_token.id).await?;
792 + RefreshTokenService::mark_as_replaced(&db, old_token.id, new_created_rt.model.id).await?;
777 793
778 794 tracing::info!(
779 795 "User {} refreshed tokens successfully (rotation complete)",
@@ -783,7 +799,7 @@ pub async fn refresh_token(
783 799 // Build response with new tokens
784 800 let response = RefreshResponse {
785 801 access_token,
786 - refresh_token: Some(new_refresh_token.token),
802 + refresh_token: Some(new_created_rt.plain_token),
787 803 token_type: "Bearer".to_string(),
788 804 expires_in: auth_config.jwt_expiration_minutes * 60,
789 805 };
@@ -838,8 +854,14 @@ mod tests {
838 854 /// ```
839 855 pub async fn forgot_password(
840 856 State(db): State<DatabaseConnection>,
857 + headers: HeaderMap,
841 858 Json(payload): Json<ForgotPasswordRequest>,
842 859 ) -> Result<impl IntoResponse, AppError> {
860 + // SEC-C2: Per-IP rate limiting
861 + if let Err(_resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) {
862 + return Err(AppError::Validation("Too many requests. Please try again later.".to_string()));
863 + }
864 +
843 865 // Validate email format
844 866 payload
845 867 .validate()
@@ -893,6 +915,9 @@ pub async fn reset_password(
893 915 .validate()
894 916 .map_err(|e| AppError::Validation(format!("Invalid request: {}", e)))?;
895 917
918 + // SEC-H1: Validate password complexity from DB rules
919 + crate::middleware::password_complexity::validate_password_complexity(&db, &payload.new_password).await?;
920 +
896 921 // Load email config
897 922 let email_config = EmailConfig::from_env()
898 923 .map_err(|e| AppError::Internal(format!("Email configuration error: {}", e)))?;
@@ -1103,9 +1128,15 @@ pub async fn resend_verification_by_username(
1103 1128 /// - Email verification is required
1104 1129 pub async fn register(
1105 1130 State(db): State<DatabaseConnection>,
1131 + headers: HeaderMap,
1106 1132 hooks: Option<Extension<Arc<dyn RustwardenHooks>>>,
1107 1133 Json(payload): Json<RegisterRequest>,
1108 1134 ) -> Result<Json<RegisterResponse>, AppError> {
1135 + // SEC-C2: Per-IP rate limiting
1136 + if let Err(_) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) {
1137 + return Err(AppError::Validation("Too many requests. Please try again later.".to_string()));
1138 + }
1139 +
1109 1140 tracing::info!(
1110 1141 "Registration request received for username: {}",
1111 1142 payload.username
@@ -1117,6 +1148,9 @@ pub async fn register(
1117 1148 AppError::Validation(format!("Invalid registration request: {}", e))
1118 1149 })?;
1119 1150
1151 + // SEC-H1: Validate password complexity from DB rules
1152 + crate::middleware::password_complexity::validate_password_complexity(&db, &payload.password).await?;
1153 +
1120 1154 let allow_registration =
1121 1155 crate::services::app_settings_service::AppSettingsService::get_bool(
1122 1156 &db,
M crates/rustwarden-core/src/auth/mod.rs
+2 -0
@@ -10,3 +10,5 @@ pub mod handlers;
10 10 pub mod jwt;
11 11 pub mod jwt_extractor;
12 12 pub mod session;
13 +#[cfg(feature = "oauth")]
14 +pub mod oauth_handlers;
A crates/rustwarden-core/src/auth/oauth_handlers.rs
+312 -0
@@ -0,0 +1,312 @@
1 +use axum::{
2 + extract::{Path, Query, State},
3 + http::{HeaderMap, StatusCode},
4 + response::{IntoResponse, Redirect, Response},
5 + Extension, Json,
6 +};
7 +use sea_orm::DatabaseConnection;
8 +use serde::Deserialize;
9 +use std::sync::Arc;
10 +use tower_sessions::Session;
11 +
12 +use crate::auth::jwt::JwtService;
13 +use crate::config::auth::AuthConfig;
14 +use crate::config::oauth::OAuthConfig;
15 +use crate::error::AppError;
16 +use crate::hooks::RustwardenHooks;
17 +use crate::services::oauth::providers::ProviderRegistry;
18 +use crate::services::oauth::OAuthService;
19 +use crate::services::refresh_token_service::RefreshTokenService;
20 +use crate::services::user_service::UserService;
21 +use crate::services::user_preferences_service::UserPreferencesService;
22 +
23 +const OAUTH_STATE_KEY: &str = "oauth_state";
24 +const OAUTH_NONCE_KEY: &str = "oauth_nonce";
25 +const OAUTH_PKCE_KEY: &str = "oauth_pkce_verifier";
26 +const OAUTH_PROVIDER_KEY: &str = "oauth_provider";
27 +
28 +#[derive(Deserialize)]
29 +pub struct OAuthCallbackQuery {
30 + pub code: Option<String>,
31 + pub state: Option<String>,
32 + pub error: Option<String>,
33 + pub error_description: Option<String>,
34 +}
35 +
36 +/// GET /api/v1/auth/oauth/:provider/redirect
37 +///
38 +/// Initiates the OAuth flow by redirecting the user to the provider's auth page.
39 +/// Stores CSRF state, nonce, and PKCE verifier in the session.
40 +pub async fn oauth_redirect(
41 + State(_db): State<DatabaseConnection>,
42 + session: Session,
43 + Extension(registry): Extension<Arc<ProviderRegistry>>,
44 + Path(provider_name): Path<String>,
45 +) -> Result<Response, AppError> {
46 + let oauth_config = OAuthConfig::from_env();
47 + if !oauth_config.enabled {
48 + return Err(AppError::Validation("OAuth is not enabled".to_string()));
49 + }
50 +
51 + let provider = registry
52 + .get(&provider_name)
53 + .ok_or_else(|| AppError::NotFound(format!("OAuth provider '{}' not found", provider_name)))?;
54 +
55 + let redirect_uri = oauth_config.callback_url(&provider_name);
56 + let auth_url = provider.authorization_url(&redirect_uri)?;
57 +
58 + // Store security params in session
59 + session.insert(OAUTH_STATE_KEY, &auth_url.csrf_state).await
60 + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
61 + session.insert(OAUTH_PROVIDER_KEY, &provider_name).await
62 + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
63 +
64 + if let Some(ref nonce) = auth_url.nonce {
65 + session.insert(OAUTH_NONCE_KEY, nonce).await
66 + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
67 + }
68 + if let Some(ref verifier) = auth_url.pkce_verifier {
69 + session.insert(OAUTH_PKCE_KEY, verifier).await
70 + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
71 + }
72 +
73 + tracing::info!("OAuth redirect to {} for provider {}", &auth_url.url[..50.min(auth_url.url.len())], provider_name);
74 + Ok(Redirect::temporary(&auth_url.url).into_response())
75 +}
76 +
77 +/// GET /api/v1/auth/oauth/:provider/callback
78 +///
79 +/// Handles the OAuth callback from the provider.
80 +/// Validates state, exchanges code for tokens, finds or creates user,
81 +/// generates JWT + refresh token, sets cookies, redirects to /home.
82 +pub async fn oauth_callback(
83 + State(db): State<DatabaseConnection>,
84 + headers: HeaderMap,
85 + session: Session,
86 + Extension(registry): Extension<Arc<ProviderRegistry>>,
87 + hooks: Option<Extension<Arc<dyn RustwardenHooks>>>,
88 + Path(provider_name): Path<String>,
89 + Query(params): Query<OAuthCallbackQuery>,
90 +) -> Result<Response, AppError> {
91 + // SEC-C2: Rate limiting
92 + if let Err(_) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) {
93 + return Err(AppError::Validation("Too many requests".to_string()));
94 + }
95 +
96 + // Check for error from provider
97 + if let Some(ref error) = params.error {
98 + tracing::warn!(
99 + "OAuth error from {}: {} - {}",
100 + provider_name,
101 + error,
102 + params.error_description.as_deref().unwrap_or("no details")
103 + );
104 + return Ok(Redirect::to("/login?error=oauth_denied").into_response());
105 + }
106 +
107 + let code = params.code.as_deref().ok_or_else(|| {
108 + AppError::Validation("Missing authorization code".to_string())
109 + })?;
110 +
111 + // Validate CSRF state
112 + let session_state: Option<String> = session.get(OAUTH_STATE_KEY).await
113 + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
114 + let session_provider: Option<String> = session.get(OAUTH_PROVIDER_KEY).await
115 + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
116 +
117 + let expected_state = session_state
118 + .ok_or_else(|| AppError::Validation("Missing OAuth state in session".to_string()))?;
119 + let received_state = params.state.as_deref()
120 + .ok_or_else(|| AppError::Validation("Missing state parameter".to_string()))?;
121 +
122 + // SEC: Constant-time comparison for state
123 + use subtle::ConstantTimeEq;
124 + let state_match: bool = expected_state.as_bytes().ct_eq(received_state.as_bytes()).into();
125 + if !state_match {
126 + tracing::warn!("OAuth state mismatch for provider {}", provider_name);
127 + return Err(AppError::Validation("OAuth state mismatch (possible CSRF attack)".to_string()));
128 + }
129 +
130 + if session_provider.as_deref() != Some(&provider_name) {
131 + return Err(AppError::Validation("Provider mismatch".to_string()));
132 + }
133 +
134 + // Retrieve nonce and PKCE verifier from session
135 + let nonce: Option<String> = session.get(OAUTH_NONCE_KEY).await.unwrap_or(None);
136 + let pkce_verifier: Option<String> = session.get(OAUTH_PKCE_KEY).await.unwrap_or(None);
137 +
138 + // Clean up session oauth data
139 + session.remove::<String>(OAUTH_STATE_KEY).await.ok();
140 + session.remove::<String>(OAUTH_NONCE_KEY).await.ok();
141 + session.remove::<String>(OAUTH_PKCE_KEY).await.ok();
142 + session.remove::<String>(OAUTH_PROVIDER_KEY).await.ok();
143 +
144 + let oauth_config = OAuthConfig::from_env();
145 + let redirect_uri = oauth_config.callback_url(&provider_name);
146 +
147 + let provider = registry
148 + .get(&provider_name)
149 + .ok_or_else(|| AppError::NotFound(format!("Provider '{}' not found", provider_name)))?;
150 +
151 + // Exchange code for user info
152 + let user_info = provider
153 + .exchange_code(
154 + code,
155 + &redirect_uri,
156 + pkce_verifier.as_deref(),
157 + nonce.as_deref(),
158 + )
159 + .await?;
160 +
161 + // Find or create user
162 + let (user, is_new_user) = OAuthService::find_or_create_user(&db, &provider_name, &user_info).await?;
163 +
164 + // Generate JWT
165 + let roles = UserService::get_user_roles(&db, user.id).await?;
166 + let permissions = crate::services::permission_service::PermissionService::get_permissions_for_user(&db, user.id).await?;
167 +
168 + let auth_config = AuthConfig::from_env()
169 + .map_err(|e| AppError::Config(format!("Auth config error: {}", e)))?;
170 +
171 + let access_token = JwtService::generate_token(
172 + &auth_config,
173 + user.id,
174 + &user.username,
175 + roles.clone(),
176 + permissions,
177 + )?;
178 +
179 + // Generate refresh token
180 + let _created_rt = RefreshTokenService::create_refresh_token(
181 + &db,
182 + user.id,
183 + auth_config.refresh_token_expiration_days,
184 + )
185 + .await?;
186 +
187 + // SEC-M1: Regenerate session ID
188 + if let Err(e) = session.cycle_id().await {
189 + tracing::warn!("Failed to cycle session ID: {}", e);
190 + }
191 +
192 + // Build cookies
193 + let expires_in_seconds = auth_config.jwt_expiration_minutes * 60;
194 + let same_site = match auth_config.cookie_same_site.to_lowercase().as_str() {
195 + "strict" => axum_extra::extract::cookie::SameSite::Strict,
196 + "lax" => axum_extra::extract::cookie::SameSite::Lax,
197 + "none" => axum_extra::extract::cookie::SameSite::None,
198 + _ => axum_extra::extract::cookie::SameSite::Lax,
199 + };
200 +
201 + let jwt_cookie = axum_extra::extract::cookie::Cookie::build(("jwt_token", access_token))
202 + .http_only(true)
203 + .same_site(same_site)
204 + .secure(auth_config.cookie_secure)
205 + .path("/")
206 + .max_age(time::Duration::seconds(expires_in_seconds));
207 +
208 + let mut response = Redirect::to("/home").into_response();
209 + if let Ok(hv) = jwt_cookie.to_string().parse() {
210 + response.headers_mut().insert(axum::http::header::SET_COOKIE, hv);
211 + }
212 +
213 + // Set theme/locale cookies from preferences
214 + if let Ok(Some(prefs)) = UserPreferencesService::get_preferences(&db, user.id).await {
215 + let theme_cookie = axum_extra::extract::cookie::Cookie::build(("theme", prefs.theme))
216 + .http_only(false)
217 + .same_site(axum_extra::extract::cookie::SameSite::Lax)
218 + .path("/")
219 + .max_age(time::Duration::seconds(31536000));
220 + if let Ok(hv) = theme_cookie.to_string().parse() {
221 + response.headers_mut().append(axum::http::header::SET_COOKIE, hv);
222 + }
223 +
224 + let locale_cookie = axum_extra::extract::cookie::Cookie::build(("locale", prefs.language))
225 + .http_only(false)
226 + .same_site(axum_extra::extract::cookie::SameSite::Lax)
227 + .path("/")
228 + .max_age(time::Duration::seconds(31536000));
229 + if let Ok(hv) = locale_cookie.to_string().parse() {
230 + response.headers_mut().append(axum::http::header::SET_COOKIE, hv);
231 + }
232 + }
233 +
234 + tracing::info!(
235 + "User {} logged in via OAuth ({}) [new_user={}]",
236 + user.username,
237 + provider_name,
238 + is_new_user
239 + );
240 +
241 + // Fire hooks
242 + if let Some(Extension(ref hooks)) = hooks {
243 + if let Err(e) = hooks.on_user_login(&db, user.id, &user.username).await {
244 + tracing::warn!("on_user_login hook error: {}", e);
245 + }
246 + if let Err(e) = hooks.on_oauth_login(&db, user.id, &provider_name, is_new_user).await {
247 + tracing::warn!("on_oauth_login hook error: {}", e);
248 + }
249 + }
250 +
251 + Ok(response)
252 +}
253 +
254 +/// GET /api/v1/auth/oauth/providers
255 +///
256 +/// Returns the list of enabled OAuth providers (for the login page to display buttons).
257 +pub async fn list_providers(
258 + Extension(registry): Extension<Arc<ProviderRegistry>>,
259 +) -> Json<Vec<serde_json::Value>> {
260 + let oauth_config = OAuthConfig::from_env();
261 + if !oauth_config.enabled {
262 + return Json(vec![]);
263 + }
264 +
265 + let providers: Vec<serde_json::Value> = registry
266 + .list()
267 + .into_iter()
268 + .map(|(name, display)| {
269 + serde_json::json!({
270 + "name": name,
271 + "display_name": display
272 + })
273 + })
274 + .collect();
275 +
276 + Json(providers)
277 +}
278 +
279 +/// GET /api/v1/auth/oauth/accounts
280 +///
281 +/// Returns the list of OAuth accounts linked to the authenticated user.
282 +pub async fn list_user_oauth_accounts(
283 + State(db): State<DatabaseConnection>,
284 + auth_user: crate::auth::extractors::AuthUser,
285 +) -> Result<Json<Vec<serde_json::Value>>, AppError> {
286 + let accounts = OAuthService::find_user_oauth_accounts(&db, auth_user.user_id).await?;
287 + let result: Vec<serde_json::Value> = accounts
288 + .into_iter()
289 + .map(|a| {
290 + serde_json::json!({
291 + "id": a.id,
292 + "provider": a.provider,
293 + "email": a.email,
294 + "display_name": a.display_name,
295 + "created_at": a.created_at.to_string(),
296 + })
297 + })
298 + .collect();
299 + Ok(Json(result))
300 +}
301 +
302 +/// DELETE /api/v1/auth/oauth/accounts/:id
303 +///
304 +/// Unlinks an OAuth account from the authenticated user.
305 +pub async fn unlink_oauth_account(
306 + State(db): State<DatabaseConnection>,
307 + auth_user: crate::auth::extractors::AuthUser,
308 + Path(account_id): Path<uuid::Uuid>,
309 +) -> Result<StatusCode, AppError> {
310 + OAuthService::unlink_account_by_id(&db, auth_user.user_id, account_id).await?;
311 + Ok(StatusCode::NO_CONTENT)
312 +}
M crates/rustwarden-core/src/config/auth.rs
+7 -1
@@ -101,11 +101,17 @@ impl AuthConfig {
101 101 .and_then(|v| v.parse::<u32>().ok())
102 102 .unwrap_or(100);
103 103
104 - let cookie_secure = env::var("COOKIE_SECURE")
104 + // SEC-M3: Force COOKIE_SECURE=true unless APP_DEBUG=true
105 + let app_debug = env::var("APP_DEBUG")
105 106 .ok()
106 107 .and_then(|v| v.parse::<bool>().ok())
107 108 .unwrap_or(false);
108 109
110 + let cookie_secure = env::var("COOKIE_SECURE")
111 + .ok()
112 + .and_then(|v| v.parse::<bool>().ok())
113 + .unwrap_or(!app_debug); // Secure by default in production
114 +
109 115 let cookie_same_site = env::var("COOKIE_SAME_SITE").unwrap_or_else(|_| "Lax".to_string());
110 116
111 117 tracing::info!(
M crates/rustwarden-core/src/config/mod.rs
+2 -0
@@ -8,3 +8,5 @@ pub mod database;
8 8 pub mod email;
9 9 pub mod server;
10 10 pub mod tasks;
11 +#[cfg(feature = "oauth")]
12 +pub mod oauth;
A crates/rustwarden-core/src/config/oauth.rs
+204 -0
@@ -0,0 +1,204 @@
1 +use std::env;
2 +
3 +use sea_orm::DatabaseConnection;
4 +
5 +use crate::services::app_settings_service::AppSettingsService;
6 +use crate::services::crypto_service::CryptoService;
7 +
8 +/// Configuration for a single OAuth provider.
9 +#[derive(Debug, Clone)]
10 +pub struct OAuthProviderConfig {
11 + pub name: String,
12 + pub client_id: String,
13 + pub client_secret: String,
14 + pub tenant: Option<String>,
15 + pub enabled: bool,
16 +}
17 +
18 +/// Global OAuth configuration.
19 +#[derive(Debug, Clone)]
20 +pub struct OAuthConfig {
21 + pub enabled: bool,
22 + pub redirect_base_url: String,
23 + pub auto_register: bool,
24 + pub link_existing_account: bool,
25 + pub providers: Vec<OAuthProviderConfig>,
26 +}
27 +
28 +impl OAuthConfig {
29 + pub const KNOWN_PROVIDERS: &'static [&'static str] = &["google", "github", "discord", "microsoft"];
30 +
31 + /// Load OAuth configuration from the database, falling back to environment
32 + /// variables for any value not set in the DB.
33 + pub async fn load(db: &DatabaseConnection) -> Self {
34 + let db_val = |key: &str| {
35 + let db = db.clone();
36 + let key = key.to_string();
37 + async move {
38 + AppSettingsService::get_setting(&db, &key)
39 + .await
40 + .ok()
41 + .flatten()
42 + .map(|m| m.value)
43 + .filter(|v| !v.is_empty())
44 + }
45 + };
46 +
47 + let enabled = match db_val("oauth_enabled").await {
48 + Some(v) => v == "true",
49 + None => env::var("OAUTH_ENABLED")
50 + .ok()
51 + .and_then(|v| v.parse::<bool>().ok())
52 + .unwrap_or(false),
53 + };
54 +
55 + let redirect_base_url = match db_val("oauth_redirect_base_url").await {
56 + Some(v) => v,
57 + None => env::var("OAUTH_REDIRECT_BASE_URL")
58 + .unwrap_or_else(|_| "http://localhost:3000".to_string()),
59 + };
60 +
61 + let auto_register = match db_val("oauth_auto_register").await {
62 + Some(v) => v == "true",
63 + None => true,
64 + };
65 +
66 + let link_existing_account = match db_val("oauth_link_existing_account").await {
67 + Some(v) => v == "true",
68 + None => true,
69 + };
70 +
71 + let mut providers = Vec::new();
72 +
73 + for &name in Self::KNOWN_PROVIDERS {
74 + let upper = name.to_uppercase();
75 +
76 + let provider_enabled = match db_val(&format!("oauth_{}_enabled", name)).await {
77 + Some(v) => v == "true",
78 + None => env::var(format!("OAUTH_{}_ENABLED", upper))
79 + .ok()
80 + .and_then(|v| v.parse::<bool>().ok())
81 + .unwrap_or(true),
82 + };
83 +
84 + let client_id = match db_val(&format!("oauth_{}_client_id", name)).await {
85 + Some(v) => Some(v),
86 + None => env::var(format!("OAUTH_{}_CLIENT_ID", upper)).ok(),
87 + };
88 +
89 + let client_secret_raw = match db_val(&format!("oauth_{}_client_secret", name)).await {
90 + Some(v) => Some(v),
91 + None => env::var(format!("OAUTH_{}_CLIENT_SECRET", upper)).ok(),
92 + };
93 +
94 + let client_secret = client_secret_raw.and_then(|raw| {
95 + if CryptoService::is_encrypted(&raw) {
96 + match CryptoService::decrypt(&raw) {
97 + Ok(plain) => Some(plain),
98 + Err(e) => {
99 + tracing::error!(
100 + provider = name,
101 + "Failed to decrypt OAuth client secret: {}",
102 + e
103 + );
104 + None
105 + }
106 + }
107 + } else {
108 + Some(raw)
109 + }
110 + });
111 +
112 + let tenant = match db_val(&format!("oauth_{}_tenant", name)).await {
113 + Some(v) => Some(v),
114 + None => env::var(format!("OAUTH_{}_TENANT", upper)).ok(),
115 + };
116 +
117 + if let (Some(id), Some(secret)) = (client_id, client_secret) {
118 + providers.push(OAuthProviderConfig {
119 + name: name.to_string(),
120 + client_id: id,
121 + client_secret: secret,
122 + tenant,
123 + enabled: provider_enabled,
124 + });
125 + }
126 + }
127 +
128 + if enabled && providers.is_empty() {
129 + tracing::warn!("OAuth is enabled but no providers are configured (neither DB nor env vars)");
130 + }
131 +
132 + Self {
133 + enabled,
134 + redirect_base_url,
135 + auto_register,
136 + link_existing_account,
137 + providers,
138 + }
139 + }
140 +
141 + /// Load from environment variables only (no DB access).
142 + /// Kept as fallback when DB is not available.
143 + pub fn from_env() -> Self {
144 + let enabled = env::var("OAUTH_ENABLED")
145 + .ok()
146 + .and_then(|v| v.parse::<bool>().ok())
147 + .unwrap_or(false);
148 +
149 + let redirect_base_url = env::var("OAUTH_REDIRECT_BASE_URL")
150 + .unwrap_or_else(|_| "http://localhost:3000".to_string());
151 +
152 + let mut providers = Vec::new();
153 +
154 + for &name in Self::KNOWN_PROVIDERS {
155 + let upper = name.to_uppercase();
156 + let client_id = env::var(format!("OAUTH_{}_CLIENT_ID", upper));
157 + let client_secret = env::var(format!("OAUTH_{}_CLIENT_SECRET", upper));
158 +
159 + if let (Ok(id), Ok(secret)) = (client_id, client_secret) {
160 + let provider_enabled = env::var(format!("OAUTH_{}_ENABLED", upper))
161 + .ok()
162 + .and_then(|v| v.parse::<bool>().ok())
163 + .unwrap_or(true);
164 +
165 + let tenant = env::var(format!("OAUTH_{}_TENANT", upper)).ok();
166 +
167 + providers.push(OAuthProviderConfig {
168 + name: name.to_string(),
169 + client_id: id,
170 + client_secret: secret,
171 + tenant,
172 + enabled: provider_enabled,
173 + });
174 + }
175 + }
176 +
177 + if enabled && providers.is_empty() {
178 + tracing::warn!("OAUTH_ENABLED=true but no providers are configured");
179 + }
180 +
181 + Self {
182 + enabled,
183 + redirect_base_url,
184 + auto_register: true,
185 + link_existing_account: true,
186 + providers,
187 + }
188 + }
189 +
190 + pub fn enabled_providers(&self) -> Vec<&OAuthProviderConfig> {
191 + self.providers.iter().filter(|p| p.enabled).collect()
192 + }
193 +
194 + pub fn get_provider(&self, name: &str) -> Option<&OAuthProviderConfig> {
195 + self.providers.iter().find(|p| p.name == name && p.enabled)
196 + }
197 +
198 + pub fn callback_url(&self, provider_name: &str) -> String {
199 + format!(
200 + "{}/api/v1/auth/oauth/{}/callback",
201 + self.redirect_base_url, provider_name
202 + )
203 + }
204 +}
M crates/rustwarden-core/src/hooks.rs
+11 -0
@@ -110,6 +110,17 @@ pub trait RustwardenHooks: Send + Sync {
110 110 ) -> anyhow::Result<()> {
111 111 Ok(())
112 112 }
113 +
114 + /// Called after a successful OAuth login (new or existing user).
115 + async fn on_oauth_login(
116 + &self,
117 + _db: &DatabaseConnection,
118 + _user_id: Uuid,
119 + _provider: &str,
120 + _is_new_user: bool,
121 + ) -> anyhow::Result<()> {
122 + Ok(())
123 + }
113 124 }
114 125
115 126 /// Default no-op implementation of hooks.
M crates/rustwarden-core/src/middleware/admin_service.rs
+5 -5
@@ -1,5 +1,4 @@
1 1 /// Admin Service Middleware
2 -/// Feature 009: Admin & Settings
3 2 ///
4 3 /// Tower Service wrapper that applies admin_required middleware to /admin/* routes.
5 4 /// This works around Axum 0.8 Sync limitations with from_fn_with_state.
@@ -15,6 +14,8 @@ use std::{
15 14 };
16 15 use tower::Service;
17 16
17 +use super::auth_service;
18 +
18 19 /// Service wrapper that applies admin_required middleware to /admin/* routes
19 20 #[derive(Clone)]
20 21 pub struct AdminService<S> {
@@ -69,7 +70,7 @@ where
69 70 tracing::warn!(
70 71 "AdminService: No JWT token found, redirecting to login"
71 72 );
72 - let redirect_url = format!("/login?redirect={}", uri);
73 + let redirect_url = auth_service::sanitize_redirect(uri.path());
73 74 return Ok(axum::response::Redirect::to(&redirect_url).into_response());
74 75 }
75 76 };
@@ -79,12 +80,11 @@ where
79 80 use crate::config::auth::AuthConfig;
80 81 use crate::services::jwt_blacklist_service::JwtBlacklistService;
81 82
82 - // Check if token is blacklisted
83 83 if JwtBlacklistService::is_blacklisted(&db, &jwt_token)
84 84 .await
85 85 .is_ok_and(|b| b)
86 86 {
87 - let redirect_url = format!("/login?redirect={}", uri);
87 + let redirect_url = auth_service::sanitize_redirect(uri.path());
88 88 return Ok(axum::response::Redirect::to(&redirect_url).into_response());
89 89 }
90 90
@@ -103,7 +103,7 @@ where
103 103 let claims = match JwtService::validate_token(&auth_config, &jwt_token) {
104 104 Ok(claims) => claims,
105 105 Err(_) => {
106 - let redirect_url = format!("/login?redirect={}", uri);
106 + let redirect_url = auth_service::sanitize_redirect(uri.path());
107 107 return Ok(axum::response::Redirect::to(&redirect_url).into_response());
108 108 }
109 109 };
M crates/rustwarden-core/src/middleware/auth_service.rs
+14 -3
@@ -14,6 +14,17 @@ use std::{
14 14 };
15 15 use tower::Service;
16 16
17 +/// SEC-L2: Sanitize redirect URL to prevent open redirect attacks.
18 +/// Only allows relative paths starting with '/' (not '//').
19 +pub(crate) fn sanitize_redirect(uri: &str) -> String {
20 + let path = uri.to_string();
21 + if path.starts_with('/') && !path.starts_with("//") {
22 + format!("/login?redirect={}", path)
23 + } else {
24 + "/login".to_string()
25 + }
26 +}
27 +
17 28 #[derive(Clone)]
18 29 pub struct AuthService<S> {
19 30 inner: S,
@@ -68,7 +79,7 @@ where
68 79 }
69 80 Err(_) => {
70 81 tracing::warn!("AuthService: No JWT token found, redirecting to login");
71 - let redirect_url = format!("/login?redirect={}", uri);
82 + let redirect_url = sanitize_redirect(uri.path());
72 83 return Ok(axum::response::Redirect::to(&redirect_url).into_response());
73 84 }
74 85 };
@@ -82,7 +93,7 @@ where
82 93 .is_ok_and(|b| b)
83 94 {
84 95 tracing::warn!("AuthService: JWT token is blacklisted, redirecting to login");
85 - let redirect_url = format!("/login?redirect={}", uri);
96 + let redirect_url = sanitize_redirect(uri.path());
86 97 return Ok(axum::response::Redirect::to(&redirect_url).into_response());
87 98 }
88 99
@@ -101,7 +112,7 @@ where
101 112 Ok(claims) => claims,
102 113 Err(_) => {
103 114 tracing::warn!("AuthService: Invalid JWT token, redirecting to login");
104 - let redirect_url = format!("/login?redirect={}", uri);
115 + let redirect_url = sanitize_redirect(uri.path());
105 116 return Ok(axum::response::Redirect::to(&redirect_url).into_response());
106 117 }
107 118 };
M crates/rustwarden-core/src/middleware/csrf.rs
+66 -45
@@ -1,8 +1,9 @@
1 1 /// CSRF Protection Middleware
2 -/// Feature 009: Admin & Settings
3 2 ///
4 3 /// Middleware to protect against Cross-Site Request Forgery (CSRF) attacks.
5 4 /// Uses session-based CSRF tokens for state-changing operations (POST/PUT/DELETE).
5 +/// SEC-H4: Uses constant-time comparison to prevent timing attacks.
6 +/// SEC-C3: Blocks requests without CSRF token (except API Bearer routes).
6 7 use axum::{
7 8 extract::{Request, State},
8 9 http::{Method, StatusCode, Uri},
@@ -11,6 +12,7 @@ use axum::{
11 12 };
12 13 use rand::RngCore;
13 14 use sea_orm::DatabaseConnection;
15 +use subtle::ConstantTimeEq;
14 16 use tower_sessions::Session;
15 17
16 18 use crate::error::AppError;
@@ -20,7 +22,7 @@ const CSRF_TOKEN_KEY: &str = "csrf_token";
20 22
21 23 /// Generate a random CSRF token
22 24 fn generate_csrf_token() -> String {
23 - let mut rng = rand::thread_rng();
25 + let mut rng = rand::rng();
24 26 let mut bytes = [0u8; 32];
25 27 rng.fill_bytes(&mut bytes);
26 28 hex::encode(bytes)
@@ -100,6 +102,23 @@ fn extract_csrf_token_from_request(request: &Request) -> Option<String> {
100 102 ///
101 103 /// * `200 OK` if valid or safe method (passes to handler)
102 104 /// * `403 Forbidden` if CSRF token missing or invalid
105 +/// SEC-C3: Check if the request uses Bearer authentication (API client)
106 +/// API clients using Authorization: Bearer are not vulnerable to CSRF
107 +/// because the token is not sent automatically by the browser.
108 +fn is_bearer_api_request(request: &Request) -> bool {
109 + request
110 + .headers()
111 + .get(axum::http::header::AUTHORIZATION)
112 + .and_then(|v| v.to_str().ok())
113 + .map(|v| v.to_lowercase().starts_with("bearer "))
114 + .unwrap_or(false)
115 +}
116 +
117 +/// SEC-H4: Constant-time comparison of CSRF tokens
118 +fn csrf_tokens_match(a: &str, b: &str) -> bool {
119 + a.as_bytes().ct_eq(b.as_bytes()).into()
120 +}
121 +
103 122 pub async fn csrf_protection(
104 123 State(_db): State<DatabaseConnection>,
105 124 session: Session,
@@ -107,35 +126,35 @@ pub async fn csrf_protection(
107 126 request: Request,
108 127 next: Next,
109 128 ) -> Result<Response, AppError> {
110 - let method = request.method();
129 + let method = request.method().clone();
111 130
112 - // Allow safe methods without CSRF token
113 131 if matches!(
114 - *method,
132 + method,
115 133 Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE
116 134 ) {
117 135 return Ok(next.run(request).await);
118 136 }
119 137
120 - // For state-changing methods, require CSRF token
138 + // SEC-C3: Skip CSRF for API routes using Bearer token authentication
139 + if is_bearer_api_request(&request) {
140 + tracing::debug!("CSRF skip: Bearer API request for {} {}", method, uri);
141 + return Ok(next.run(request).await);
142 + }
143 +
121 144 tracing::debug!("CSRF protection: validating token for {} {}", method, uri);
122 145
123 - // Get CSRF token from session
124 146 let session_token = get_or_create_csrf_token(&session).await?;
125 -
126 - // Extract token from request
127 147 let request_token = extract_csrf_token_from_request(&request);
128 148
129 149 match request_token {
130 150 Some(token) => {
131 - // Validate token
132 - if token != session_token {
151 + if !csrf_tokens_match(&token, &session_token) {
133 152 tracing::warn!(
134 - "CSRF token mismatch for {} {} (expected: {}, got: {})",
153 + "CSRF token mismatch for {} {} (expected: {}..., got: {}...)",
135 154 method,
136 155 uri,
137 - &session_token[..8],
138 - &token[..8]
156 + &session_token[..8.min(session_token.len())],
157 + &token[..8.min(token.len())]
139 158 );
140 159 return Ok((StatusCode::FORBIDDEN, "Invalid CSRF token").into_response());
141 160 }
@@ -144,15 +163,20 @@ pub async fn csrf_protection(
144 163 Ok(next.run(request).await)
145 164 }
146 165 None => {
147 - // For form submissions, token might be in body
148 - // We'll validate it in the handler after parsing the form
149 - // For now, allow the request to proceed (handler will validate)
150 - tracing::debug!(
151 - "CSRF token not found in headers for {} {}, will validate in handler",
152 - method,
153 - uri
154 - );
155 - Ok(next.run(request).await)
166 + // SEC-C3: Block state-changing requests without CSRF token
167 + // Exception: /api/ routes may pass CSRF via form body (validated in handler)
168 + let path = uri.path();
169 + if path.starts_with("/api/") {
170 + tracing::debug!(
171 + "CSRF token not in headers for API {} {}, will validate in handler",
172 + method,
173 + uri
174 + );
175 + Ok(next.run(request).await)
176 + } else {
177 + tracing::warn!("CSRF token missing for {} {}", method, uri);
178 + Ok((StatusCode::FORBIDDEN, "CSRF token required").into_response())
179 + }
156 180 }
157 181 }
158 182 }
@@ -165,28 +189,25 @@ pub async fn csrf_protection_impl(
165 189 request: Request,
166 190 next: Next,
167 191 ) -> Result<Response, AppError> {
168 - // For now, we'll use a simplified version
169 - // The full implementation will be integrated in routes
170 - let method = request.method();
192 + let method = request.method().clone();
171 193
172 - // Allow safe methods without CSRF token
173 194 if matches!(
174 - *method,
195 + method,
175 196 Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE
176 197 ) {
177 198 return Ok(next.run(request).await);
178 199 }
179 200
180 - // Get CSRF token from session
181 - let session_token = get_or_create_csrf_token(&session).await?;
201 + if is_bearer_api_request(&request) {
202 + return Ok(next.run(request).await);
203 + }
182 204
183 - // Extract token from request
205 + let session_token = get_or_create_csrf_token(&session).await?;
184 206 let request_token = extract_csrf_token_from_request(&request);
185 207
186 208 match request_token {
187 209 Some(token) => {
188 - // Validate token
189 - if token != session_token {
210 + if !csrf_tokens_match(&token, &session_token) {
190 211 tracing::warn!("CSRF token mismatch for {} {}", method, uri);
191 212 return Ok((StatusCode::FORBIDDEN, "Invalid CSRF token").into_response());
192 213 }
@@ -195,14 +216,13 @@ pub async fn csrf_protection_impl(
195 216 Ok(next.run(request).await)
196 217 }
197 218 None => {
198 - // For form submissions, token might be in body
199 - // We'll validate it in the handler after parsing the form
200 - tracing::debug!(
201 - "CSRF token not found in headers for {} {}, will validate in handler",
202 - method,
203 - uri
204 - );
205 - Ok(next.run(request).await)
219 + let path = uri.path();
220 + if path.starts_with("/api/") {
221 + Ok(next.run(request).await)
222 + } else {
223 + tracing::warn!("CSRF token missing for {} {}", method, uri);
224 + Ok((StatusCode::FORBIDDEN, "CSRF token required").into_response())
225 + }
206 226 }
207 227 }
208 228 }
@@ -220,11 +240,12 @@ pub async fn csrf_protection_impl(
220 240 pub async fn validate_csrf_token(session: &Session, form_token: &str) -> Result<(), AppError> {
221 241 let session_token = get_or_create_csrf_token(session).await?;
222 242
223 - if form_token != session_token {
243 + // SEC-H4: Constant-time comparison to prevent timing attacks
244 + if !csrf_tokens_match(form_token, &session_token) {
224 245 tracing::warn!(
225 - "CSRF token mismatch (expected: {}, got: {})",
226 - &session_token[..8],
227 - &form_token[..8]
246 + "CSRF token mismatch (expected: {}..., got: {}...)",
247 + &session_token[..8.min(session_token.len())],
248 + &form_token[..8.min(form_token.len())]
228 249 );
229 250 return Err(AppError::Forbidden);
230 251 }
M crates/rustwarden-core/src/middleware/rate_limit.rs
+95 -0
@@ -300,6 +300,90 @@ pub fn check_public_share_rate_limit_from_headers(
300 300 }
301 301 }
302 302
303 +/// SEC-C2: Per-IP rate limiter for authentication endpoints
304 +/// Protects login, register, refresh, and forgot-password from brute-force attacks.
305 +#[derive(Clone)]
306 +pub struct AuthRateLimiter {
307 + requests: Arc<Mutex<HashMap<String, (u32, Instant)>>>,
308 + max_requests: u32,
309 + window_seconds: u64,
310 +}
311 +
312 +impl AuthRateLimiter {
313 + pub fn new(max_requests: u32, window_seconds: u64) -> Self {
314 + Self {
315 + requests: Arc::new(Mutex::new(HashMap::new())),
316 + max_requests,
317 + window_seconds,
318 + }
319 + }
320 +
321 + pub fn check_rate_limit(&self, ip: &str) -> Result<(), StatusCode> {
322 + let mut requests = self
323 + .requests
324 + .lock()
325 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
326 +
327 + let now = Instant::now();
328 + let window_duration = Duration::from_secs(self.window_seconds);
329 +
330 + requests.retain(|_, (_, start)| now.duration_since(*start) < window_duration);
331 +
332 + let (count, start) = requests.entry(ip.to_string()).or_insert_with(|| (0, now));
333 +
334 + if now.duration_since(*start) >= window_duration {
335 + *count = 0;
336 + *start = now;
337 + }
338 +
339 + *count += 1;
340 + if *count > self.max_requests {
341 + tracing::warn!(
342 + ip = %ip,
343 + count = %count,
344 + max = %self.max_requests,
345 + "Auth rate limit exceeded"
346 + );
347 + return Err(StatusCode::TOO_MANY_REQUESTS);
348 + }
349 +
350 + Ok(())
351 + }
352 +}
353 +
354 +static AUTH_RATE_LIMITER: std::sync::OnceLock<AuthRateLimiter> = std::sync::OnceLock::new();
355 +
356 +/// Get or initialize the global auth rate limiter.
357 +/// Default: 10 requests per minute per IP.
358 +pub fn get_auth_rate_limiter() -> &'static AuthRateLimiter {
359 + AUTH_RATE_LIMITER.get_or_init(|| {
360 + let max = std::env::var("RATE_LIMIT_AUTH_PER_MINUTE")
361 + .ok()
362 + .and_then(|v| v.parse().ok())
363 + .unwrap_or(10);
364 + AuthRateLimiter::new(max, 60)
365 + })
366 +}
367 +
368 +/// Check auth rate limit for a request and return an error response if exceeded.
369 +pub fn check_auth_rate_limit(
370 + headers: &axum::http::HeaderMap,
371 +) -> Result<(), axum::response::Response> {
372 + let ip = extract_client_ip_from_headers(headers);
373 + let limiter = get_auth_rate_limiter();
374 +
375 + match limiter.check_rate_limit(&ip) {
376 + Ok(()) => Ok(()),
377 + Err(status) => Err((
378 + status,
379 + axum::Json(serde_json::json!({
380 + "error": "Too many requests. Please try again later."
381 + })),
382 + )
383 + .into_response()),
384 + }
385 +}
386 +
303 387 #[cfg(test)]
304 388 mod tests {
305 389 use super::*;
@@ -310,4 +394,15 @@ mod tests {
310 394 let _user_mgmt = AdminRateLimitConfig::user_management_limit();
311 395 let _reset = AdminRateLimitConfig::reset_password_limit();
312 396 }
397 +
398 + #[test]
399 + fn test_auth_rate_limiter() {
400 + let limiter = AuthRateLimiter::new(3, 60);
401 + assert!(limiter.check_rate_limit("127.0.0.1").is_ok());
402 + assert!(limiter.check_rate_limit("127.0.0.1").is_ok());
403 + assert!(limiter.check_rate_limit("127.0.0.1").is_ok());
404 + assert!(limiter.check_rate_limit("127.0.0.1").is_err());
405 + // Different IP should still be allowed
406 + assert!(limiter.check_rate_limit("192.168.1.1").is_ok());
407 + }
313 408 }
M crates/rustwarden-core/src/models/mod.rs
+4 -0
@@ -4,6 +4,8 @@
4 4
5 5 pub mod app_settings;
6 6 pub mod audit_log;
7 +#[cfg(feature = "oauth")]
8 +pub mod oauth_account;
7 9 pub mod email_delivery_status;
8 10 pub mod email_queue;
9 11 pub mod email_validation_token;
@@ -37,3 +39,5 @@ pub use role_permission::Entity as RolePermission;
37 39 pub use user::Entity as User;
38 40 pub use user_preferences::Entity as UserPreferences;
39 41 pub use user_role::Entity as UserRole;
42 +#[cfg(feature = "oauth")]
43 +pub use oauth_account::Entity as OauthAccount;
A crates/rustwarden-core/src/models/oauth_account.rs
+37 -0
@@ -0,0 +1,37 @@
1 +use chrono::{DateTime, Utc};
2 +use sea_orm::entity::prelude::*;
3 +use serde::{Deserialize, Serialize};
4 +use uuid::Uuid;
5 +
6 +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
7 +#[sea_orm(table_name = "oauth_accounts")]
8 +pub struct Model {
9 + #[sea_orm(primary_key)]
10 + pub id: Uuid,
11 + pub user_id: Uuid,
12 + pub provider: String,
13 + pub provider_user_id: String,
14 + pub email: Option<String>,
15 + pub display_name: Option<String>,
16 + pub avatar_url: Option<String>,
17 + pub created_at: DateTime<Utc>,
18 + pub updated_at: DateTime<Utc>,
19 +}
20 +
21 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
22 +pub enum Relation {
23 + #[sea_orm(
24 + belongs_to = "super::user::Entity",
25 + from = "Column::UserId",
26 + to = "super::user::Column::Id"
27 + )]
28 + User,
29 +}
30 +
31 +impl Related<super::user::Entity> for Entity {
32 + fn to() -> RelationDef {
33 + Relation::User.def()
34 + }
35 +}
36 +
37 +impl ActiveModelBehavior for ActiveModel {}
M crates/rustwarden-core/src/services/app_settings_service.rs
+85 -0
@@ -294,6 +294,91 @@ impl AppSettingsService {
294 294 r#"["create","update","delete","reset_password"]"#,
295 295 Some("Actions to log in audit_logs table (JSON array)"),
296 296 ),
297 + (
298 + "oauth_enabled",
299 + "false",
300 + Some("Enable OAuth/OpenID Connect authentication"),
301 + ),
302 + (
303 + "oauth_google_enabled",
304 + "false",
305 + Some("Enable Google OAuth provider"),
306 + ),
307 + (
308 + "oauth_github_enabled",
309 + "false",
310 + Some("Enable GitHub OAuth provider"),
311 + ),
312 + (
313 + "oauth_discord_enabled",
314 + "false",
315 + Some("Enable Discord OAuth provider"),
316 + ),
317 + (
318 + "oauth_microsoft_enabled",
319 + "false",
320 + Some("Enable Microsoft OAuth provider"),
321 + ),
322 + (
323 + "oauth_redirect_base_url",
324 + "",
325 + Some("Base URL for OAuth redirect callbacks (fallback to OAUTH_REDIRECT_BASE_URL env var)"),
326 + ),
327 + (
328 + "oauth_auto_register",
329 + "true",
330 + Some("Auto-create user account on first OAuth login if email not found"),
331 + ),
332 + (
333 + "oauth_link_existing_account",
334 + "true",
335 + Some("Auto-link OAuth account to existing user with same verified email"),
336 + ),
337 + (
338 + "oauth_google_client_id",
339 + "",
340 + Some("Google OAuth Client ID"),
341 + ),
342 + (
343 + "oauth_google_client_secret",
344 + "",
345 + Some("Google OAuth Client Secret (encrypted)"),
346 + ),
347 + (
348 + "oauth_github_client_id",
349 + "",
350 + Some("GitHub OAuth Client ID"),
351 + ),
352 + (
353 + "oauth_github_client_secret",
354 + "",
355 + Some("GitHub OAuth Client Secret (encrypted)"),
356 + ),
357 + (
358 + "oauth_discord_client_id",
359 + "",
360 + Some("Discord OAuth Client ID"),
361 + ),
362 + (
363 + "oauth_discord_client_secret",
364 + "",
365 + Some("Discord OAuth Client Secret (encrypted)"),
366 + ),
367 + (
368 + "oauth_microsoft_client_id",
369 + "",
370 + Some("Microsoft OAuth Client ID"),
371 + ),
372 + (
373 + "oauth_microsoft_client_secret",
374 + "",
375 + Some("Microsoft OAuth Client Secret (encrypted)"),
376 + ),
377 + (
378 + "oauth_microsoft_tenant",
379 + "",
380 + Some("Microsoft Azure AD Tenant ID"),
381 + ),
297 382 ];
298 383
299 384 // Check if settings already exist before creating
A crates/rustwarden-core/src/services/crypto_service.rs
+122 -0
@@ -0,0 +1,122 @@
1 +use crate::error::AppError;
2 +use aes_gcm::{
3 + aead::{Aead, KeyInit, OsRng},
4 + Aes256Gcm, AeadCore, Nonce,
5 +};
6 +use base64::{engine::general_purpose::STANDARD as B64, Engine};
7 +use std::env;
8 +
9 +const ENCRYPTED_PREFIX: &str = "enc:";
10 +
11 +pub struct CryptoService;
12 +
13 +impl CryptoService {
14 + fn get_key() -> Result<[u8; 32], AppError> {
15 + let key_hex = env::var("OAUTH_ENCRYPTION_KEY").map_err(|_| {
16 + AppError::Internal(
17 + "OAUTH_ENCRYPTION_KEY is not set. A 64-character hex key is required to store OAuth secrets.".to_string(),
18 + )
19 + })?;
20 +
21 + let bytes = hex::decode(key_hex.trim()).map_err(|e| {
22 + AppError::Internal(format!(
23 + "OAUTH_ENCRYPTION_KEY is not valid hex: {}",
24 + e
25 + ))
26 + })?;
27 +
28 + if bytes.len() != 32 {
29 + return Err(AppError::Internal(format!(
30 + "OAUTH_ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes), got {} bytes",
31 + bytes.len()
32 + )));
33 + }
34 +
35 + let mut key = [0u8; 32];
36 + key.copy_from_slice(&bytes);
37 + Ok(key)
38 + }
39 +
40 + /// Encrypt a plaintext string. Returns `enc:<base64(nonce || ciphertext)>`.
41 + pub fn encrypt(plaintext: &str) -> Result<String, AppError> {
42 + let key_bytes = Self::get_key()?;
43 + let cipher = Aes256Gcm::new_from_slice(&key_bytes)
44 + .map_err(|e| AppError::Internal(format!("Failed to create cipher: {}", e)))?;
45 +
46 + let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
47 + let ciphertext = cipher
48 + .encrypt(&nonce, plaintext.as_bytes())
49 + .map_err(|e| AppError::Internal(format!("Encryption failed: {}", e)))?;
50 +
51 + let mut combined = nonce.to_vec();
52 + combined.extend_from_slice(&ciphertext);
53 +
54 + Ok(format!("{}{}", ENCRYPTED_PREFIX, B64.encode(&combined)))
55 + }
56 +
57 + /// Decrypt a value previously encrypted by [`encrypt`]. Handles both
58 + /// `enc:` prefixed (encrypted) and plain text (legacy/migration) values.
59 + pub fn decrypt(value: &str) -> Result<String, AppError> {
60 + let encoded = match value.strip_prefix(ENCRYPTED_PREFIX) {
61 + Some(s) => s,
62 + None => return Ok(value.to_string()),
63 + };
64 +
65 + let key_bytes = Self::get_key()?;
66 + let cipher = Aes256Gcm::new_from_slice(&key_bytes)
67 + .map_err(|e| AppError::Internal(format!("Failed to create cipher: {}", e)))?;
68 +
69 + let combined = B64.decode(encoded).map_err(|e| {
70 + AppError::Internal(format!("Failed to decode encrypted value: {}", e))
71 + })?;
72 +
73 + if combined.len() < 12 {
74 + return Err(AppError::Internal("Encrypted value too short".to_string()));
75 + }
76 +
77 + let (nonce_bytes, ciphertext) = combined.split_at(12);
78 + let nonce = Nonce::from_slice(nonce_bytes);
79 +
80 + let plaintext = cipher
81 + .decrypt(nonce, ciphertext)
82 + .map_err(|_| AppError::Internal("Decryption failed – wrong key or corrupted data".to_string()))?;
83 +
84 + String::from_utf8(plaintext)
85 + .map_err(|e| AppError::Internal(format!("Decrypted value is not valid UTF-8: {}", e)))
86 + }
87 +
88 + /// Returns true if `OAUTH_ENCRYPTION_KEY` is configured.
89 + pub fn is_available() -> bool {
90 + env::var("OAUTH_ENCRYPTION_KEY").is_ok()
91 + }
92 +
93 + /// Returns true if the value is already encrypted (has the `enc:` prefix).
94 + pub fn is_encrypted(value: &str) -> bool {
95 + value.starts_with(ENCRYPTED_PREFIX)
96 + }
97 +}
98 +
99 +#[cfg(test)]
100 +mod tests {
101 + use super::*;
102 +
103 + #[test]
104 + fn test_roundtrip() {
105 + env::set_var(
106 + "OAUTH_ENCRYPTION_KEY",
107 + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
108 + );
109 + let secret = "my-super-secret-client-key";
110 + let encrypted = CryptoService::encrypt(secret).unwrap();
111 + assert!(encrypted.starts_with("enc:"));
112 + let decrypted = CryptoService::decrypt(&encrypted).unwrap();
113 + assert_eq!(decrypted, secret);
114 + }
115 +
116 + #[test]
117 + fn test_plaintext_passthrough() {
118 + let plain = "not-encrypted-value";
119 + let result = CryptoService::decrypt(plain).unwrap();
120 + assert_eq!(result, plain);
121 + }
122 +}
M crates/rustwarden-core/src/services/mod.rs
+3 -0
@@ -4,6 +4,7 @@
4 4
5 5 pub mod app_settings_service;
6 6 pub mod auth_logging;
7 +pub mod crypto_service;
7 8 pub mod default_admin_service;
8 9 pub mod auth_service;
9 10 pub mod email_queue_service;
@@ -21,3 +22,5 @@ pub mod resource_service;
21 22 pub mod role_service;
22 23 pub mod user_preferences_service;
23 24 pub mod user_service;
25 +#[cfg(feature = "oauth")]
26 +pub mod oauth;
A crates/rustwarden-core/src/services/oauth/mod.rs
+6 -0
@@ -0,0 +1,6 @@
1 +pub mod provider_trait;
2 +pub mod providers;
3 +pub mod service;
4 +
5 +pub use provider_trait::{OAuthProvider, ProviderUserInfo};
6 +pub use service::OAuthService;
A crates/rustwarden-core/src/services/oauth/provider_trait.rs
+45 -0
@@ -0,0 +1,45 @@
1 +use async_trait::async_trait;
2 +use crate::error::AppError;
3 +
4 +/// Information about a user obtained from an OAuth/OIDC provider.
5 +#[derive(Debug, Clone)]
6 +pub struct ProviderUserInfo {
7 + pub provider_user_id: String,
8 + pub email: Option<String>,
9 + pub email_verified: bool,
10 + pub display_name: Option<String>,
11 + pub avatar_url: Option<String>,
12 +}
13 +
14 +/// Authorization URL and associated security state.
15 +pub struct AuthorizationUrl {
16 + pub url: String,
17 + pub csrf_state: String,
18 + pub nonce: Option<String>,
19 + pub pkce_verifier: Option<String>,
20 +}
21 +
22 +/// Trait that all OAuth/OIDC providers must implement.
23 +/// This enables a modular architecture where new providers can be added
24 +/// by implementing this trait without changing the core service.
25 +#[async_trait]
26 +pub trait OAuthProvider: Send + Sync {
27 + /// Unique name for this provider (e.g. "google", "github", "discord").
28 + fn name(&self) -> &str;
29 +
30 + /// Human-readable display name (e.g. "Google", "GitHub", "Discord").
31 + fn display_name(&self) -> &str;
32 +
33 + /// Build the authorization URL that the user will be redirected to.
34 + fn authorization_url(&self, redirect_uri: &str) -> Result<AuthorizationUrl, AppError>;
35 +
36 + /// Exchange an authorization code for user info.
37 + /// Validates the state, nonce, and PKCE verifier.
38 + async fn exchange_code(
39 + &self,
40 + code: &str,
41 + redirect_uri: &str,
42 + pkce_verifier: Option<&str>,
43 + nonce: Option<&str>,
44 + ) -> Result<ProviderUserInfo, AppError>;
45 +}
A crates/rustwarden-core/src/services/oauth/providers/discord.rs
+112 -0
@@ -0,0 +1,112 @@
1 +use async_trait::async_trait;
2 +use openidconnect::CsrfToken;
3 +use reqwest;
4 +use serde::Deserialize;
5 +use crate::error::AppError;
6 +use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo};
7 +
8 +pub struct DiscordProvider {
9 + client_id: String,
10 + client_secret: String,
11 +}
12 +
13 +impl DiscordProvider {
14 + pub fn new(client_id: String, client_secret: String) -> Self {
15 + Self { client_id, client_secret }
16 + }
17 +}
18 +
19 +#[derive(Deserialize)]
20 +struct DiscordTokenResponse {
21 + access_token: String,
22 +}
23 +
24 +#[derive(Deserialize)]
25 +struct DiscordUser {
26 + id: String,
27 + username: String,
28 + global_name: Option<String>,
29 + email: Option<String>,
30 + verified: Option<bool>,
31 + avatar: Option<String>,
32 +}
33 +
34 +#[async_trait]
35 +impl OAuthProvider for DiscordProvider {
36 + fn name(&self) -> &str {
37 + "discord"
38 + }
39 +
40 + fn display_name(&self) -> &str {
41 + "Discord"
42 + }
43 +
44 + fn authorization_url(&self, redirect_uri: &str) -> Result<AuthorizationUrl, AppError> {
45 + let state = CsrfToken::new_random();
46 +
47 + let url = format!(
48 + "https://discord.com/oauth2/authorize?\
49 + client_id={}&\
50 + redirect_uri={}&\
51 + response_type=code&\
52 + scope=identify%20email&\
53 + state={}",
54 + self.client_id, redirect_uri, state.secret(),
55 + );
56 +
57 + Ok(AuthorizationUrl {
58 + url,
59 + csrf_state: state.secret().to_string(),
60 + nonce: None,
61 + pkce_verifier: None,
62 + })
63 + }
64 +
65 + async fn exchange_code(
66 + &self,
67 + code: &str,
68 + redirect_uri: &str,
69 + _pkce_verifier: Option<&str>,
70 + _nonce: Option<&str>,
71 + ) -> Result<ProviderUserInfo, AppError> {
72 + let http = reqwest::Client::new();
73 +
74 + let token_resp = http
75 + .post("https://discord.com/api/oauth2/token")
76 + .form(&[
77 + ("client_id", self.client_id.as_str()),
78 + ("client_secret", self.client_secret.as_str()),
79 + ("grant_type", "authorization_code"),
80 + ("code", code),
81 + ("redirect_uri", redirect_uri),
82 + ])
83 + .send()
84 + .await
85 + .map_err(|e| AppError::Internal(format!("Discord token exchange failed: {}", e)))?
86 + .json::<DiscordTokenResponse>()
87 + .await
88 + .map_err(|e| AppError::Internal(format!("Discord token parse failed: {}", e)))?;
89 +
90 + let user: DiscordUser = http
91 + .get("https://discord.com/api/v10/users/@me")
92 + .header("Authorization", format!("Bearer {}", token_resp.access_token))
93 + .send()
94 + .await
95 + .map_err(|e| AppError::Internal(format!("Discord user fetch failed: {}", e)))?
96 + .json()
97 + .await
98 + .map_err(|e| AppError::Internal(format!("Discord user parse failed: {}", e)))?;
99 +
100 + let avatar_url = user.avatar.as_ref().map(|hash| {
101 + format!("https://cdn.discordapp.com/avatars/{}/{}.png", user.id, hash)
102 + });
103 +
104 + Ok(ProviderUserInfo {
105 + provider_user_id: user.id,
106 + email: user.email,
107 + email_verified: user.verified.unwrap_or(false),
108 + display_name: user.global_name.or(Some(user.username)),
109 + avatar_url,
110 + })
111 + }
112 +}
A crates/rustwarden-core/src/services/oauth/providers/github.rs
+135 -0
@@ -0,0 +1,135 @@
1 +use async_trait::async_trait;
2 +use openidconnect::CsrfToken;
3 +use serde::Deserialize;
4 +use crate::error::AppError;
5 +use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo};
6 +
7 +pub struct GitHubProvider {
8 + client_id: String,
9 + client_secret: String,
10 +}
11 +
12 +impl GitHubProvider {
13 + pub fn new(client_id: String, client_secret: String) -> Self {
14 + Self { client_id, client_secret }
15 + }
16 +}
17 +
18 +#[derive(Deserialize)]
19 +struct GitHubTokenResponse {
20 + access_token: String,
21 +}
22 +
23 +#[derive(Deserialize)]
24 +struct GitHubUser {
25 + id: i64,
26 + login: String,
27 + name: Option<String>,
28 + email: Option<String>,
29 + avatar_url: Option<String>,
30 +}
31 +
32 +#[derive(Deserialize)]
33 +struct GitHubEmail {
34 + email: String,
35 + primary: bool,
36 + verified: bool,
37 +}
38 +
39 +#[async_trait]
40 +impl OAuthProvider for GitHubProvider {
41 + fn name(&self) -> &str {
42 + "github"
43 + }
44 +
45 + fn display_name(&self) -> &str {
46 + "GitHub"
47 + }
48 +
49 + fn authorization_url(&self, redirect_uri: &str) -> Result<AuthorizationUrl, AppError> {
50 + let state = CsrfToken::new_random();
51 +
52 + let url = format!(
53 + "https://github.com/login/oauth/authorize?\
54 + client_id={}&\
55 + redirect_uri={}&\
56 + scope=user:email&\
57 + state={}",
58 + self.client_id, redirect_uri, state.secret(),
59 + );
60 +
61 + Ok(AuthorizationUrl {
62 + url,
63 + csrf_state: state.secret().to_string(),
64 + nonce: None,
65 + pkce_verifier: None,
66 + })
67 + }
68 +
69 + async fn exchange_code(
70 + &self,
71 + code: &str,
72 + _redirect_uri: &str,
73 + _pkce_verifier: Option<&str>,
74 + _nonce: Option<&str>,
75 + ) -> Result<ProviderUserInfo, AppError> {
76 + let http = reqwest::Client::new();
77 +
78 + let token_resp = http
79 + .post("https://github.com/login/oauth/access_token")
80 + .header("Accept", "application/json")
81 + .form(&[
82 + ("client_id", self.client_id.as_str()),
83 + ("client_secret", self.client_secret.as_str()),
84 + ("code", code),
85 + ])
86 + .send()
87 + .await
88 + .map_err(|e| AppError::Internal(format!("GitHub token exchange failed: {}", e)))?
89 + .json::<GitHubTokenResponse>()
90 + .await
91 + .map_err(|e| AppError::Internal(format!("GitHub token parse failed: {}", e)))?;
92 +
93 + let user: GitHubUser = http
94 + .get("https://api.github.com/user")
95 + .header("Authorization", format!("Bearer {}", token_resp.access_token))
96 + .header("User-Agent", "Rustwarden")
97 + .send()
98 + .await
99 + .map_err(|e| AppError::Internal(format!("GitHub user fetch failed: {}", e)))?
100 + .json()
101 + .await
102 + .map_err(|e| AppError::Internal(format!("GitHub user parse failed: {}", e)))?;
103 +
104 + let (email, email_verified) = if user.email.is_some() {
105 + (user.email.clone(), true)
106 + } else {
107 + let emails: Vec<GitHubEmail> = http
108 + .get("https://api.github.com/user/emails")
109 + .header("Authorization", format!("Bearer {}", token_resp.access_token))
110 + .header("User-Agent", "Rustwarden")
111 + .send()
112 + .await
113 + .map_err(|e| AppError::Internal(format!("GitHub emails fetch failed: {}", e)))?
114 + .json()
115 + .await
116 + .unwrap_or_default();
117 +
118 + let primary = emails
119 + .into_iter()
120 + .find(|e| e.primary && e.verified);
121 + match primary {
122 + Some(e) => (Some(e.email), true),
123 + None => (None, false),
124 + }
125 + };
126 +
127 + Ok(ProviderUserInfo {
128 + provider_user_id: user.id.to_string(),
129 + email,
130 + email_verified,
131 + display_name: user.name.or(Some(user.login)),
132 + avatar_url: user.avatar_url,
133 + })
134 + }
135 +}
A crates/rustwarden-core/src/services/oauth/providers/google.rs
+132 -0
@@ -0,0 +1,132 @@
1 +use async_trait::async_trait;
2 +use openidconnect::{
3 + core::CoreProviderMetadata,
4 + AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce,
5 + PkceCodeChallenge, PkceCodeVerifier, RedirectUrl,
6 +};
7 +use crate::error::AppError;
8 +use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo};
9 +
10 +pub struct GoogleProvider {
11 + client_id: String,
12 + client_secret: String,
13 +}
14 +
15 +impl GoogleProvider {
16 + pub fn new(client_id: String, client_secret: String) -> Self {
17 + Self { client_id, client_secret }
18 + }
19 +}
20 +
21 +#[async_trait]
22 +impl OAuthProvider for GoogleProvider {
23 + fn name(&self) -> &str {
24 + "google"
25 + }
26 +
27 + fn display_name(&self) -> &str {
28 + "Google"
29 + }
30 +
31 + fn authorization_url(&self, redirect_uri: &str) -> Result<AuthorizationUrl, AppError> {
32 + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
33 + let nonce = Nonce::new_random();
34 + let state = CsrfToken::new_random();
35 +
36 + let url = format!(
37 + "https://accounts.google.com/o/oauth2/v2/auth?\
38 + client_id={}&\
39 + redirect_uri={}&\
40 + response_type=code&\
41 + scope=openid%20email%20profile&\
42 + state={}&\
43 + nonce={}&\
44 + code_challenge={}&\
45 + code_challenge_method=S256",
46 + self.client_id,
47 + redirect_uri,
48 + state.secret(),
49 + nonce.secret(),
50 + pkce_challenge.as_str(),
51 + );
52 +
53 + Ok(AuthorizationUrl {
54 + url,
55 + csrf_state: state.secret().to_string(),
56 + nonce: Some(nonce.secret().to_string()),
57 + pkce_verifier: Some(pkce_verifier.secret().to_string()),
58 + })
59 + }
60 +
61 + async fn exchange_code(
62 + &self,
63 + code: &str,
64 + redirect_uri: &str,
65 + pkce_verifier: Option<&str>,
66 + nonce_str: Option<&str>,
67 + ) -> Result<ProviderUserInfo, AppError> {
68 + let http_client = reqwest::Client::builder()
69 + .redirect(reqwest::redirect::Policy::none())
70 + .build()
71 + .map_err(|e| AppError::Internal(format!("HTTP client error: {}", e)))?;
72 +
73 + let issuer_url = IssuerUrl::new("https://accounts.google.com".to_string())
74 + .map_err(|e| AppError::Internal(format!("Invalid issuer URL: {}", e)))?;
75 +
76 + let provider_metadata =
77 + CoreProviderMetadata::discover_async(issuer_url, &http_client)
78 + .await
79 + .map_err(|e| AppError::Internal(format!("OIDC discovery failed: {}", e)))?;
80 +
81 + let client = openidconnect::core::CoreClient::from_provider_metadata(
82 + provider_metadata,
83 + ClientId::new(self.client_id.clone()),
84 + Some(ClientSecret::new(self.client_secret.clone())),
85 + )
86 + .set_redirect_uri(
87 + RedirectUrl::new(redirect_uri.to_string())
88 + .map_err(|e| AppError::Internal(format!("Invalid redirect URI: {}", e)))?,
89 + );
90 +
91 + let mut token_request = client
92 + .exchange_code(AuthorizationCode::new(code.to_string()))
93 + .map_err(|e| AppError::Internal(format!("Token request config error: {}", e)))?;
94 +
95 + if let Some(verifier) = pkce_verifier {
96 + token_request =
97 + token_request.set_pkce_verifier(PkceCodeVerifier::new(verifier.to_string()));
98 + }
99 +
100 + let token_response = token_request
101 + .request_async(&http_client)
102 + .await
103 + .map_err(|e| AppError::Internal(format!("Token exchange failed: {}", e)))?;
104 +
105 + let id_token = token_response
106 + .extra_fields()
107 + .id_token()
108 + .ok_or_else(|| AppError::Internal("Missing ID token in response".to_string()))?;
109 +
110 + let nonce = Nonce::new(nonce_str.unwrap_or("").to_string());
111 + let claims = id_token
112 + .claims(&client.id_token_verifier(), &nonce)
113 + .map_err(|e| AppError::Internal(format!("ID token verification failed: {}", e)))?;
114 +
115 + let subject = claims.subject().to_string();
116 + let email = claims.email().map(|e| e.to_string());
117 + let email_verified = claims.email_verified().unwrap_or(false);
118 + let name = claims
119 + .name()
120 + .and_then(|n| n.get(None))
121 + .map(|n| n.to_string());
122 + let picture = claims.picture().and_then(|p| p.get(None)).map(|p| p.to_string());
123 +
124 + Ok(ProviderUserInfo {
125 + provider_user_id: subject,
126 + email,
127 + email_verified,
128 + display_name: name,
129 + avatar_url: picture,
130 + })
131 + }
132 +}
A crates/rustwarden-core/src/services/oauth/providers/microsoft.rs
+140 -0
@@ -0,0 +1,140 @@
1 +use async_trait::async_trait;
2 +use openidconnect::{
3 + core::CoreProviderMetadata,
4 + AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce,
5 + PkceCodeChallenge, PkceCodeVerifier, RedirectUrl,
6 +};
7 +use crate::error::AppError;
8 +use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo};
9 +
10 +pub struct MicrosoftProvider {
11 + client_id: String,
12 + client_secret: String,
13 + tenant: String,
14 +}
15 +
16 +impl MicrosoftProvider {
17 + pub fn new(client_id: String, client_secret: String, tenant: Option<String>) -> Self {
18 + Self {
19 + client_id,
20 + client_secret,
21 + tenant: tenant.unwrap_or_else(|| "common".to_string()),
22 + }
23 + }
24 +}
25 +
26 +#[async_trait]
27 +impl OAuthProvider for MicrosoftProvider {
28 + fn name(&self) -> &str {
29 + "microsoft"
30 + }
31 +
32 + fn display_name(&self) -> &str {
33 + "Microsoft"
34 + }
35 +
36 + fn authorization_url(&self, redirect_uri: &str) -> Result<AuthorizationUrl, AppError> {
37 + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
38 + let nonce = Nonce::new_random();
39 + let state = CsrfToken::new_random();
40 +
41 + let url = format!(
42 + "https://login.microsoftonline.com/{}/oauth2/v2.0/authorize?\
43 + client_id={}&\
44 + redirect_uri={}&\
45 + response_type=code&\
46 + scope=openid%20email%20profile&\
47 + state={}&\
48 + nonce={}&\
49 + code_challenge={}&\
50 + code_challenge_method=S256",
51 + self.tenant,
52 + self.client_id,
53 + redirect_uri,
54 + state.secret(),
55 + nonce.secret(),
56 + pkce_challenge.as_str(),
57 + );
58 +
59 + Ok(AuthorizationUrl {
60 + url,
61 + csrf_state: state.secret().to_string(),
62 + nonce: Some(nonce.secret().to_string()),
63 + pkce_verifier: Some(pkce_verifier.secret().to_string()),
64 + })
65 + }
66 +
67 + async fn exchange_code(
68 + &self,
69 + code: &str,
70 + redirect_uri: &str,
71 + pkce_verifier: Option<&str>,
72 + nonce_str: Option<&str>,
73 + ) -> Result<ProviderUserInfo, AppError> {
74 + let http_client = reqwest::Client::builder()
75 + .redirect(reqwest::redirect::Policy::none())
76 + .build()
77 + .map_err(|e| AppError::Internal(format!("HTTP client error: {}", e)))?;
78 +
79 + let issuer_url = IssuerUrl::new(
80 + format!("https://login.microsoftonline.com/{}/v2.0", self.tenant),
81 + )
82 + .map_err(|e| AppError::Internal(format!("Invalid issuer URL: {}", e)))?;
83 +
84 + let provider_metadata =
85 + CoreProviderMetadata::discover_async(issuer_url, &http_client)
86 + .await
87 + .map_err(|e| AppError::Internal(format!("OIDC discovery failed: {}", e)))?;
88 +
89 + let client = openidconnect::core::CoreClient::from_provider_metadata(
90 + provider_metadata,
91 + ClientId::new(self.client_id.clone()),
92 + Some(ClientSecret::new(self.client_secret.clone())),
93 + )
94 + .set_redirect_uri(
95 + RedirectUrl::new(redirect_uri.to_string())
96 + .map_err(|e| AppError::Internal(format!("Invalid redirect URI: {}", e)))?,
97 + );
98 +
99 + let mut token_request = client
100 + .exchange_code(AuthorizationCode::new(code.to_string()))
101 + .map_err(|e| AppError::Internal(format!("Token request config error: {}", e)))?;
102 +
103 + if let Some(verifier) = pkce_verifier {
104 + token_request =
105 + token_request.set_pkce_verifier(PkceCodeVerifier::new(verifier.to_string()));
106 + }
107 +
108 + let token_response = token_request
109 + .request_async(&http_client)
110 + .await
111 + .map_err(|e| AppError::Internal(format!("Token exchange failed: {}", e)))?;
112 +
113 + let id_token = token_response
114 + .extra_fields()
115 + .id_token()
116 + .ok_or_else(|| AppError::Internal("Missing ID token in response".to_string()))?;
117 +
118 + let nonce = Nonce::new(nonce_str.unwrap_or("").to_string());
119 + let claims = id_token
120 + .claims(&client.id_token_verifier(), &nonce)
121 + .map_err(|e| AppError::Internal(format!("ID token verification failed: {}", e)))?;
122 +
123 + let subject = claims.subject().to_string();
124 + let email = claims.email().map(|e| e.to_string());
125 + let email_verified = claims.email_verified().unwrap_or(false);
126 + let name = claims
127 + .name()
128 + .and_then(|n| n.get(None))
129 + .map(|n| n.to_string());
130 + let picture = claims.picture().and_then(|p| p.get(None)).map(|p| p.to_string());
131 +
132 + Ok(ProviderUserInfo {
133 + provider_user_id: subject,
134 + email,
135 + email_verified,
136 + display_name: name,
137 + avatar_url: picture,
138 + })
139 + }
140 +}
A crates/rustwarden-core/src/services/oauth/providers/mod.rs
+46 -0
@@ -0,0 +1,46 @@
1 +pub mod google;
2 +pub mod github;
3 +pub mod discord;
4 +pub mod microsoft;
5 +
6 +use std::collections::HashMap;
7 +use std::sync::Arc;
8 +use super::provider_trait::OAuthProvider;
9 +
10 +/// Registry of available OAuth providers, indexed by name.
11 +pub struct ProviderRegistry {
12 + providers: HashMap<String, Arc<dyn OAuthProvider>>,
13 +}
14 +
15 +impl ProviderRegistry {
16 + pub fn new() -> Self {
17 + Self {
18 + providers: HashMap::new(),
19 + }
20 + }
21 +
22 + pub fn register(&mut self, provider: Arc<dyn OAuthProvider>) {
23 + self.providers.insert(provider.name().to_string(), provider);
24 + }
25 +
26 + pub fn get(&self, name: &str) -> Option<&Arc<dyn OAuthProvider>> {
27 + self.providers.get(name)
28 + }
29 +
30 + pub fn list(&self) -> Vec<(&str, &str)> {
31 + self.providers
32 + .values()
33 + .map(|p| (p.name(), p.display_name()))
34 + .collect()
35 + }
36 +
37 + pub fn is_empty(&self) -> bool {
38 + self.providers.is_empty()
39 + }
40 +}
41 +
42 +impl Default for ProviderRegistry {
43 + fn default() -> Self {
44 + Self::new()
45 + }
46 +}
A crates/rustwarden-core/src/services/oauth/service.rs
+219 -0
@@ -0,0 +1,219 @@
1 +use chrono::Utc;
2 +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
3 +use uuid::Uuid;
4 +
5 +use crate::error::AppError;
6 +use crate::models::oauth_account;
7 +use crate::services::user_service::UserService;
8 +use crate::services::role_service::RoleService;
9 +use super::provider_trait::ProviderUserInfo;
10 +
11 +pub struct OAuthService;
12 +
13 +impl OAuthService {
14 + /// Find an existing OAuth account link for a provider + provider_user_id.
15 + pub async fn find_oauth_account(
16 + db: &DatabaseConnection,
17 + provider: &str,
18 + provider_user_id: &str,
19 + ) -> Result<Option<oauth_account::Model>, AppError> {
20 + let account = oauth_account::Entity::find()
21 + .filter(oauth_account::Column::Provider.eq(provider))
22 + .filter(oauth_account::Column::ProviderUserId.eq(provider_user_id))
23 + .one(db)
24 + .await?;
25 + Ok(account)
26 + }
27 +
28 + /// Find all OAuth accounts linked to a user.
29 + pub async fn find_user_oauth_accounts(
30 + db: &DatabaseConnection,
31 + user_id: Uuid,
32 + ) -> Result<Vec<oauth_account::Model>, AppError> {
33 + let accounts = oauth_account::Entity::find()
34 + .filter(oauth_account::Column::UserId.eq(user_id))
35 + .all(db)
36 + .await?;
37 + Ok(accounts)
38 + }
39 +
40 + /// Link an OAuth account to an existing user.
41 + pub async fn link_account(
42 + db: &DatabaseConnection,
43 + user_id: Uuid,
44 + provider: &str,
45 + info: &ProviderUserInfo,
46 + ) -> Result<oauth_account::Model, AppError> {
47 + let now = Utc::now();
48 + let account = oauth_account::ActiveModel {
49 + id: Set(Uuid::new_v4()),
50 + user_id: Set(user_id),
51 + provider: Set(provider.to_string()),
52 + provider_user_id: Set(info.provider_user_id.clone()),
53 + email: Set(info.email.clone()),
54 + display_name: Set(info.display_name.clone()),
55 + avatar_url: Set(info.avatar_url.clone()),
56 + created_at: Set(now),
57 + updated_at: Set(now),
58 + };
59 + let model = account.insert(db).await?;
60 + tracing::info!("Linked {} account for user {}", provider, user_id);
61 + Ok(model)
62 + }
63 +
64 + /// Unlink an OAuth account from a user by provider name.
65 + /// Refuses if it's the user's only auth method (no password + last OAuth link).
66 + pub async fn unlink_account(
67 + db: &DatabaseConnection,
68 + user_id: Uuid,
69 + provider: &str,
70 + ) -> Result<(), AppError> {
71 + let linked_accounts = Self::find_user_oauth_accounts(db, user_id).await?;
72 + let account = linked_accounts
73 + .into_iter()
74 + .find(|a| a.provider == provider)
75 + .ok_or_else(|| AppError::NotFound("OAuth account not found".to_string()))?;
76 +
77 + Self::unlink_account_by_id(db, user_id, account.id).await
78 + }
79 +
80 + /// Unlink an OAuth account from a user by account ID.
81 + /// Refuses if it's the user's only auth method (no password + last OAuth link).
82 + pub async fn unlink_account_by_id(
83 + db: &DatabaseConnection,
84 + user_id: Uuid,
85 + account_id: Uuid,
86 + ) -> Result<(), AppError> {
87 + let user = UserService::get_user_by_id(db, user_id)
88 + .await?
89 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
90 +
91 + let linked_accounts = Self::find_user_oauth_accounts(db, user_id).await?;
92 +
93 + let account = linked_accounts
94 + .iter()
95 + .find(|a| a.id == account_id)
96 + .ok_or_else(|| AppError::NotFound("OAuth account not found".to_string()))?;
97 +
98 + let has_password = !user.password_hash.is_empty();
99 + if !has_password && linked_accounts.len() <= 1 {
100 + return Err(AppError::Validation(
101 + "Cannot unlink the last authentication method. Set a password first.".to_string(),
102 + ));
103 + }
104 +
105 + let provider_name = account.provider.clone();
106 + oauth_account::Entity::delete_by_id(account_id)
107 + .exec(db)
108 + .await?;
109 +
110 + tracing::info!("Unlinked {} account for user {}", provider_name, user_id);
111 + Ok(())
112 + }
113 +
114 + /// Find or create a user from OAuth provider info.
115 + /// - If an oauth_account link exists, return the linked user.
116 + /// - If a user with the same verified email exists, link the account and return the user.
117 + /// - Otherwise, create a new user, assign the "user" role, link the account, return user.
118 + ///
119 + /// SEC: Refuses login if provider did not verify the email.
120 + pub async fn find_or_create_user(
121 + db: &DatabaseConnection,
122 + provider: &str,
123 + info: &ProviderUserInfo,
124 + ) -> Result<(crate::models::user::Model, bool), AppError> {
125 + // 1. Check existing link
126 + if let Some(account) = Self::find_oauth_account(db, provider, &info.provider_user_id).await? {
127 + let user = UserService::get_user_by_id(db, account.user_id)
128 + .await?
129 + .ok_or_else(|| AppError::Internal("Linked user not found".to_string()))?;
130 + return Ok((user, false));
131 + }
132 +
133 + // SEC: Require verified email from provider
134 + let email = info.email.as_ref().ok_or_else(|| {
135 + AppError::Validation("OAuth provider did not return an email address".to_string())
136 + })?;
137 +
138 + if !info.email_verified {
139 + return Err(AppError::Validation(
140 + "Email address is not verified by the OAuth provider. Please verify your email first.".to_string(),
141 + ));
142 + }
143 +
144 + // 2. Check if user with same email already exists
145 + let is_new_user;
146 + let user = if let Some(existing) = UserService::get_user_by_email(db, email).await? {
147 + is_new_user = false;
148 + existing
149 + } else {
150 + // 3. Create new user
151 + is_new_user = true;
152 + let username = Self::generate_unique_username(
153 + db,
154 + info.display_name.as_deref().unwrap_or("user"),
155 + )
156 + .await?;
157 +
158 + let user = UserService::create_user(
159 + db,
160 + username,
161 + email.clone(),
162 + String::new(), // No password for OAuth-only users
163 + false,
164 + )
165 + .await?;
166 +
167 + // Mark email as verified (provider already verified it)
168 + use sea_orm::Set;
169 + let mut active: crate::models::user::ActiveModel = user.clone().into();
170 + active.email_verified = Set(true);
171 + active.email_verified_at = Set(Some(Utc::now()));
172 + active.update(db).await?;
173 +
174 + // Assign default "user" role
175 + if let Some(role) = RoleService::get_role_by_name(db, "user").await? {
176 + UserService::assign_role_to_user(db, user.id, role.id).await?;
177 + }
178 +
179 + let refreshed = UserService::get_user_by_id(db, user.id)
180 + .await?
181 + .ok_or_else(|| AppError::Internal("User not found after creation".to_string()))?;
182 + refreshed
183 + };
184 +
185 + // 4. Link OAuth account to user
186 + Self::link_account(db, user.id, provider, info).await?;
187 +
188 + Ok((user, is_new_user))
189 + }
190 +
191 + async fn generate_unique_username(
192 + db: &DatabaseConnection,
193 + base: &str,
194 + ) -> Result<String, AppError> {
195 + let sanitized: String = base
196 + .chars()
197 + .filter(|c| c.is_alphanumeric() || *c == '_')
198 + .take(20)
199 + .collect();
200 + let sanitized = if sanitized.is_empty() {
201 + "user".to_string()
202 + } else {
203 + sanitized
204 + };
205 +
206 + if UserService::get_user_by_username(db, &sanitized).await?.is_none() {
207 + return Ok(sanitized);
208 + }
209 +
210 + for i in 1..100 {
211 + let candidate = format!("{}_{}", sanitized, i);
212 + if UserService::get_user_by_username(db, &candidate).await?.is_none() {
213 + return Ok(candidate);
214 + }
215 + }
216 +
217 + Ok(format!("{}_{}", sanitized, Uuid::new_v4().to_string()[..8].to_string()))
218 + }
219 +}
M crates/rustwarden-core/src/services/password_service.rs
+6 -6
@@ -106,7 +106,7 @@ impl PasswordService {
106 106 0123456789\
107 107 !@#$%^&*()_+-=[]{}|;:,.<>?";
108 108
109 - let mut rng = rand::thread_rng();
109 + let mut rng = rand::rng();
110 110 let mut password = Vec::with_capacity(20);
111 111
112 112 // Ensure at least one of each required type
@@ -116,14 +116,14 @@ impl PasswordService {
116 116 let special = b"!@#$%^&*()_+-=[]{}|;:,.<>?";
117 117
118 118 // Add one of each required type
119 - password.push(uppercase[rng.gen_range(0..uppercase.len())] as char);
120 - password.push(lowercase[rng.gen_range(0..lowercase.len())] as char);
121 - password.push(digits[rng.gen_range(0..digits.len())] as char);
122 - password.push(special[rng.gen_range(0..special.len())] as char);
119 + password.push(uppercase[rng.random_range(0..uppercase.len())] as char);
120 + password.push(lowercase[rng.random_range(0..lowercase.len())] as char);
121 + password.push(digits[rng.random_range(0..digits.len())] as char);
122 + password.push(special[rng.random_range(0..special.len())] as char);
123 123
124 124 // Fill the rest randomly
125 125 for _ in 4..20 {
126 - let idx = rng.gen_range(0..CHARSET.len());
126 + let idx = rng.random_range(0..CHARSET.len());
127 127 password.push(CHARSET[idx] as char);
128 128 }
129 129
M crates/rustwarden-core/src/services/refresh_token_service.rs
+28 -9
@@ -10,11 +10,12 @@
10 10 use crate::error::AppError;
11 11 use crate::models::refresh_token;
12 12 use chrono::{DateTime, Duration, Utc};
13 -use rand::{distributions::Alphanumeric, Rng};
13 +use rand::{distr::Alphanumeric, Rng};
14 14 use sea_orm::{
15 15 ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
16 16 Set,
17 17 };
18 +use sha2::{Digest, Sha256};
18 19 use uuid::Uuid;
19 20
20 21 /// Active session information for display
@@ -29,10 +30,23 @@ pub struct ActiveSession {
29 30 pub is_current: bool,
30 31 }
31 32
33 +/// Result of creating a refresh token: the DB model (with hash) + plain token for the client.
34 +pub struct CreatedRefreshToken {
35 + pub model: refresh_token::Model,
36 + pub plain_token: String,
37 +}
38 +
32 39 /// Service for refresh token operations
33 40 pub struct RefreshTokenService;
34 41
35 42 impl RefreshTokenService {
43 + /// SEC-L1: Hash a token with SHA-256 before storage/lookup.
44 + fn hash_token(token: &str) -> String {
45 + let mut hasher = Sha256::new();
46 + hasher.update(token.as_bytes());
47 + hex::encode(hasher.finalize())
48 + }
49 +
36 50 /// Generate a secure random refresh token
37 51 ///
38 52 /// Generates a cryptographically secure random token using `rand::thread_rng()`.
@@ -57,7 +71,7 @@ impl RefreshTokenService {
57 71 /// assert_eq!(token.len(), 64);
58 72 /// ```
59 73 pub fn generate_token() -> String {
60 - rand::thread_rng()
74 + rand::rng()
61 75 .sample_iter(&Alphanumeric)
62 76 .take(64)
63 77 .map(char::from)
@@ -96,19 +110,22 @@ impl RefreshTokenService {
96 110 /// println!("Token: {}", token.token);
97 111 /// }
98 112 /// ```
113 + /// SEC-L1: Stores the SHA-256 hash of the token in the database.
114 + /// Returns the plain token (for the client) along with the DB model.
99 115 pub async fn create_refresh_token(
100 116 db: &DatabaseConnection,
101 117 user_id: Uuid,
102 118 expiration_days: i64,
103 - ) -> Result<refresh_token::Model, AppError> {
104 - let token = Self::generate_token();
119 + ) -> Result<CreatedRefreshToken, AppError> {
120 + let plain_token = Self::generate_token();
121 + let token_hash = Self::hash_token(&plain_token);
105 122 let now = Utc::now();
106 123 let expires_at = now + Duration::days(expiration_days);
107 124
108 125 let refresh_token = refresh_token::ActiveModel {
109 126 id: Set(Uuid::new_v4()),
110 127 user_id: Set(user_id),
111 - token: Set(token),
128 + token: Set(token_hash),
112 129 expires_at: Set(expires_at),
113 130 created_at: Set(now),
114 131 revoked: Set(false),
@@ -116,7 +133,7 @@ impl RefreshTokenService {
116 133 replaced_by: Set(None),
117 134 };
118 135
119 - let result = refresh_token.insert(db).await?;
136 + let model = refresh_token.insert(db).await?;
120 137
121 138 tracing::debug!(
122 139 "Created refresh token for user {} (expires in {} days)",
@@ -124,7 +141,7 @@ impl RefreshTokenService {
124 141 expiration_days
125 142 );
126 143
127 - Ok(result)
144 + Ok(CreatedRefreshToken { model, plain_token })
128 145 }
129 146
130 147 /// Validate and use a refresh token
@@ -167,13 +184,15 @@ impl RefreshTokenService {
167 184 /// }
168 185 /// }
169 186 /// ```
187 + /// SEC-L1: Hashes the input token with SHA-256 before DB lookup.
170 188 pub async fn validate_refresh_token(
171 189 db: &DatabaseConnection,
172 190 token: &str,
173 191 ) -> Result<refresh_token::Model, AppError> {
174 - // Find token in database
192 + let token_hash = Self::hash_token(token);
193 +
175 194 let refresh_token = refresh_token::Entity::find()
176 - .filter(refresh_token::Column::Token.eq(token))
195 + .filter(refresh_token::Column::Token.eq(token_hash))
177 196 .one(db)
178 197 .await?
179 198 .ok_or_else(|| {
M crates/rustwarden-ui/Cargo.toml
+2 -2
@@ -17,7 +17,7 @@ serde_json = "1.0"
17 17 tower = { version = "0.5", features = ["limit", "util"] }
18 18 tower-http = { version = "0.6", features = ["cors", "trace", "limit", "fs"] }
19 19 # Templates
20 -askama = "0.14"
20 +askama = "0.15"
21 21 # Logging
22 22 tracing = "0.1"
23 23 # Database ORM
@@ -27,7 +27,7 @@ uuid = { version = "1.0", features = ["v4", "serde"] }
27 27 chrono = { version = "0.4", features = ["serde"] }
28 28 # Sessions
29 29 tower-sessions = "0.14"
30 -axum-extra = { version = "0.9", features = ["cookie"] }
30 +axum-extra = { version = "0.12", features = ["cookie"] }
31 31 # Validation
32 32 validator = { version = "0.20", features = ["derive"] }
33 33 # Formatting
D crates/rustwarden-ui/locales/de.yml
+0 -572
@@ -1,572 +0,0 @@
1 -# Deutsche Übersetzungen für RustCloud
2 -# Format: hierarchische Schlüssel mit Punkten
3 -
4 -# Anmeldung
5 -login:
6 - title: "Anmeldung"
7 - page_title: "Anmeldung - RustCloud"
8 - page_description: "Anmeldung bei RustCloud"
9 - subtitle: "Melden Sie sich in Ihrem Cloud-Bereich an"
10 - username: "Benutzername"
11 - password: "Passwort"
12 - submit: "Anmelden"
13 - remember_me: "Angemeldet bleiben"
14 - tab_login: "Anmeldung"
15 - tab_register: "Registrierung"
16 - register_title: "Konto erstellen"
17 - register_submit: "Mein Konto erstellen"
18 - username_placeholder: "Ihr Benutzername"
19 - password_placeholder: "Ihr Passwort"
20 - username_placeholder_register: "Ihr Benutzername (min. 3 Zeichen)"
21 - password_placeholder_register: "Ihr Passwort (min. 8 Zeichen)"
22 - email_placeholder: "ihre@email.de"
23 - forgot_password: "Passwort vergessen?"
24 - email_not_verified: "E-Mail nicht verifiziert?"
25 - resend_verification: "Bestätigungs-E-Mail erneut senden"
26 - already_have_account: "Bereits ein Konto?"
27 - sign_in_link: "Anmelden"
28 - success_message: "Anmeldung erfolgreich! Weiterleitung..."
29 - error_message: "Anmeldefehler. Überprüfen Sie Ihre Anmeldedaten."
30 - email_unverified_message: "Ihre E-Mail ist nicht verifiziert. Klicken Sie unten, um den Link erneut zu senden."
31 - resend_button: "Bestätigungs-E-Mail erneut senden"
32 - resend_success: "Bestätigungs-E-Mail gesendet, falls das Konto existiert."
33 - resend_error: "Anfrage kann derzeit nicht gesendet werden."
34 - resend_prompt: "Bitte geben Sie Ihre E-Mail ein, bevor Sie erneut senden."
35 - register_success: "Konto erfolgreich erstellt! Bitte überprüfen Sie Ihre E-Mail."
36 - register_error: "Registrierungsfehler"
37 - email_label: "E-Mail"
38 - footer_text: "Nextcloud Fork in Rust"
39 -
40 -# Fehlermeldungen
41 -error:
42 - invalid_credentials: "Ungültige Anmeldedaten"
43 - email_invalid: "Ungültige E-Mail"
44 - required_field: "Dieses Feld ist erforderlich"
45 - unauthorized: "Nicht autorisiert"
46 - not_found: "Ressource nicht gefunden"
47 - validation: "Validierungsfehler: {message}"
48 - database: "Datenbankfehler"
49 - internal: "Interner Serverfehler"
50 - invalid_token: "Ungültiges Token"
51 - token_expired: "Token abgelaufen"
52 - token_reused: "Token wiederverwendet - bitte erneut authentifizieren"
53 - must_change_password: "Passwortänderung erforderlich"
54 - authentication_required: "Authentifizierung erforderlich"
55 - insufficient_permissions: "Unzureichende Berechtigungen"
56 - config: "Serverkonfigurationsfehler"
57 - template: "Template-Fehler"
58 - validation_field:
59 - username_min_length: "Der Benutzername muss mindestens {min} Zeichen enthalten"
60 - password_min_length: "Das Passwort muss mindestens {min} Zeichen enthalten"
61 - email_format: "Die E-Mail-Adresse ist ungültig"
62 - password_require_uppercase: "Das Passwort muss mindestens einen Großbuchstaben enthalten"
63 - password_require_lowercase: "Das Passwort muss mindestens einen Kleinbuchstaben enthalten"
64 - password_require_digits: "Das Passwort muss mindestens eine Ziffer enthalten"
65 - password_require_special: "Das Passwort muss mindestens ein Sonderzeichen (!@#$%^&*()_+-=[]{}|;:,.<>?) enthalten"
66 -
67 -# Bestätigungs-E-Mail erneut senden
68 -resend_verification:
69 - page_title: "Bestätigungs-E-Mail erneut senden - RustWarden"
70 - title: "Bestätigungs-E-Mail erneut senden"
71 - subtitle: "Geben Sie Ihre E-Mail-Adresse ein, um einen neuen Bestätigungslink zu erhalten."
72 - email_label: "E-Mail-Adresse"
73 - email_placeholder: "sie@beispiel.de"
74 - submit: "Bestätigungslink senden"
75 - back_to_login: "Zurück zur Anmeldung"
76 - error_default: "Die Anfrage konnte nicht gesendet werden."
77 - success_default: "Wenn das Konto existiert und nicht verifiziert ist, wurde eine E-Mail gesendet."
78 - error_missing_email: "Bitte geben Sie Ihre E-Mail ein."
79 - error_cannot_send: "Die Anfrage kann derzeit nicht gesendet werden."
80 -
81 -# Passwort zurücksetzen (über E-Mail-Link)
82 -password_reset:
83 - page_title: "Passwort zurücksetzen"
84 - title: "Passwort zurücksetzen"
85 - subtitle: "Geben Sie unten Ihr neues Passwort ein."
86 - new_password_label: "Neues Passwort"
87 - new_password_placeholder: "Neues sicheres Passwort"
88 - requirements: "⚠️ Muss enthalten: 8+ Zeichen, Großbuchstabe, Kleinbuchstabe, Ziffer, Sonderzeichen"
89 - confirm_password_label: "Passwort bestätigen"
90 - confirm_password_placeholder: "Bestätigen Sie Ihr neues Passwort"
91 - missing_token_url: "Zurücksetzungs-Token fehlt in der URL."
92 - missing_token: "Zurücksetzungs-Token fehlt."
93 - missing_new_password: "Bitte geben Sie ein neues Passwort ein."
94 - passwords_not_match: "Die Passwörter stimmen nicht überein."
95 - success_message: "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt anmelden."
96 - error_link_invalid: "Das Passwort konnte nicht zurückgesetzt werden. Der Link ist möglicherweise abgelaufen oder ungültig."
97 - error_generic: "Das Passwort kann derzeit nicht zurückgesetzt werden."
98 -
99 -# Einstellungen
100 -settings:
101 - title: "Einstellungen"
102 - page_title: "Einstellungen - RustWarden"
103 - subtitle: "Verwalten Sie Ihre Präferenzen und Kontoeinstellungen"
104 - profile_title: "Mein Profil"
105 - profile_desc: "Bearbeiten Sie Ihre persönlichen Informationen (Benutzername, E-Mail)"
106 - password_title: "Passwort"
107 - password_desc: "Ändern Sie Ihr Passwort, um Ihr Konto zu sichern"
108 - sessions_title: "Aktive Sitzungen"
109 - sessions_desc: "Verwalten Sie Ihre aktiven Sitzungen und widerrufen Sie den Zugriff von anderen Geräten"
110 - preferences_title: "Präferenzen"
111 - preferences_desc: "Passen Sie Ihre Erfahrung an (Design, Sprache)"
112 - access: "Zugriff"
113 - info_title: "Informationen"
114 - info_desc: "Verwenden Sie die obigen Abschnitte, um verschiedene Aspekte Ihres Kontos zu verwalten. Alle Änderungen sind sicher und erfordern Ihre Authentifizierung."
115 - language: "Sprache"
116 - language_description: "Wählen Sie Ihre bevorzugte Sprache"
117 - save: "Speichern"
118 - saved: "Einstellungen gespeichert"
119 -
120 -# Startseite
121 -home:
122 - welcome: "Willkommen, {username}!"
123 - subtitle: "Willkommen in Ihrem persönlichen Bereich"
124 - my_account: "Mein Konto"
125 - stat_username: "Benutzername"
126 - stat_email: "E-Mail"
127 - stat_member_since: "Mitglied seit"
128 - stat_roles: "Rollen"
129 -
130 -# Allgemeine Nachrichten
131 -common:
132 - welcome: "Willkommen {username}"
133 - loading: "Laden..."
134 - save: "Speichern"
135 - cancel: "Abbrechen"
136 - delete: "Löschen"
137 - edit: "Bearbeiten"
138 - create: "Erstellen"
139 -
140 -# Präferenzen
141 -preferences:
142 - title: "Anzeigeeinstellungen"
143 - page_title: "Anzeigeeinstellungen - RustCloud"
144 - subtitle: "Passen Sie das Erscheinungsbild der Benutzeroberfläche an"
145 - theme: "Design"
146 - theme_light: "Hell"
147 - theme_light_desc: "Helle und saubere Benutzeroberfläche"
148 - theme_dark: "Dunkel"
149 - theme_dark_desc: "Reduziert Augenbelastung"
150 - theme_auto: "Automatisch"
151 - theme_auto_desc: "Passt sich der Tageszeit an"
152 - theme_preview: "Das Design wird sofort nach dem Speichern angewendet"
153 - language: "Sprache"
154 - language_fr: "Français"
155 - language_fr_desc: "Benutzeroberfläche auf Französisch"
156 - language_en: "English"
157 - language_en_desc: "Englische Benutzeroberfläche"
158 - language_de: "Deutsch"
159 - language_de_desc: "Deutsche Benutzeroberfläche"
160 - language_es: "Español"
161 - language_es_desc: "Spanische Benutzeroberfläche"
162 - translation_warning: "Die vollständige Übersetzung der Benutzeroberfläche befindet sich in der Entwicklung. Einige Elemente können auf Französisch bleiben."
163 - modified: "Einstellungen geändert"
164 - modified_desc: "Speichern Sie Ihre Änderungen, um sie anzuwenden"
165 - save: "Einstellungen speichern"
166 - saving: "Speichern..."
167 - cancel: "Abbrechen"
168 - saved_success: "Einstellungen erfolgreich gespeichert!"
169 - saved_reload: "Einstellungen erfolgreich gespeichert!\n\nDie Sprache wurde geändert. Möchten Sie die Seite jetzt neu laden, um die Änderungen anzuwenden?"
170 - error: "Fehler: {message}"
171 - network_error: "Netzwerkfehler. Bitte versuchen Sie es erneut."
172 - sync_title: "Ihre Einstellungen sind synchronisiert"
173 - sync_desc: "Ihre Anzeigeeinstellungen werden gespeichert und automatisch auf allen Geräten angewendet, die mit Ihrem RustCloud-Konto verbunden sind."
174 - preview_dark: "Vorschau: Dunkles Design wird angewendet"
175 - preview_light: "Vorschau: Helles Design wird angewendet"
176 - preview_auto: "Vorschau: Automatisches Design wird angewendet"
177 -
178 -# Profil
179 -profile:
180 - title: "Mein Profil"
181 - page_title: "Mein Profil - RustCloud"
182 - subtitle: "Bearbeiten Sie Ihre persönlichen Informationen"
183 - account_info: "Kontoinformationen"
184 - username: "Benutzername"
185 - username_public: "Öffentlich sichtbar"
186 - username_placeholder: "Ihr Benutzername"
187 - username_pattern: "Nur Buchstaben, Zahlen, Bindestriche und Unterstriche"
188 - email: "E-Mail"
189 - email_placeholder: "ihre@email.de"
190 - save: "Änderungen speichern"
191 - saving: "Speichern..."
192 - saved: "Profil erfolgreich aktualisiert"
193 - error: "Fehler beim Aktualisieren des Profils"
194 - email_label: "E-Mail-Adresse"
195 - email_used_for: "Wird für die Anmeldung verwendet"
196 - email_warning: "⚠️ Sie werden nach der Änderung abgemeldet"
197 - email_change_info: "E-Mail-Änderungen erfordern aus Sicherheitsgründen eine erneute Anmeldung."
198 - username_help: "3-50 Zeichen (Buchstaben, Zahlen, -, _)"
199 - additional_info: "Zusätzliche Informationen"
200 - account_created: "Konto erstellt am"
201 - roles: "Rollen"
202 -
203 -# Breadcrumb
204 -breadcrumb:
205 - home: "Startseite"
206 - settings: "Einstellungen"
207 - admin: "Verwaltung"
208 - preferences: "Präferenzen"
209 - profile: "Profil"
210 - password: "Passwort"
211 - sessions: "Sitzungen"
212 -
213 -# Admin
214 -admin:
215 - dashboard:
216 - title: "Verwaltung"
217 - heading: "Administrator-Dashboard"
218 - users_card_title: "Benutzer"
219 - users_card_subtitle: "Benutzer gesamt"
220 - access_button: "Zugreifen"
221 - sessions_card_title: "Aktive Sitzungen"
222 - sessions_card_subtitle: "Laufende Sitzungen"
223 - admins_card_title: "Administratoren"
224 - admins_card_subtitle: "Admin-Konten"
225 - last_activity_card_title: "Letzte Aktivität"
226 - last_activity_card_subtitle: "Letzte Anmeldung"
227 - audit_section_title: "Audit-Log-Statistiken"
228 - audit_total_logs: "Logs gesamt"
229 - audit_last_24h: "Letzte 24h"
230 - audit_by_level: "Nach Stufe"
231 - chart_label: "Logs nach Stufe"
232 - roles_card_title: "Rollenverwaltung"
233 - roles_card_desc: "Rollen und granulare Berechtigungen verwalten"
234 - roles_stat_title: "Rollen"
235 - settings_card_title: "Anwendungseinstellungen"
236 - settings_card_desc: "Globale Anwendungseinstellungen konfigurieren"
237 - apps_card_title: "Anwendungsverwaltung"
238 - apps_card_desc: "Modulare Anwendungen aktivieren oder deaktivieren"
239 - orgs_card_title: "Organisationsverwaltung"
240 - orgs_card_desc: "Organisationen und deren Mitglieder erstellen und verwalten"
241 - orgs_stat_title: "Organisationen"
242 - orgs_members_stat_title: "Mitglieder gesamt"
243 - roles_list:
244 - page_title: "Rollenverwaltung"
245 - breadcrumb_roles: "Rollen"
246 - title: "Rollen"
247 - create_role: "Rolle erstellen"
248 - table_name: "Name"
249 - table_description: "Beschreibung"
250 - table_users: "Benutzer"
251 - table_permissions: "Berechtigungen"
252 - table_actions: "Aktionen"
253 - edit: "Bearbeiten"
254 - delete: "Löschen"
255 - no_roles: "Keine Rollen gefunden."
256 - protected_hint: "Geschützt"
257 - delete_confirm_title: "Löschen bestätigen"
258 - delete_confirm_message: "Möchten Sie die Rolle wirklich löschen"
259 - delete_button: "Löschen"
260 - role_form:
261 - title_create: "Rolle erstellen"
262 - title_edit: "Rolle bearbeiten"
263 - breadcrumb_create: "Erstellen"
264 - breadcrumb_edit: "Bearbeiten"
265 - back_to_list: "Zurück zur Liste"
266 - name_label: "Rollenname"
267 - name_placeholder: "z.B. moderator"
268 - description_label: "Beschreibung"
269 - description_placeholder: "Rollenbeschreibung..."
270 - permissions_label: "Berechtigungen"
271 - protected_name_hint: "Der Name dieser Rolle kann nicht geändert werden."
272 - cancel: "Abbrechen"
273 - submit_create: "Rolle erstellen"
274 - submit_edit: "Änderungen speichern"
275 - errors:
276 - title: "Fehlercodes"
277 - description: "Dokumentation der Anwendungsfehlercodes. Jeder Fehlercode ist mit einer Middleware verknüpft und liefert Kontextinformationen für die Fehlersuche."
278 - breadcrumb_errors: "Fehlercodes"
279 - table_code: "Code"
280 - table_description: "Beschreibung"
281 - table_category: "Kategorie"
282 - table_middleware: "Middleware"
283 - table_context: "Kontext"
284 - table_example: "Beispiel"
285 - legend_title: "Legende"
286 - legend_code: "Eindeutige Fehler-ID (Format: CATEGORY_NUMBER)"
287 - legend_description: "Beschreibung des Fehlers"
288 - legend_category: "Fehlertyp (Authentifizierung, CSRF, Rate Limiting, etc.)"
289 - legend_middleware: "Middleware, die diesen Fehler erzeugt"
290 - legend_context: "Detaillierte Erklärung, wann dieser Fehler auftritt"
291 - legend_example: "Konkretes Beispiel einer Situation, die diesen Fehler erzeugt"
292 - error_auth001_desc: "Ungültige Anmeldedaten"
293 - error_auth001_category: "Authentifizierung"
294 - error_auth001_context: "Der Benutzer hat bei der Anmeldung falsche Anmeldedaten eingegeben"
295 - error_auth001_example: "Anmeldeversuch mit falschem Passwort"
296 - error_auth002_desc: "Ungültiges Token"
297 - error_auth002_category: "Authentifizierung"
298 - error_auth002_context: "Das bereitgestellte JWT-Token ist ungültig oder fehlerhaft"
299 - error_auth002_example: "Beschädigtes JWT-Token oder mit anderem Schlüssel signiert"
300 - error_auth003_desc: "Token abgelaufen"
301 - error_auth003_category: "Authentifizierung"
302 - error_auth003_context: "Das JWT-Token ist abgelaufen und muss erneuert werden"
303 - error_auth003_example: "Zugriffsversuch mit abgelaufenem Token nach der Gültigkeitsdauer"
304 - error_csrf001_desc: "CSRF-Token fehlt"
305 - error_csrf001_category: "CSRF-Schutz"
306 - error_csrf001_context: "Die POST/PUT/DELETE-Anfrage enthält kein CSRF-Token"
307 - error_csrf001_example: "Formular ohne CSRF-Token im Header oder Body abgesendet"
308 - error_csrf002_desc: "Ungültiges CSRF-Token"
309 - error_csrf002_category: "CSRF-Schutz"
310 - error_csrf002_context: "Das bereitgestellte CSRF-Token stimmt nicht mit dem Sitzungs-Token überein"
311 - error_csrf002_example: "Geändertes oder gestohlenes CSRF-Token"
312 - error_rate001_desc: "Ratenlimit überschritten"
313 - error_rate001_category: "Rate Limiting"
314 - error_rate001_context: "Die Anzahl der Anfragen pro Minute wurde überschritten"
315 - error_rate001_example: "Mehr als 100 Anfragen/Min auf Admin-Endpunkten"
316 - error_valid001_desc: "Ungültige Eingabedaten"
317 - error_valid001_category: "Validierung"
318 - error_valid001_context: "Die bereitgestellten Daten entsprechen nicht den Validierungsregeln"
319 - error_valid001_example: "Ungültiges E-Mail-Format oder fehlendes Pflichtfeld"
320 - error_valid002_desc: "Passwortkomplexität nicht erfüllt"
321 - error_valid002_category: "Validierung"
322 - error_valid002_context: "Das Passwort erfüllt nicht die konfigurierten Komplexitätsregeln"
323 - error_valid002_example: "Passwort zu kurz oder fehlende erforderliche Zeichen"
324 - error_rbac001_desc: "Unzureichende Berechtigungen"
325 - error_rbac001_category: "Autorisierung"
326 - error_rbac001_context: "Der Benutzer hat nicht die erforderlichen Berechtigungen für diese Aktion"
327 - error_rbac001_example: "Nicht-Admin-Benutzer versucht auf /admin/users zuzugreifen"
328 - error_rbac002_desc: "Administratorrolle erforderlich"
329 - error_rbac002_category: "Autorisierung"
330 - error_rbac002_context: "Die Aktion erfordert eine Administratorrolle"
331 - error_rbac002_example: "Versuch einen Benutzer ohne Admin-Rolle zu löschen"
332 - error_db001_desc: "Datenbankoperationsfehler"
333 - error_db001_category: "Datenbank"
334 - error_db001_context: "Eine Datenbankoperation ist fehlgeschlagen"
335 - error_db001_example: "Fremdschlüssel-Constraint verletzt oder DB-Verbindung verloren"
336 - error_db002_desc: "Ressource nicht gefunden"
337 - error_db002_category: "Datenbank"
338 - error_db002_context: "Die angeforderte Ressource existiert nicht in der Datenbank"
339 - error_db002_example: "Benutzer mit nicht existierender ID"
340 - user_form:
341 - breadcrumb_create: "Erstellen"
342 - breadcrumb_edit: "Bearbeiten"
343 - title_create: "Benutzer erstellen"
344 - title_edit: "Benutzer bearbeiten"
345 - back_to_list: "Zurück zur Liste"
346 - username_label: "Benutzername"
347 - username_placeholder: "benutzername"
348 - username_readonly_hint: "Der Benutzername kann nach der Erstellung nicht geändert werden"
349 - email_label: "E-Mail"
350 - email_placeholder: "benutzer@example.com"
351 - password_label: "Passwort"
352 - password_optional: "Optional"
353 - password_placeholder: "Leer lassen, um ein zufälliges Passwort zu generieren"
354 - password_hint: "Wenn leer gelassen, wird automatisch ein sicheres Passwort generiert"
355 - roles_label: "Rollen"
356 - roles_hint: "Wählen Sie mindestens eine Rolle"
357 - must_change_password_label: "Passwortänderung erzwingen"
358 - must_change_password_desc: "Der Benutzer muss sein Passwort bei der nächsten Anmeldung ändern"
359 - cancel: "Abbrechen"
360 - submit_create: "Benutzer erstellen"
361 - submit_edit: "Änderungen speichern"
362 - settings:
363 - title: "Anwendungseinstellungen"
364 - page_title: "Anwendungseinstellungen - Admin - RustCloud"
365 - subtitle: "Globale RustCloud-Konfiguration"
366 - general: "Allgemein"
367 - app_name: "Anwendungsname"
368 - app_domain: "Domäne"
369 - app_domain_placeholder: "example.com"
370 - app_description: "Beschreibung"
371 - allow_registration: "Registrierung erlauben"
372 - allow_registration_desc: "Benutzer können Konten von der Startseite aus erstellen"
373 - email_validation_required: "E-Mail-Validierung erforderlich"
374 - email_validation_required_desc: "Benutzer müssen ihre E-Mail validieren, bevor sie sich anmelden können"
375 - save: "Einstellungen speichern"
376 - cancel: "Abbrechen"
377 - saved: "Einstellungen erfolgreich gespeichert"
378 - error: "Fehler beim Speichern der Einstellungen"
379 - network_error: "Netzwerkfehler. Bitte versuchen Sie es erneut."
380 - password_security: "Passwortsicherheit"
381 - password_min_length: "Mindestlänge"
382 - password_min_length_hint: "Zwischen 6 und 128 Zeichen"
383 - password_complexity: "Komplexitätsanforderungen"
384 - require_uppercase: "Großbuchstabe erforderlich"
385 - require_lowercase: "Kleinbuchstabe erforderlich"
386 - require_digits: "Ziffer erforderlich"
387 - require_special: "Sonderzeichen erforderlich"
388 - password_change_require_email: "E-Mail-Bestätigung für Passwortänderung"
389 - password_change_require_email_desc: "Wenn aktiviert, wird eine Bestätigungs-E-Mail gesendet, um jede Passwortänderung zu validieren. Andernfalls wird die Änderung sofort angewendet."
390 - password_expiration: "Passwortablauf"
391 - password_expiration_desc: "Periodische Passwortänderung erzwingen"
392 - expiration_days: "Ablauf nach (Tagen)"
393 - expiration_alert: "E-Mail-Warnung vor Ablauf"
394 - expiration_alert_days: "Warnung (Tage vorher)"
395 - audit_logging: "Audit-Protokollierung"
396 - log_level: "Protokollebene"
397 - log_level_debug: "DEBUG - Alle Details"
398 - log_level_info: "INFO - Normale Informationen"
399 - log_level_warn: "WARN - Nur Warnungen"
400 - log_level_error: "ERROR - Nur Fehler"
401 - log_level_hint: "Steuert die Ausführlichkeit der Audit-Logs"
402 - log_retention: "Log-Aufbewahrung (Tage)"
403 - log_retention_hint: "Aufbewahrungsdauer der Logs (1-365 Tage)"
404 - audit_info_message: "Audit-Logs zeichnen alle administrativen Aktionen auf (Erstellung, Änderung, Löschung von Benutzern)."
405 - save_changes: "Änderungen speichern"
406 - save_changes_desc: "Einstellungen werden sofort angewendet"
407 - saving: "Speichern..."
408 - save_success: "Einstellungen erfolgreich gespeichert!"
409 - save_error_prefix: "Fehler"
410 - users_list:
411 - page_title: "Benutzerverwaltung - Admin - RustCloud"
412 - title: "Benutzerverwaltung"
413 - breadcrumb_users: "Benutzer"
414 - create_user: "Benutzer erstellen"
415 - search_label: "Suchen"
416 - search_placeholder: "Benutzername oder E-Mail..."
417 - role_filter_label: "Rolle"
418 - all_roles: "Alle Rollen"
419 - per_page_label: "Pro Seite"
420 - search_button: "Suchen"
421 - results_summary: "Anzeige von {count} Benutzer(n) von {total} insgesamt"
422 - page_info: "Seite {page} von {total_pages}"
423 - table_username: "Benutzername"
424 - table_email: "E-Mail"
425 - table_roles: "Rollen"
426 - table_created_at: "Erstellt am"
427 - table_last_login: "Letzte Anmeldung"
428 - table_actions: "Aktionen"
429 - never: "Nie"
430 - edit: "Bearbeiten"
431 - no_users: "Keine Benutzer gefunden"
432 - reset_password_title: "Passwort zurücksetzen"
433 - reset_generate: "Zufälliges Passwort generieren"
434 - reset_custom: "Benutzerdefiniertes Passwort festlegen"
435 - reset_new_password_label: "Neues Passwort"
436 - reset_new_password_placeholder: "Mindestens 8 Zeichen, Großbuchstabe, Kleinbuchstabe, Ziffer, Sonderzeichen"
437 - reset_password_requirements: "⚠️ Muss enthalten: 8+ Zeichen, Großbuchstabe, Kleinbuchstabe, Ziffer, Sonderzeichen"
438 - reset_invalidate_sessions: "Alle aktiven Sitzungen für diesen Benutzer ungültig machen"
439 - reset_invalidate_sessions_desc: "Der Benutzer muss sich nach der Änderung erneut anmelden"
440 - reset_button: "Zurücksetzen"
441 - delete_confirm_title: "⚠️ Löschung bestätigen"
442 - delete_confirm_message: "Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?"
443 - delete_confirm_username: "Benutzername:"
444 - delete_confirm_email: "E-Mail:"
445 - delete_irreversible: "Diese Aktion ist unwiderruflich!"
446 - delete_button: "Endgültig löschen"
447 - delete: "Löschen"
448 - delete_error: "Fehler beim Löschen des Benutzers"
449 - network_error: "Netzwerkfehler"
450 - edit_user_title: "Benutzer bearbeiten"
451 - resetting: "Zurücksetzen..."
452 - reset_by_email_title: "Zurücksetzen per E-Mail"
453 - reset_email: "Per E-Mail zurücksetzen"
454 - close: "Schließen"
455 - password_reset_success_title: "Passwort zurückgesetzt"
456 - password_warning_once: "Dieses Passwort wird nur einmal angezeigt. Kopieren Sie es jetzt!"
457 - new_password_label: "Neues Passwort"
458 - copy: "Kopieren"
459 - password_copied: "Passwort in die Zwischenablage kopiert"
460 - password_set_success: "Passwort erfolgreich zurückgesetzt"
461 - reset_error: "Fehler beim Zurücksetzen des Passworts"
462 -
463 -# Passwort ändern
464 -password:
465 - title: "Mein Passwort ändern"
466 - page_title: "Mein Passwort ändern - RustCloud"
467 - subtitle: "Ändern Sie Ihr Passwort, um Ihr Konto zu sichern. Eine Bestätigungs-E-Mail wird gesendet, um die Änderung zu validieren."
468 - subtitle_no_email: "Ändern Sie Ihr Passwort, um Ihr Konto zu sichern."
469 - must_change_message: "⚠️ Sie müssen Ihr Passwort ändern, bevor Sie fortfahren können."
470 - new_password: "Neues Passwort"
471 - current_password: "Aktuelles Passwort"
472 - current_password_placeholder: "Ihr aktuelles Passwort"
473 - new_password_placeholder: "Sicheres neues Passwort"
474 - required: "Erforderlich"
475 - requirements: "📌 Anforderungen: 8+ Zeichen, Großbuchstabe, Kleinbuchstabe, Ziffer, Sonderzeichen"
476 - strength: "Passwortstärke:"
477 - strength_weak: "Schwach"
478 - strength_medium: "Mittel"
479 - strength_strong: "Stark ✓"
480 - strength_too_short: "zu kurz"
481 - strength_missing_lowercase: "Kleinbuchstabe fehlt"
482 - strength_missing_uppercase: "Großbuchstabe fehlt"
483 - strength_missing_digit: "Ziffer fehlt"
484 - strength_missing_special: "Sonderzeichen fehlt"
485 - confirm_password: "Neues Passwort bestätigen"
486 - confirm_password_placeholder: "Wiederholen Sie das neue Passwort"
487 - passwords_match: "✓ Passwörter stimmen überein"
488 - passwords_not_match: "✗ Passwörter stimmen nicht überein"
489 - change_button: "Passwort ändern"
490 - changing: "Änderung läuft..."
491 - logout_warning_title: "Abmeldung von anderen Sitzungen"
492 - logout_warning_desc: "Alle Ihre anderen aktiven Sitzungen werden nach der Passwortänderung automatisch abgemeldet. Nur Ihre aktuelle Sitzung bleibt aktiv."
493 - save: "Neues Passwort speichern"
494 - saving: "Speichern..."
495 - saved: "Eine Bestätigungs-E-Mail wurde an Ihre Adresse gesendet. Klicken Sie auf den Link in der E-Mail, um die Passwortänderung zu bestätigen."
496 - saved_direct: "Passwort erfolgreich geändert! Ihre anderen Sitzungen wurden abgemeldet."
497 - error: "Fehler beim Ändern des Passworts"
498 - error_all_fields_required: "Alle Felder sind erforderlich."
499 - error_empty_data: "Die zu sendenden Daten sind leer."
500 - error_invalid_data: "Ungültige Daten. Überprüfen Sie, ob alle Felder ausgefüllt sind."
501 - error_incorrect_current: "Aktuelles Passwort ist falsch."
502 - error_reading_response: "Fehler beim Lesen der Serverantwort"
503 - error_415: "Fehler 415: Der Server hat die Daten nicht im erwarteten JSON-Format erhalten. Bitte versuchen Sie es erneut."
504 - network_error: "Netzwerkfehler. Bitte versuchen Sie es erneut."
505 - security_tips_title: "Sicherheitstipps"
506 - security_tip_unique: "Verwenden Sie ein eindeutiges Passwort, das Sie nicht anderswo verwenden"
507 - security_tip_share: "Teilen Sie Ihr Passwort niemals mit jemandem"
508 - security_tip_regular: "Ändern Sie Ihr Passwort regelmäßig (alle 3-6 Monate)"
509 - security_tip_2fa: "Aktivieren Sie die Zwei-Faktor-Authentifizierung, falls verfügbar"
510 - security_tip_manager: "Verwenden Sie einen Passwort-Manager für mehr Sicherheit"
511 -
512 -# Sitzungen
513 -sessions:
514 - title: "Aktive Sitzungen"
515 - page_title: "Aktive Sitzungen - RustCloud"
516 - subtitle: "Verwalten Sie Geräte, die mit Ihrem Konto verbunden sind"
517 - current_session: "Aktuelle Sitzung"
518 - current_badge: "Aktuell"
519 - unknown_device: "Unbekanntes Gerät"
520 - ip_address: "IP-Adresse:"
521 - created_at: "Erstellt am:"
522 - last_activity: "Letzte Aktivität:"
523 - session_id: "Sitzungs-ID:"
524 - revoke: "Widerrufen"
525 - revoke_confirm: "Sind Sie sicher, dass Sie die Sitzung für \"{device}\" widerrufen möchten?\n\nDieses Gerät wird sofort getrennt."
526 - revoke_success: "Sitzung erfolgreich widerrufen!"
527 - revoke_error: "Fehler beim Widerrufen der Sitzung"
528 - no_sessions: "Keine aktiven Sitzungen"
529 - no_sessions_desc: "Sie haben derzeit keine aktiven Sitzungen"
530 - danger_zone: "Gefahrenzone"
531 - danger_zone_desc: "Trennen Sie alle anderen Geräte in einer Aktion (Ihre aktuelle Sitzung bleibt aktiv)."
532 - revoke_all: "Alle anderen Sitzungen widerrufen"
533 - revoke_all_confirm: "⚠️ WARNUNG\n\nSind Sie sicher, dass Sie ALLE Ihre anderen Sitzungen widerrufen möchten?\n\nAlle Ihre anderen Geräte werden getrennt. Nur Ihre aktuelle Sitzung bleibt aktiv."
534 - revoke_all_success: "Alle anderen Sitzungen wurden widerrufen!"
535 - revoke_all_error: "Fehler beim Widerrufen. Einige Sitzungen wurden möglicherweise nicht widerrufen."
536 - about_title: "Über Sitzungen"
537 - about_desc: "Eine Sitzung wird bei jeder Anmeldung von einem anderen Gerät oder Browser erstellt. Das Widerrufen einer Sitzung trennt dieses Gerät sofort. Ihre aktuelle Sitzung kann von dieser Seite nicht widerrufen werden (melden Sie sich ab, um dies zu tun)."
538 - back_to_settings: "Zurück zu den Einstellungen"
539 -
540 -# Navigation menu (navbar)
541 -nav:
542 - home: "Startseite"
543 - settings: "Einstellungen"
544 - admin: "Administration"
545 -
546 -# User menu
547 -user_menu:
548 - profile: "Profil"
549 - logout: "Abmelden"
550 - settings: "Einstellungen"
551 -
552 -# Email verification messages
553 -email_verify:
554 - config_error: "Server-Konfigurationsfehler. Bitte kontaktieren Sie den Administrator."
555 - success: "Ihre E-Mail-Adresse wurde erfolgreich verifiziert. Sie können sich jetzt in Ihr Konto einloggen."
556 - invalid_link: "Der Verifizierungslink ist ungültig oder abgelaufen."
557 -
558 -# Email subjects
559 -email_subject:
560 - password_reset: "Passwort zurücksetzen"
561 - email_validation: "Bestätigung Ihrer E-Mail-Adresse"
562 - welcome: "Willkommen bei RustWarden!"
563 -
564 -# Rate limiting
565 -rate_limit:
566 - exceeded: "Zu viele Anfragen. Bitte versuchen Sie es später erneut."
567 - password_change: "Zu viele Passwortänderungsanfragen. Bitte warten Sie, bevor Sie es erneut versuchen."
568 -
569 -# Sessions
570 -session:
571 - ip_unavailable: "Nicht verfügbar"
572 -
D crates/rustwarden-ui/locales/en.yml
+0 -593
@@ -1,593 +0,0 @@
1 -# English translations for RustCloud
2 -# Format: hierarchical keys with dots
3 -
4 -# Login
5 -login:
6 - title: "Login"
7 - page_title: "Login - RustCloud"
8 - page_description: "Login to RustCloud"
9 - subtitle: "Sign in to your cloud space"
10 - username: "Username"
11 - password: "Password"
12 - submit: "Sign in"
13 - remember_me: "Remember me"
14 - tab_login: "Login"
15 - tab_register: "Register"
16 - register_title: "Create an account"
17 - register_submit: "Create my account"
18 - username_placeholder: "Your username"
19 - password_placeholder: "Your password"
20 - username_placeholder_register: "Your username (min. 3 characters)"
21 - password_placeholder_register: "Your password (min. 8 characters)"
22 - email_placeholder: "your@email.com"
23 - forgot_password: "Forgot password?"
24 - email_not_verified: "Email not verified?"
25 - resend_verification: "Resend verification email"
26 - already_have_account: "Already have an account?"
27 - sign_in_link: "Sign in"
28 - success_message: "Login successful! Redirecting..."
29 - error_message: "Login error. Check your credentials."
30 - email_unverified_message: "Your email is not verified. Click below to resend the link."
31 - resend_button: "Resend verification email"
32 - resend_success: "Verification email sent if the account exists."
33 - resend_error: "Unable to send the request at this time."
34 - resend_prompt: "Please enter your email before resending."
35 - register_success: "Account created successfully! Please check your email."
36 - register_error: "Registration error"
37 - email_label: "Email"
38 - footer_text: "Nextcloud fork in Rust"
39 -
40 -# Resend verification email
41 -resend_verification:
42 - page_title: "Resend verification email - RustWarden"
43 - title: "Resend verification email"
44 - subtitle: "Enter your email address to receive a new verification link."
45 - email_label: "Email address"
46 - email_placeholder: "you@example.com"
47 - submit: "Send verification link"
48 - back_to_login: "Back to login"
49 - error_default: "Unable to send the request."
50 - success_default: "If the account exists and is not verified, an email has been sent."
51 - error_missing_email: "Please enter your email."
52 - error_cannot_send: "Unable to send the request at this time."
53 -
54 -# Password reset (from email link)
55 -password_reset:
56 - page_title: "Reset password"
57 - title: "Reset password"
58 - subtitle: "Enter your new password below."
59 - new_password_label: "New password"
60 - new_password_placeholder: "New secure password"
61 - requirements: "⚠️ Must contain: 8+ characters, uppercase, lowercase, digit, special character"
62 - confirm_password_label: "Confirm password"
63 - confirm_password_placeholder: "Confirm your new password"
64 - missing_token_url: "Reset token missing in URL."
65 - missing_token: "Reset token is missing."
66 - missing_new_password: "Please enter a new password."
67 - passwords_not_match: "Passwords do not match."
68 - success_message: "Your password has been reset successfully. You can now log in."
69 - error_link_invalid: "Unable to reset password. The link may be expired or invalid."
70 - error_generic: "Unable to reset password at this time."
71 -
72 -# Error messages
73 -error:
74 - invalid_credentials: "Invalid credentials"
75 - email_invalid: "Invalid email"
76 - required_field: "This field is required"
77 - unauthorized: "Unauthorized"
78 - not_found: "Resource not found"
79 - validation: "Validation error: {message}"
80 - database: "Database error occurred"
81 - internal: "Internal server error"
82 - invalid_token: "Invalid token"
83 - token_expired: "Token expired"
84 - token_reused: "Token reused - please re-authenticate"
85 - must_change_password: "Password change required"
86 - authentication_required: "Authentication required"
87 - insufficient_permissions: "Insufficient permissions"
88 - config: "Server configuration error"
89 - template: "Template error"
90 - validation_field:
91 - username_min_length: "Username must contain at least {min} characters"
92 - password_min_length: "Password must contain at least {min} characters"
93 - email_format: "Email address is not valid"
94 - password_require_uppercase: "Password must contain at least one uppercase letter"
95 - password_require_lowercase: "Password must contain at least one lowercase letter"
96 - password_require_digits: "Password must contain at least one digit"
97 - password_require_special: "Password must contain at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)"
98 -
99 -# Settings
100 -settings:
101 - title: "Settings"
102 - page_title: "Settings - RustWarden"
103 - subtitle: "Manage your preferences and account settings"
104 - profile_title: "My Profile"
105 - profile_desc: "Edit your personal information (username, email)"
106 - password_title: "Password"
107 - password_desc: "Change your password to secure your account"
108 - sessions_title: "Active Sessions"
109 - sessions_desc: "Manage your active sessions and revoke access from other devices"
110 - preferences_title: "Preferences"
111 - preferences_desc: "Customize your experience (theme, language)"
112 - access: "Access"
113 - info_title: "Information"
114 - info_desc: "Use the sections above to manage different aspects of your account. All changes are secure and require your authentication."
115 - language: "Language"
116 - language_description: "Choose your preferred language"
117 - save: "Save"
118 - saved: "Settings saved"
119 -
120 -# Home page
121 -home:
122 - welcome: "Welcome, {username}!"
123 - subtitle: "Welcome to your personal space"
124 - my_account: "My Account"
125 - stat_username: "Username"
126 - stat_email: "Email"
127 - stat_member_since: "Member since"
128 - stat_roles: "Roles"
129 -
130 -# Common messages
131 -common:
132 - welcome: "Welcome {username}"
133 - loading: "Loading..."
134 - save: "Save"
135 - cancel: "Cancel"
136 - delete: "Delete"
137 - edit: "Edit"
138 - create: "Create"
139 -
140 -# i18n messages
141 -i18n:
142 - language_updated: "Language preference updated. Please reload the page to apply changes."
143 - invalid_language: "Invalid language code. Supported: fr, en, de, es"
144 -
145 -# Preferences
146 -preferences:
147 - title: "Display Preferences"
148 - page_title: "Display Preferences - RustCloud"
149 - subtitle: "Customize the interface appearance"
150 - theme: "Theme"
151 - theme_light: "Light"
152 - theme_light_desc: "Bright and clean interface"
153 - theme_dark: "Dark"
154 - theme_dark_desc: "Reduces eye strain"
155 - theme_auto: "Automatic"
156 - theme_auto_desc: "Adapts to time of day"
157 - theme_preview: "The theme will be applied immediately after saving"
158 - language: "Language"
159 - language_fr: "Français"
160 - language_fr_desc: "Interface in French"
161 - language_en: "English"
162 - language_en_desc: "English interface"
163 - language_de: "Deutsch"
164 - language_de_desc: "German interface"
165 - language_es: "Español"
166 - language_es_desc: "Spanish interface"
167 - translation_warning: "Complete interface translation is under development. Some elements may remain in French."
168 - modified: "Preferences modified"
169 - modified_desc: "Save your changes to apply them"
170 - save: "Save preferences"
171 - saving: "Saving..."
172 - cancel: "Cancel"
173 - saved_success: "Preferences saved successfully!"
174 - saved_reload: "Preferences saved successfully!\n\nThe language has been changed. Do you want to reload the page now to apply the changes?"
175 - error: "Error: {message}"
176 - network_error: "Network error. Please try again."
177 - sync_title: "Your preferences are synchronized"
178 - sync_desc: "Your display preferences are saved and automatically apply to all devices connected to your RustCloud account."
179 - preview_dark: "Preview: Dark theme will be applied"
180 - preview_light: "Preview: Light theme will be applied"
181 - preview_auto: "Preview: Automatic theme will be applied"
182 -
183 -# Profile
184 -profile:
185 - title: "My Profile"
186 - page_title: "My Profile - RustCloud"
187 - subtitle: "Edit your personal information"
188 - account_info: "Account Information"
189 - username: "Username"
190 - username_public: "Publicly visible"
191 - username_placeholder: "Your username"
192 - username_pattern: "Letters, numbers, dashes and underscores only"
193 - email: "Email"
194 - email_placeholder: "your@email.com"
195 - save: "Save changes"
196 - saving: "Saving..."
197 - saved: "Profile updated successfully"
198 - error: "Error updating profile"
199 - email_label: "Email address"
200 - email_used_for: "Used for login"
201 - email_warning: "⚠️ You will be logged out after modification"
202 - email_change_info: "Email changes require reconnection for security reasons."
203 - username_help: "3-50 characters (letters, numbers, -, _)"
204 - additional_info: "Additional Information"
205 - account_created: "Account created on"
206 - roles: "Roles"
207 -
208 -# Breadcrumb
209 -breadcrumb:
210 - home: "Home"
211 - settings: "Settings"
212 - admin: "Administration"
213 - preferences: "Preferences"
214 - profile: "Profile"
215 - password: "Password"
216 - sessions: "Sessions"
217 -
218 -# Admin
219 -admin:
220 - dashboard:
221 - title: "Administration"
222 - heading: "Administrator Dashboard"
223 - users_card_title: "Users"
224 - users_card_subtitle: "Total users"
225 - access_button: "Access"
226 - sessions_card_title: "Active Sessions"
227 - sessions_card_subtitle: "Ongoing sessions"
228 - admins_card_title: "Administrators"
229 - admins_card_subtitle: "Admin accounts"
230 - last_activity_card_title: "Last Activity"
231 - last_activity_card_subtitle: "Last login"
232 - audit_section_title: "Audit Log Statistics"
233 - audit_total_logs: "Total logs"
234 - audit_last_24h: "Last 24h"
235 - audit_by_level: "By level"
236 - chart_label: "Logs by level"
237 - roles_card_title: "Roles Management"
238 - roles_card_desc: "Manage roles and granular permissions"
239 - roles_stat_title: "Roles"
240 - settings_card_title: "Application Settings"
241 - settings_card_desc: "Configure global application settings"
242 - apps_card_title: "Application Management"
243 - apps_card_desc: "Enable or disable modular applications"
244 - orgs_card_title: "Organisation Management"
245 - orgs_card_desc: "Create and manage organisations and their members"
246 - orgs_stat_title: "Organisations"
247 - orgs_members_stat_title: "Total members"
248 - roles_list:
249 - page_title: "Roles Management"
250 - breadcrumb_roles: "Roles"
251 - title: "Roles"
252 - create_role: "Create a role"
253 - table_name: "Name"
254 - table_description: "Description"
255 - table_users: "Users"
256 - table_permissions: "Permissions"
257 - table_actions: "Actions"
258 - edit: "Edit"
259 - delete: "Delete"
260 - no_roles: "No roles found."
261 - protected_hint: "Protected"
262 - delete_confirm_title: "Confirm deletion"
263 - delete_confirm_message: "Are you sure you want to delete the role"
264 - delete_button: "Delete"
265 - role_form:
266 - title_create: "Create a role"
267 - title_edit: "Edit role"
268 - breadcrumb_create: "Create"
269 - breadcrumb_edit: "Edit"
270 - back_to_list: "Back to list"
271 - name_label: "Role name"
272 - name_placeholder: "e.g. moderator"
273 - description_label: "Description"
274 - description_placeholder: "Role description..."
275 - permissions_label: "Permissions"
276 - protected_name_hint: "This role name cannot be changed."
277 - cancel: "Cancel"
278 - submit_create: "Create role"
279 - submit_edit: "Save changes"
280 - errors:
281 - title: "Error Codes"
282 - description: "Application error code documentation. Each error code is associated with a middleware and provides context information for debugging."
283 - breadcrumb_errors: "Error Codes"
284 - table_code: "Code"
285 - table_description: "Description"
286 - table_category: "Category"
287 - table_middleware: "Middleware"
288 - table_context: "Context"
289 - table_example: "Example"
290 - legend_title: "Legend"
291 - legend_code: "Unique error identifier (format: CATEGORY_NUMBER)"
292 - legend_description: "Description of the error"
293 - legend_category: "Error type (Authentication, CSRF, Rate Limiting, etc.)"
294 - legend_middleware: "Middleware responsible for generating this error"
295 - legend_context: "Detailed explanation of when this error occurs"
296 - legend_example: "Concrete example of a situation generating this error"
297 - error_auth001_desc: "Invalid credentials"
298 - error_auth001_category: "Authentication"
299 - error_auth001_context: "The user provided incorrect credentials during login"
300 - error_auth001_example: "Login attempt with an incorrect password"
301 - error_auth002_desc: "Invalid token"
302 - error_auth002_category: "Authentication"
303 - error_auth002_context: "The provided JWT token is invalid or malformed"
304 - error_auth002_example: "Corrupted JWT token or signed with a different key"
305 - error_auth003_desc: "Token expired"
306 - error_auth003_category: "Authentication"
307 - error_auth003_context: "The JWT token has expired and needs to be renewed"
308 - error_auth003_example: "Access attempt with an expired token after the validity period"
309 - error_csrf001_desc: "Missing CSRF token"
310 - error_csrf001_category: "CSRF Protection"
311 - error_csrf001_context: "The POST/PUT/DELETE request does not contain a CSRF token"
312 - error_csrf001_example: "Form submitted without a CSRF token in the header or body"
313 - error_csrf002_desc: "Invalid CSRF token"
314 - error_csrf002_category: "CSRF Protection"
315 - error_csrf002_context: "The provided CSRF token does not match the session token"
316 - error_csrf002_example: "Modified or stolen CSRF token"
317 - error_rate001_desc: "Rate limit exceeded"
318 - error_rate001_category: "Rate Limiting"
319 - error_rate001_context: "The number of requests per minute has been exceeded"
320 - error_rate001_example: "More than 100 requests/min on admin endpoints"
321 - error_valid001_desc: "Invalid input data"
322 - error_valid001_category: "Validation"
323 - error_valid001_context: "The provided data does not comply with validation rules"
324 - error_valid001_example: "Invalid email format or missing required field"
325 - error_valid002_desc: "Password complexity not met"
326 - error_valid002_category: "Validation"
327 - error_valid002_context: "The password does not meet the configured complexity rules"
328 - error_valid002_example: "Password too short or missing required characters"
329 - error_rbac001_desc: "Insufficient permissions"
330 - error_rbac001_category: "Authorization"
331 - error_rbac001_context: "The user does not have the necessary permissions for this action"
332 - error_rbac001_example: "Non-admin user attempting to access /admin/users"
333 - error_rbac002_desc: "Admin role required"
334 - error_rbac002_category: "Authorization"
335 - error_rbac002_context: "The action requires an administrator role"
336 - error_rbac002_example: "Attempting to delete a user without admin role"
337 - error_db001_desc: "Database operation error"
338 - error_db001_category: "Database"
339 - error_db001_context: "A database operation failed"
340 - error_db001_example: "Foreign key constraint violated or DB connection lost"
341 - error_db002_desc: "Resource not found"
342 - error_db002_category: "Database"
343 - error_db002_context: "The requested resource does not exist in the database"
344 - error_db002_example: "User with non-existent ID"
345 - user_form:
346 - breadcrumb_create: "Create"
347 - breadcrumb_edit: "Edit"
348 - title_create: "Create a user"
349 - title_edit: "Edit a user"
350 - back_to_list: "Back to list"
351 - username_label: "Username"
352 - username_placeholder: "username"
353 - username_readonly_hint: "Username cannot be changed after creation"
354 - email_label: "Email"
355 - email_placeholder: "user@example.com"
356 - password_label: "Password"
357 - password_optional: "Optional"
358 - password_placeholder: "Leave empty to generate a random password"
359 - password_hint: "If left empty, a secure password will be automatically generated"
360 - roles_label: "Roles"
361 - roles_hint: "Select at least one role"
362 - must_change_password_label: "Force password change"
363 - must_change_password_desc: "The user will have to change their password at next login"
364 - cancel: "Cancel"
365 - submit_create: "Create user"
366 - submit_edit: "Save changes"
367 - settings:
368 - title: "Application Settings"
369 - page_title: "Application Settings - Admin - RustCloud"
370 - subtitle: "Global RustCloud configuration"
371 - general: "General"
372 - app_name: "Application name"
373 - app_domain: "Domain"
374 - app_domain_placeholder: "example.com"
375 - app_description: "Description"
376 - allow_registration: "Allow registration"
377 - allow_registration_desc: "Users can create accounts from the home page"
378 - email_validation_required: "Email validation required"
379 - email_validation_required_desc: "Users must validate their email before they can log in"
380 - save: "Save settings"
381 - cancel: "Cancel"
382 - saved: "Settings saved successfully"
383 - error: "Error saving settings"
384 - network_error: "Network error. Please try again."
385 - password_security: "Password Security"
386 - password_min_length: "Minimum length"
387 - password_min_length_hint: "Between 6 and 128 characters"
388 - password_complexity: "Complexity requirements"
389 - require_uppercase: "Uppercase required"
390 - require_lowercase: "Lowercase required"
391 - require_digits: "Digit required"
392 - require_special: "Special character required"
393 - password_change_require_email: "Email confirmation for password change"
394 - password_change_require_email_desc: "When enabled, a confirmation email is sent to validate any password change. Otherwise, the change is applied immediately."
395 - password_expiration: "Password expiration"
396 - password_expiration_desc: "Force periodic password change"
397 - expiration_days: "Expiration after (days)"
398 - expiration_alert: "Email alert before expiration"
399 - expiration_alert_days: "Alert (days before)"
400 - audit_logging: "Audit Logging"
401 - log_level: "Log level"
402 - log_level_debug: "DEBUG - All details"
403 - log_level_info: "INFO - Normal information"
404 - log_level_warn: "WARN - Warnings only"
405 - log_level_error: "ERROR - Errors only"
406 - log_level_hint: "Controls the verbosity of audit logs"
407 - log_retention: "Log retention (days)"
408 - log_retention_hint: "Log retention period (1-365 days)"
409 - audit_info_message: "Audit logs record all administrative actions (creation, modification, deletion of users)."
410 - save_changes: "Save changes"
411 - save_changes_desc: "Settings will be applied immediately"
412 - saving: "Saving..."
413 - save_success: "Settings saved successfully!"
414 - save_error_prefix: "Error"
415 - users_list:
416 - page_title: "User Management - Admin - RustCloud"
417 - title: "User Management"
418 - breadcrumb_users: "Users"
419 - create_user: "Create user"
420 - search_label: "Search"
421 - search_placeholder: "Username or email..."
422 - role_filter_label: "Role"
423 - all_roles: "All roles"
424 - per_page_label: "Per page"
425 - search_button: "Search"
426 - results_summary: "Showing {count} user(s) out of {total} total"
427 - page_info: "Page {page} of {total_pages}"
428 - table_username: "Username"
429 - table_email: "Email"
430 - table_roles: "Roles"
431 - table_created_at: "Created on"
432 - table_last_login: "Last login"
433 - table_actions: "Actions"
434 - never: "Never"
435 - edit: "Edit"
436 - no_users: "No users found"
437 - reset_password_title: "Reset password"
438 - reset_generate: "Generate random password"
439 - reset_custom: "Set custom password"
440 - reset_email: "Reset by email"
441 - reset_new_password_label: "New password"
442 - reset_new_password_placeholder: "Minimum 8 characters, uppercase, lowercase, digit, special character"
443 - reset_password_requirements: "⚠️ Must contain: 8+ characters, uppercase, lowercase, digit, special character"
444 - reset_invalidate_sessions: "Invalidate all active sessions for this user"
445 - reset_invalidate_sessions_desc: "The user will need to log in again after the change"
446 - reset_button: "Reset"
447 - delete_confirm_title: "⚠️ Confirm deletion"
448 - delete_confirm_message: "Are you sure you want to delete this user?"
449 - delete_confirm_username: "Username:"
450 - delete_confirm_email: "Email:"
451 - delete_irreversible: "This action is irreversible!"
452 - delete_button: "Delete permanently"
453 - delete: "Delete"
454 - delete_error: "Error deleting user"
455 - network_error: "Network error"
456 - edit_user_title: "Edit user"
457 - resetting: "Resetting..."
458 - reset_by_email_title: "Reset by email"
459 - close: "Close"
460 - password_reset_success_title: "Password reset"
461 - password_warning_once: "This password will only be displayed once. Copy it now!"
462 - new_password_label: "New password"
463 - copy: "Copy"
464 - password_copied: "Password copied to clipboard"
465 - password_set_success: "Password reset successfully"
466 - reset_error: "Error resetting password"
467 -
468 -# Password Change
469 -password:
470 - title: "Change my password"
471 - page_title: "Change my password - RustCloud"
472 - subtitle: "Modify your password to secure your account. A confirmation email will be sent to validate the change."
473 - subtitle_no_email: "Modify your password to secure your account."
474 - must_change_message: "⚠️ You must change your password before continuing."
475 - new_password: "New password"
476 - current_password: "Current password"
477 - current_password_placeholder: "Your current password"
478 - new_password_placeholder: "Secure new password"
479 - required: "Required"
480 - requirements: "📌 Requirements: 8+ characters, uppercase, lowercase, digit, special character"
481 - strength: "Password strength:"
482 - strength_weak: "Weak"
483 - strength_medium: "Medium"
484 - strength_strong: "Strong ✓"
485 - strength_too_short: "too short"
486 - strength_missing_lowercase: "missing lowercase"
487 - strength_missing_uppercase: "missing uppercase"
488 - strength_missing_digit: "missing digit"
489 - strength_missing_special: "missing special character"
490 - confirm_password: "Confirm new password"
491 - confirm_password_placeholder: "Repeat the new password"
492 - passwords_match: "✓ Passwords match"
493 - passwords_not_match: "✗ Passwords do not match"
494 - change_button: "Change password"
495 - changing: "Changing..."
496 - logout_warning_title: "Logout from other sessions"
497 - logout_warning_desc: "All your other active sessions will be automatically logged out after the password change. Only your current session will remain active."
498 - save: "Save new password"
499 - saving: "Saving..."
500 - saved: "A confirmation email has been sent to your address. Click the link in the email to validate the password change."
501 - saved_direct: "Password changed successfully! Your other sessions have been logged out."
502 - error: "Error changing password"
503 - error_all_fields_required: "All fields are required."
504 - error_empty_data: "The data to send is empty."
505 - error_invalid_data: "Invalid data. Check that all fields are filled."
506 - error_incorrect_current: "Current password is incorrect."
507 - error_reading_response: "Error reading server response"
508 - error_415: "Error 415: The server did not receive the data in the expected JSON format. Please try again."
509 - network_error: "Network error. Please try again."
510 - security_tips_title: "Security Tips"
511 - security_tip_unique: "Use a unique password that you don't use elsewhere"
512 - security_tip_share: "Never share your password with anyone"
513 - security_tip_regular: "Change your password regularly (every 3-6 months)"
514 - security_tip_2fa: "Enable two-factor authentication if available"
515 - security_tip_manager: "Use a password manager for better security"
516 - pending_request_message: "⚠️ A password change request is pending email confirmation."
517 - pending_request_info: "A confirmation email has been sent to your address. Click the link in the email to confirm the change."
518 - pending_request_cancel: "Cancel pending request"
519 - pending_request_canceled: "Request canceled successfully"
520 - pending_request_cancel_error: "Error canceling the request"
521 - confirmation_sent: "Confirmation email sent! Check your inbox."
522 - confirmation_error: "Error sending confirmation email"
523 -
524 -# Password Change Confirmation Email
525 -password_change_confirmation:
526 - subject: "Password Change Confirmation"
527 - greeting: "Hello {username}"
528 - message: "You have requested to change your password for your {app_name} account. To confirm this change, click the link below."
529 - button_text: "Confirm Password Change"
530 - expires_message: "This link is valid for {hours} hours and is single-use."
531 - footer: "This email was sent automatically, please do not reply."
532 -
533 -# Sessions
534 -sessions:
535 - title: "Active sessions"
536 - page_title: "Active sessions - RustCloud"
537 - subtitle: "Manage devices connected to your account"
538 - current_session: "Current session"
539 - current_badge: "Current"
540 - unknown_device: "Unknown device"
541 - ip_address: "IP Address:"
542 - created_at: "Created on:"
543 - last_activity: "Last activity:"
544 - session_id: "Session ID:"
545 - revoke: "Revoke"
546 - revoke_confirm: "Are you sure you want to revoke the session for \"{device}\"?\n\nThis device will be disconnected immediately."
547 - revoke_success: "Session revoked successfully!"
548 - revoke_error: "Error revoking session"
549 - no_sessions: "No active sessions"
550 - no_sessions_desc: "You currently have no active sessions"
551 - danger_zone: "Danger Zone"
552 - danger_zone_desc: "Disconnect all other devices in one action (your current session will remain active)."
553 - revoke_all: "Revoke all other sessions"
554 - revoke_all_confirm: "⚠️ WARNING\n\nAre you sure you want to revoke ALL your other sessions?\n\nAll your other devices will be disconnected. Only your current session will remain active."
555 - revoke_all_success: "All other sessions have been revoked!"
556 - revoke_all_error: "Error revoking. Some sessions may not have been revoked."
557 - about_title: "About sessions"
558 - about_desc: "A session is created each time you log in from a different device or browser. Revoking a session immediately disconnects that device. Your current session cannot be revoked from this page (log out to do that)."
559 - back_to_settings: "Back to settings"
560 -
561 -# Navigation menu (navbar)
562 -nav:
563 - home: "Home"
564 - settings: "Settings"
565 - admin: "Administration"
566 -
567 -# User menu
568 -user_menu:
569 - profile: "Profile"
570 - logout: "Logout"
571 - settings: "Settings"
572 -
573 -# Email verification messages
574 -email_verify:
575 - config_error: "Server configuration error. Please contact the administrator."
576 - success: "Your email address has been successfully verified. You can now log in to your account."
577 - invalid_link: "The verification link is invalid or has expired."
578 -
579 -# Email subjects
580 -email_subject:
581 - password_reset: "Password Reset"
582 - email_validation: "Verify Your Email Address"
583 - welcome: "Welcome to RustWarden!"
584 -
585 -# Rate limiting
586 -rate_limit:
587 - exceeded: "Too many requests. Please try again later."
588 - password_change: "Too many password change requests. Please wait before trying again."
589 -
590 -# Sessions
591 -session:
592 - ip_unavailable: "Unavailable"
593 -
D crates/rustwarden-ui/locales/es.yml
+0 -572
@@ -1,572 +0,0 @@
1 -# Traducciones en español para RustCloud
2 -# Formato: claves jerárquicas con puntos
3 -
4 -# Inicio de sesión
5 -login:
6 - title: "Iniciar sesión"
7 - page_title: "Iniciar sesión - RustCloud"
8 - page_description: "Iniciar sesión en RustCloud"
9 - subtitle: "Inicie sesión en su espacio en la nube"
10 - username: "Nombre de usuario"
11 - password: "Contraseña"
12 - submit: "Iniciar sesión"
13 - remember_me: "Recordarme"
14 - tab_login: "Iniciar sesión"
15 - tab_register: "Registrarse"
16 - register_title: "Crear una cuenta"
17 - register_submit: "Crear mi cuenta"
18 - username_placeholder: "Su nombre de usuario"
19 - password_placeholder: "Su contraseña"
20 - username_placeholder_register: "Su nombre de usuario (mín. 3 caracteres)"
21 - password_placeholder_register: "Su contraseña (mín. 8 caracteres)"
22 - email_placeholder: "su@email.com"
23 - forgot_password: "¿Olvidó su contraseña?"
24 - email_not_verified: "¿Email no verificado?"
25 - resend_verification: "Reenviar email de verificación"
26 - already_have_account: "¿Ya tiene una cuenta?"
27 - sign_in_link: "Iniciar sesión"
28 - success_message: "¡Inicio de sesión exitoso! Redirigiendo..."
29 - error_message: "Error de inicio de sesión. Verifique sus credenciales."
30 - email_unverified_message: "Su email no está verificado. Haga clic a continuación para reenviar el enlace."
31 - resend_button: "Reenviar email de verificación"
32 - resend_success: "Email de verificación enviado si la cuenta existe."
33 - resend_error: "No se puede enviar la solicitud en este momento."
34 - resend_prompt: "Por favor ingrese su email antes de reenviar."
35 - register_success: "¡Cuenta creada con éxito! Por favor verifique su email."
36 - register_error: "Error en el registro"
37 - email_label: "Correo electrónico"
38 - footer_text: "Fork de Nextcloud en Rust"
39 -
40 -# Mensajes de error
41 -error:
42 - invalid_credentials: "Credenciales inválidas"
43 - email_invalid: "Email inválido"
44 - required_field: "Este campo es obligatorio"
45 - unauthorized: "No autorizado"
46 - not_found: "Recurso no encontrado"
47 - validation: "Error de validación: {message}"
48 - database: "Error de base de datos"
49 - internal: "Error interno del servidor"
50 - invalid_token: "Token inválido"
51 - token_expired: "Token expirado"
52 - token_reused: "Token reutilizado - por favor vuelva a autenticarse"
53 - must_change_password: "Cambio de contraseña requerido"
54 - authentication_required: "Autenticación requerida"
55 - insufficient_permissions: "Permisos insuficientes"
56 - config: "Error de configuración del servidor"
57 - template: "Error de plantilla"
58 - validation_field:
59 - username_min_length: "El nombre de usuario debe contener al menos {min} caracteres"
60 - password_min_length: "La contraseña debe contener al menos {min} caracteres"
61 - email_format: "La dirección de correo electrónico no es válida"
62 - password_require_uppercase: "La contraseña debe contener al menos una letra mayúscula"
63 - password_require_lowercase: "La contraseña debe contener al menos una letra minúscula"
64 - password_require_digits: "La contraseña debe contener al menos un dígito"
65 - password_require_special: "La contraseña debe contener al menos un carácter especial (!@#$%^&*()_+-=[]{}|;:,.<>?)"
66 -
67 -# Reenviar email de verificación
68 -resend_verification:
69 - page_title: "Reenviar email de verificación - RustWarden"
70 - title: "Reenviar email de verificación"
71 - subtitle: "Ingrese su dirección de correo electrónico para recibir un nuevo enlace de verificación."
72 - email_label: "Dirección de correo electrónico"
73 - email_placeholder: "usted@ejemplo.com"
74 - submit: "Enviar enlace de verificación"
75 - back_to_login: "Volver al inicio de sesión"
76 - error_default: "No se pudo enviar la solicitud."
77 - success_default: "Si la cuenta existe y no está verificada, se ha enviado un email."
78 - error_missing_email: "Por favor ingrese su correo electrónico."
79 - error_cannot_send: "No se puede enviar la solicitud en este momento."
80 -
81 -# Restablecimiento de contraseña (desde enlace de email)
82 -password_reset:
83 - page_title: "Restablecer contraseña"
84 - title: "Restablecer contraseña"
85 - subtitle: "Ingrese su nueva contraseña a continuación."
86 - new_password_label: "Nueva contraseña"
87 - new_password_placeholder: "Nueva contraseña segura"
88 - requirements: "⚠️ Debe contener: 8+ caracteres, mayúscula, minúscula, dígito, carácter especial"
89 - confirm_password_label: "Confirmar contraseña"
90 - confirm_password_placeholder: "Confirme su nueva contraseña"
91 - missing_token_url: "Falta el token de restablecimiento en la URL."
92 - missing_token: "Falta el token de restablecimiento."
93 - missing_new_password: "Por favor ingrese una nueva contraseña."
94 - passwords_not_match: "Las contraseñas no coinciden."
95 - success_message: "Su contraseña ha sido restablecida con éxito. Ahora puede iniciar sesión."
96 - error_link_invalid: "No se pudo restablecer la contraseña. El enlace puede haber expirado o ser inválido."
97 - error_generic: "No se puede restablecer la contraseña en este momento."
98 -
99 -# Configuración
100 -settings:
101 - title: "Configuración"
102 - page_title: "Configuración - RustWarden"
103 - subtitle: "Gestione sus preferencias y configuración de cuenta"
104 - profile_title: "Mi Perfil"
105 - profile_desc: "Edite su información personal (nombre de usuario, email)"
106 - password_title: "Contraseña"
107 - password_desc: "Cambie su contraseña para asegurar su cuenta"
108 - sessions_title: "Sesiones activas"
109 - sessions_desc: "Gestione sus sesiones activas y revoque el acceso desde otros dispositivos"
110 - preferences_title: "Preferencias"
111 - preferences_desc: "Personalice su experiencia (tema, idioma)"
112 - access: "Acceder"
113 - info_title: "Información"
114 - info_desc: "Utilice las secciones anteriores para gestionar diferentes aspectos de su cuenta. Todos los cambios son seguros y requieren su autenticación."
115 - language: "Idioma"
116 - language_description: "Elija su idioma preferido"
117 - save: "Guardar"
118 - saved: "Configuración guardada"
119 -
120 -# Página de inicio
121 -home:
122 - welcome: "¡Bienvenido, {username}!"
123 - subtitle: "Bienvenido a tu espacio personal"
124 - my_account: "Mi Cuenta"
125 - stat_username: "Nombre de usuario"
126 - stat_email: "Correo electrónico"
127 - stat_member_since: "Miembro desde"
128 - stat_roles: "Roles"
129 -
130 -# Mensajes comunes
131 -common:
132 - welcome: "Bienvenido {username}"
133 - loading: "Cargando..."
134 - save: "Guardar"
135 - cancel: "Cancelar"
136 - delete: "Eliminar"
137 - edit: "Editar"
138 - create: "Crear"
139 -
140 -# Preferencias
141 -preferences:
142 - title: "Preferencias de visualización"
143 - page_title: "Preferencias de visualización - RustCloud"
144 - subtitle: "Personalice la apariencia de la interfaz"
145 - theme: "Tema"
146 - theme_light: "Claro"
147 - theme_light_desc: "Interfaz brillante y limpia"
148 - theme_dark: "Oscuro"
149 - theme_dark_desc: "Reduce la fatiga visual"
150 - theme_auto: "Automático"
151 - theme_auto_desc: "Se adapta a la hora del día"
152 - theme_preview: "El tema se aplicará inmediatamente después de guardar"
153 - language: "Idioma"
154 - language_fr: "Français"
155 - language_fr_desc: "Interfaz en francés"
156 - language_en: "English"
157 - language_en_desc: "Interfaz en inglés"
158 - language_de: "Deutsch"
159 - language_de_desc: "Interfaz en alemán"
160 - language_es: "Español"
161 - language_es_desc: "Interfaz en español"
162 - translation_warning: "La traducción completa de la interfaz está en desarrollo. Algunos elementos pueden permanecer en francés."
163 - modified: "Preferencias modificadas"
164 - modified_desc: "Guarde sus cambios para aplicarlos"
165 - save: "Guardar preferencias"
166 - saving: "Guardando..."
167 - cancel: "Cancelar"
168 - saved_success: "¡Preferencias guardadas con éxito!"
169 - saved_reload: "¡Preferencias guardadas con éxito!\n\nEl idioma ha sido cambiado. ¿Desea recargar la página ahora para aplicar los cambios?"
170 - error: "Error: {message}"
171 - network_error: "Error de red. Por favor intente nuevamente."
172 - sync_title: "Sus preferencias están sincronizadas"
173 - sync_desc: "Sus preferencias de visualización se guardan y se aplican automáticamente en todos los dispositivos conectados a su cuenta RustCloud."
174 - preview_dark: "Vista previa: Se aplicará el tema oscuro"
175 - preview_light: "Vista previa: Se aplicará el tema claro"
176 - preview_auto: "Vista previa: Se aplicará el tema automático"
177 -
178 -# Perfil
179 -profile:
180 - title: "Mi Perfil"
181 - page_title: "Mi Perfil - RustCloud"
182 - subtitle: "Edite su información personal"
183 - account_info: "Información de la cuenta"
184 - username: "Nombre de usuario"
185 - username_public: "Visible públicamente"
186 - username_placeholder: "Su nombre de usuario"
187 - username_pattern: "Solo letras, números, guiones y guiones bajos"
188 - email: "Email"
189 - email_placeholder: "su@email.com"
190 - save: "Guardar cambios"
191 - saving: "Guardando..."
192 - saved: "Perfil actualizado con éxito"
193 - error: "Error al actualizar el perfil"
194 - email_label: "Dirección de correo electrónico"
195 - email_used_for: "Se utiliza para iniciar sesión"
196 - email_warning: "⚠️ Será desconectado después de la modificación"
197 - email_change_info: "Los cambios de correo electrónico requieren reconexión por razones de seguridad."
198 - username_help: "3-50 caracteres (letras, números, -, _)"
199 - additional_info: "Información adicional"
200 - account_created: "Cuenta creada el"
201 - roles: "Roles"
202 -
203 -# Breadcrumb
204 -breadcrumb:
205 - home: "Inicio"
206 - settings: "Configuración"
207 - admin: "Administración"
208 - preferences: "Preferencias"
209 - profile: "Perfil"
210 - password: "Contraseña"
211 - sessions: "Sesiones"
212 -
213 -# Administración
214 -admin:
215 - dashboard:
216 - title: "Administración"
217 - heading: "Panel de Administración"
218 - users_card_title: "Usuarios"
219 - users_card_subtitle: "Usuarios totales"
220 - access_button: "Acceder"
221 - sessions_card_title: "Sesiones activas"
222 - sessions_card_subtitle: "Sesiones en curso"
223 - admins_card_title: "Administradores"
224 - admins_card_subtitle: "Cuentas admin"
225 - last_activity_card_title: "Última actividad"
226 - last_activity_card_subtitle: "Último inicio de sesión"
227 - audit_section_title: "Estadísticas de logs de auditoría"
228 - audit_total_logs: "Logs totales"
229 - audit_last_24h: "Últimas 24h"
230 - audit_by_level: "Por nivel"
231 - chart_label: "Logs por nivel"
232 - roles_card_title: "Gestión de Roles"
233 - roles_card_desc: "Gestionar roles y permisos granulares"
234 - roles_stat_title: "Roles"
235 - settings_card_title: "Configuración de la Aplicación"
236 - settings_card_desc: "Configurar los parámetros globales de la aplicación"
237 - apps_card_title: "Gestión de Aplicaciones"
238 - apps_card_desc: "Activar o desactivar aplicaciones modulares"
239 - orgs_card_title: "Gestión de Organizaciones"
240 - orgs_card_desc: "Crear y gestionar organizaciones y sus miembros"
241 - orgs_stat_title: "Organizaciones"
242 - orgs_members_stat_title: "Miembros totales"
243 - roles_list:
244 - page_title: "Gestión de roles"
245 - breadcrumb_roles: "Roles"
246 - title: "Roles"
247 - create_role: "Crear un rol"
248 - table_name: "Nombre"
249 - table_description: "Descripción"
250 - table_users: "Usuarios"
251 - table_permissions: "Permisos"
252 - table_actions: "Acciones"
253 - edit: "Editar"
254 - delete: "Eliminar"
255 - no_roles: "No se encontraron roles."
256 - protected_hint: "Protegido"
257 - delete_confirm_title: "Confirmar eliminación"
258 - delete_confirm_message: "¿Está seguro de que desea eliminar el rol"
259 - delete_button: "Eliminar"
260 - role_form:
261 - title_create: "Crear un rol"
262 - title_edit: "Editar rol"
263 - breadcrumb_create: "Crear"
264 - breadcrumb_edit: "Editar"
265 - back_to_list: "Volver a la lista"
266 - name_label: "Nombre del rol"
267 - name_placeholder: "ej: moderator"
268 - description_label: "Descripción"
269 - description_placeholder: "Descripción del rol..."
270 - permissions_label: "Permisos"
271 - protected_name_hint: "El nombre de este rol no se puede cambiar."
272 - cancel: "Cancelar"
273 - submit_create: "Crear rol"
274 - submit_edit: "Guardar cambios"
275 - errors:
276 - title: "Códigos de Error"
277 - description: "Documentación de los códigos de error de la aplicación. Cada código de error está asociado a un middleware y proporciona información de contexto para la depuración."
278 - breadcrumb_errors: "Códigos de Error"
279 - table_code: "Código"
280 - table_description: "Descripción"
281 - table_category: "Categoría"
282 - table_middleware: "Middleware"
283 - table_context: "Contexto"
284 - table_example: "Ejemplo"
285 - legend_title: "Leyenda"
286 - legend_code: "Identificador único del error (formato: CATEGORY_NUMBER)"
287 - legend_description: "Descripción del error"
288 - legend_category: "Tipo de error (Autenticación, CSRF, Rate Limiting, etc.)"
289 - legend_middleware: "Middleware responsable de generar este error"
290 - legend_context: "Explicación detallada de cuándo ocurre este error"
291 - legend_example: "Ejemplo concreto de una situación que genera este error"
292 - error_auth001_desc: "Credenciales inválidas"
293 - error_auth001_category: "Autenticación"
294 - error_auth001_context: "El usuario proporcionó credenciales incorrectas al iniciar sesión"
295 - error_auth001_example: "Intento de inicio de sesión con contraseña incorrecta"
296 - error_auth002_desc: "Token inválido"
297 - error_auth002_category: "Autenticación"
298 - error_auth002_context: "El token JWT proporcionado es inválido o está malformado"
299 - error_auth002_example: "Token JWT corrupto o firmado con una clave diferente"
300 - error_auth003_desc: "Token expirado"
301 - error_auth003_category: "Autenticación"
302 - error_auth003_context: "El token JWT ha expirado y debe renovarse"
303 - error_auth003_example: "Intento de acceso con un token expirado después del período de validez"
304 - error_csrf001_desc: "Token CSRF faltante"
305 - error_csrf001_category: "Protección CSRF"
306 - error_csrf001_context: "La solicitud POST/PUT/DELETE no contiene un token CSRF"
307 - error_csrf001_example: "Formulario enviado sin token CSRF en el header o body"
308 - error_csrf002_desc: "Token CSRF inválido"
309 - error_csrf002_category: "Protección CSRF"
310 - error_csrf002_context: "El token CSRF proporcionado no coincide con el token de sesión"
311 - error_csrf002_example: "Token CSRF modificado o robado"
312 - error_rate001_desc: "Límite de tasa excedido"
313 - error_rate001_category: "Rate Limiting"
314 - error_rate001_context: "Se ha excedido el número de solicitudes por minuto"
315 - error_rate001_example: "Más de 100 solicitudes/min en endpoints admin"
316 - error_valid001_desc: "Datos de entrada inválidos"
317 - error_valid001_category: "Validación"
318 - error_valid001_context: "Los datos proporcionados no cumplen con las reglas de validación"
319 - error_valid001_example: "Formato de email inválido o campo requerido faltante"
320 - error_valid002_desc: "Complejidad de contraseña no cumplida"
321 - error_valid002_category: "Validación"
322 - error_valid002_context: "La contraseña no cumple con las reglas de complejidad configuradas"
323 - error_valid002_example: "Contraseña demasiado corta o sin los caracteres requeridos"
324 - error_rbac001_desc: "Permisos insuficientes"
325 - error_rbac001_category: "Autorización"
326 - error_rbac001_context: "El usuario no tiene los permisos necesarios para esta acción"
327 - error_rbac001_example: "Usuario no administrador intentando acceder a /admin/users"
328 - error_rbac002_desc: "Rol de administrador requerido"
329 - error_rbac002_category: "Autorización"
330 - error_rbac002_context: "La acción requiere un rol de administrador"
331 - error_rbac002_example: "Intento de eliminar un usuario sin rol admin"
332 - error_db001_desc: "Error de operación de base de datos"
333 - error_db001_category: "Base de datos"
334 - error_db001_context: "Una operación de base de datos falló"
335 - error_db001_example: "Restricción de clave foránea violada o conexión DB perdida"
336 - error_db002_desc: "Recurso no encontrado"
337 - error_db002_category: "Base de datos"
338 - error_db002_context: "El recurso solicitado no existe en la base de datos"
339 - error_db002_example: "Usuario con ID inexistente"
340 - user_form:
341 - breadcrumb_create: "Crear"
342 - breadcrumb_edit: "Modificar"
343 - title_create: "Crear un usuario"
344 - title_edit: "Modificar un usuario"
345 - back_to_list: "Volver a la lista"
346 - username_label: "Nombre de usuario"
347 - username_placeholder: "nombre_usuario"
348 - username_readonly_hint: "El nombre de usuario no se puede cambiar después de la creación"
349 - email_label: "Email"
350 - email_placeholder: "usuario@example.com"
351 - password_label: "Contraseña"
352 - password_optional: "Opcional"
353 - password_placeholder: "Dejar vacío para generar una contraseña aleatoria"
354 - password_hint: "Si se deja vacío, se generará automáticamente una contraseña segura"
355 - roles_label: "Roles"
356 - roles_hint: "Seleccione al menos un rol"
357 - must_change_password_label: "Forzar cambio de contraseña"
358 - must_change_password_desc: "El usuario deberá cambiar su contraseña en el próximo inicio de sesión"
359 - cancel: "Cancelar"
360 - submit_create: "Crear usuario"
361 - submit_edit: "Guardar cambios"
362 - settings:
363 - title: "Configuración de la aplicación"
364 - page_title: "Configuración de la aplicación - Admin - RustCloud"
365 - subtitle: "Configuración global de RustCloud"
366 - general: "General"
367 - app_name: "Nombre de la aplicación"
368 - app_domain: "Dominio"
369 - app_domain_placeholder: "example.com"
370 - app_description: "Descripción"
371 - allow_registration: "Permitir registro"
372 - allow_registration_desc: "Los usuarios pueden crear cuentas desde la página de inicio"
373 - email_validation_required: "Validación de email requerida"
374 - email_validation_required_desc: "Los usuarios deben validar su email antes de poder iniciar sesión"
375 - save: "Guardar configuración"
376 - cancel: "Cancelar"
377 - saved: "Configuración guardada con éxito"
378 - error: "Error al guardar la configuración"
379 - network_error: "Error de red. Por favor intente nuevamente."
380 - password_security: "Seguridad de Contraseñas"
381 - password_min_length: "Longitud mínima"
382 - password_min_length_hint: "Entre 6 y 128 caracteres"
383 - password_complexity: "Requisitos de complejidad"
384 - require_uppercase: "Mayúscula requerida"
385 - require_lowercase: "Minúscula requerida"
386 - require_digits: "Dígito requerido"
387 - require_special: "Carácter especial requerido"
388 - password_change_require_email: "Confirmación por email del cambio de contraseña"
389 - password_change_require_email_desc: "Cuando está activado, se envía un correo de confirmación para validar cualquier cambio de contraseña. De lo contrario, el cambio se aplica inmediatamente."
390 - password_expiration: "Expiración de contraseñas"
391 - password_expiration_desc: "Forzar cambio periódico de contraseña"
392 - expiration_days: "Expiración después de (días)"
393 - expiration_alert: "Alerta por email antes de la expiración"
394 - expiration_alert_days: "Alerta (días antes)"
395 - audit_logging: "Registro de Auditoría"
396 - log_level: "Nivel de log"
397 - log_level_debug: "DEBUG - Todos los detalles"
398 - log_level_info: "INFO - Información normal"
399 - log_level_warn: "WARN - Solo advertencias"
400 - log_level_error: "ERROR - Solo errores"
401 - log_level_hint: "Controla la verbosidad de los logs de auditoría"
402 - log_retention: "Retención de logs (días)"
403 - log_retention_hint: "Período de retención de logs (1-365 días)"
404 - audit_info_message: "Los logs de auditoría registran todas las acciones administrativas (creación, modificación, eliminación de usuarios)."
405 - save_changes: "Guardar cambios"
406 - save_changes_desc: "La configuración se aplicará inmediatamente"
407 - saving: "Guardando..."
408 - save_success: "¡Configuración guardada con éxito!"
409 - save_error_prefix: "Error"
410 - users_list:
411 - page_title: "Gestión de Usuarios - Admin - RustCloud"
412 - title: "Gestión de Usuarios"
413 - breadcrumb_users: "Usuarios"
414 - create_user: "Crear usuario"
415 - search_label: "Buscar"
416 - search_placeholder: "Nombre de usuario o email..."
417 - role_filter_label: "Rol"
418 - all_roles: "Todos los roles"
419 - per_page_label: "Por página"
420 - search_button: "Buscar"
421 - results_summary: "Mostrando {count} usuario(s) de {total} total"
422 - page_info: "Página {page} de {total_pages}"
423 - table_username: "Nombre de usuario"
424 - table_email: "Email"
425 - table_roles: "Roles"
426 - table_created_at: "Creado el"
427 - table_last_login: "Último inicio de sesión"
428 - table_actions: "Acciones"
429 - never: "Nunca"
430 - edit: "Modificar"
431 - no_users: "No se encontraron usuarios"
432 - reset_password_title: "Restablecer contraseña"
433 - reset_generate: "Generar contraseña aleatoria"
434 - reset_custom: "Establecer contraseña personalizada"
435 - reset_new_password_label: "Nueva contraseña"
436 - reset_new_password_placeholder: "Mínimo 8 caracteres, mayúscula, minúscula, dígito, carácter especial"
437 - reset_password_requirements: "⚠️ Debe contener: 8+ caracteres, mayúscula, minúscula, dígito, carácter especial"
438 - reset_invalidate_sessions: "Invalidar todas las sesiones activas de este usuario"
439 - reset_invalidate_sessions_desc: "El usuario deberá iniciar sesión nuevamente después del cambio"
440 - reset_button: "Restablecer"
441 - delete_confirm_title: "⚠️ Confirmar eliminación"
442 - delete_confirm_message: "¿Está seguro de que desea eliminar este usuario?"
443 - delete_confirm_username: "Nombre de usuario:"
444 - delete_confirm_email: "Email:"
445 - delete_irreversible: "¡Esta acción es irreversible!"
446 - delete_button: "Eliminar permanentemente"
447 - delete: "Eliminar"
448 - delete_error: "Error al eliminar el usuario"
449 - network_error: "Error de red"
450 - edit_user_title: "Modificar usuario"
451 - resetting: "Restableciendo..."
452 - reset_by_email_title: "Restablecer por email"
453 - reset_email: "Restablecer por email"
454 - close: "Cerrar"
455 - password_reset_success_title: "Contraseña restablecida"
456 - password_warning_once: "¡Esta contraseña solo se mostrará una vez. Cópiela ahora!"
457 - new_password_label: "Nueva contraseña"
458 - copy: "Copiar"
459 - password_copied: "Contraseña copiada al portapapeles"
460 - password_set_success: "Contraseña restablecida con éxito"
461 - reset_error: "Error al restablecer la contraseña"
462 -
463 -# Cambio de contraseña
464 -password:
465 - title: "Cambiar mi contraseña"
466 - page_title: "Cambiar mi contraseña - RustCloud"
467 - subtitle: "Modifique su contraseña para asegurar su cuenta. Se enviará un correo electrónico de confirmación para validar el cambio."
468 - subtitle_no_email: "Modifique su contraseña para asegurar su cuenta."
469 - must_change_message: "⚠️ Debe cambiar su contraseña antes de continuar."
470 - new_password: "Nueva contraseña"
471 - current_password: "Contraseña actual"
472 - current_password_placeholder: "Su contraseña actual"
473 - new_password_placeholder: "Nueva contraseña segura"
474 - required: "Requerido"
475 - requirements: "📌 Requisitos: 8+ caracteres, mayúscula, minúscula, dígito, carácter especial"
476 - strength: "Fortaleza de la contraseña:"
477 - strength_weak: "Débil"
478 - strength_medium: "Medio"
479 - strength_strong: "Fuerte ✓"
480 - strength_too_short: "demasiado corta"
481 - strength_missing_lowercase: "falta minúscula"
482 - strength_missing_uppercase: "falta mayúscula"
483 - strength_missing_digit: "falta dígito"
484 - strength_missing_special: "falta carácter especial"
485 - confirm_password: "Confirmar nueva contraseña"
486 - confirm_password_placeholder: "Repita la nueva contraseña"
487 - passwords_match: "✓ Las contraseñas coinciden"
488 - passwords_not_match: "✗ Las contraseñas no coinciden"
489 - change_button: "Cambiar contraseña"
490 - changing: "Cambiando..."
491 - logout_warning_title: "Cerrar sesión de otras sesiones"
492 - logout_warning_desc: "Todas sus otras sesiones activas se cerrarán automáticamente después del cambio de contraseña. Solo su sesión actual permanecerá activa."
493 - save: "Guardar nueva contraseña"
494 - saving: "Guardando..."
495 - saved: "Se ha enviado un correo electrónico de confirmación a su dirección. Haga clic en el enlace del correo para validar el cambio de contraseña."
496 - saved_direct: "¡Contraseña cambiada con éxito! Sus otras sesiones han sido cerradas."
497 - error: "Error al cambiar la contraseña"
498 - error_all_fields_required: "Todos los campos son obligatorios."
499 - error_empty_data: "Los datos a enviar están vacíos."
500 - error_invalid_data: "Datos inválidos. Verifique que todos los campos estén completos."
501 - error_incorrect_current: "La contraseña actual es incorrecta."
502 - error_reading_response: "Error al leer la respuesta del servidor"
503 - error_415: "Error 415: El servidor no recibió los datos en el formato JSON esperado. Por favor intente nuevamente."
504 - network_error: "Error de red. Por favor intente nuevamente."
505 - security_tips_title: "Consejos de seguridad"
506 - security_tip_unique: "Use una contraseña única que no use en otro lugar"
507 - security_tip_share: "Nunca comparta su contraseña con nadie"
508 - security_tip_regular: "Cambie su contraseña regularmente (cada 3-6 meses)"
509 - security_tip_2fa: "Active la autenticación de dos factores si está disponible"
510 - security_tip_manager: "Use un administrador de contraseñas para mayor seguridad"
511 -
512 -# Sesiones
513 -sessions:
514 - title: "Sesiones activas"
515 - page_title: "Sesiones activas - RustCloud"
516 - subtitle: "Gestione los dispositivos conectados a su cuenta"
517 - current_session: "Sesión actual"
518 - current_badge: "Actual"
519 - unknown_device: "Dispositivo desconocido"
520 - ip_address: "Dirección IP:"
521 - created_at: "Creada el:"
522 - last_activity: "Última actividad:"
523 - session_id: "ID de sesión:"
524 - revoke: "Revocar"
525 - revoke_confirm: "¿Está seguro de que desea revocar la sesión para \"{device}\"?\n\nEste dispositivo será desconectado inmediatamente."
526 - revoke_success: "¡Sesión revocada con éxito!"
527 - revoke_error: "Error al revocar la sesión"
528 - no_sessions: "No hay sesiones activas"
529 - no_sessions_desc: "Actualmente no tiene sesiones activas"
530 - danger_zone: "Zona de peligro"
531 - danger_zone_desc: "Desconecte todos los demás dispositivos en una sola acción (su sesión actual permanecerá activa)."
532 - revoke_all: "Revocar todas las demás sesiones"
533 - revoke_all_confirm: "⚠️ ADVERTENCIA\n\n¿Está seguro de que desea revocar TODAS sus demás sesiones?\n\nTodos sus demás dispositivos serán desconectados. Solo su sesión actual permanecerá activa."
534 - revoke_all_success: "¡Todas las demás sesiones han sido revocadas!"
535 - revoke_all_error: "Error al revocar. Es posible que algunas sesiones no hayan sido revocadas."
536 - about_title: "Acerca de las sesiones"
537 - about_desc: "Se crea una sesión cada vez que inicia sesión desde un dispositivo o navegador diferente. Revocar una sesión desconecta inmediatamente ese dispositivo. Su sesión actual no puede ser revocada desde esta página (cierre sesión para hacerlo)."
538 - back_to_settings: "Volver a configuración"
539 -
540 -# Navigation menu (navbar)
541 -nav:
542 - home: "Inicio"
543 - settings: "Configuración"
544 - admin: "Administración"
545 -
546 -# User menu
547 -user_menu:
548 - profile: "Perfil"
549 - logout: "Cerrar sesión"
550 - settings: "Configuración"
551 -
552 -# Email verification messages
553 -email_verify:
554 - config_error: "Error de configuración del servidor. Por favor, contacte al administrador."
555 - success: "Su dirección de correo electrónico ha sido verificada con éxito. Ahora puede iniciar sesión en su cuenta."
556 - invalid_link: "El enlace de verificación es inválido o ha expirado."
557 -
558 -# Email subjects
559 -email_subject:
560 - password_reset: "Restablecimiento de contraseña"
561 - email_validation: "Verifique su dirección de correo electrónico"
562 - welcome: "¡Bienvenido a RustWarden!"
563 -
564 -# Rate limiting
565 -rate_limit:
566 - exceeded: "Demasiadas solicitudes. Por favor, inténtelo de nuevo más tarde."
567 - password_change: "Demasiadas solicitudes de cambio de contraseña. Por favor, espere antes de intentarlo de nuevo."
568 -
569 -# Sessions
570 -session:
571 - ip_unavailable: "No disponible"
572 -
D crates/rustwarden-ui/locales/fr.yml
+0 -695
@@ -1,695 +0,0 @@
1 -# Traductions françaises pour RustCloud
2 -# Format: clés hiérarchiques avec points
3 -
4 -# Connexion
5 -login:
6 - title: "Connexion"
7 - page_title: "Connexion - RustCloud"
8 - page_description: "Connexion à RustCloud"
9 - subtitle: "Connectez-vous à votre espace cloud"
10 - username: "Nom d'utilisateur"
11 - password: "Mot de passe"
12 - submit: "Se connecter"
13 - remember_me: "Se souvenir de moi"
14 - tab_login: "Connexion"
15 - tab_register: "Inscription"
16 - register_title: "Créer un compte"
17 - register_submit: "Créer mon compte"
18 - username_placeholder: "Votre nom d'utilisateur"
19 - password_placeholder: "Votre mot de passe"
20 - username_placeholder_register: "Votre nom d'utilisateur (min. 3 caractères)"
21 - password_placeholder_register: "Votre mot de passe (min. 8 caractères)"
22 - email_placeholder: "votre@email.com"
23 - forgot_password: "Mot de passe oublié ?"
24 - email_not_verified: "Email non vérifié ?"
25 - resend_verification: "Renvoyer l'email de validation"
26 - already_have_account: "Déjà un compte ?"
27 - sign_in_link: "Se connecter"
28 - success_message: "Connexion réussie ! Redirection..."
29 - error_message: "Erreur de connexion. Vérifiez vos identifiants."
30 - email_unverified_message: "Votre email n'est pas vérifié. Cliquez ci-dessous pour renvoyer le lien."
31 - resend_button: "Renvoyer l'email de validation"
32 - resend_success: "Email de validation renvoyé si le compte existe."
33 - resend_error: "Impossible d'envoyer la demande pour l'instant."
34 - resend_prompt: "Veuillez renseigner votre email avant de renvoyer."
35 - register_success: "Compte créé avec succès ! Veuillez vérifier votre email."
36 - register_error: "Erreur lors de l'inscription"
37 - email_label: "Email"
38 - footer_text: "Fork de Nextcloud en Rust"
39 -
40 -# Renvoyer l'email de validation
41 -resend_verification:
42 - page_title: "Renvoyer l'email de validation - RustWarden"
43 - title: "Renvoyer l'email de validation"
44 - subtitle: "Entrez votre adresse email pour recevoir un nouveau lien de validation."
45 - email_label: "Adresse email"
46 - email_placeholder: "vous@example.com"
47 - submit: "Envoyer le lien de validation"
48 - back_to_login: "Revenir à la connexion"
49 - error_default: "Impossible d'envoyer la demande."
50 - success_default: "Si le compte existe et n'est pas vérifié, un email a été envoyé."
51 - error_missing_email: "Veuillez renseigner votre email."
52 - error_cannot_send: "Impossible d'envoyer la demande pour l'instant."
53 -
54 -# Réinitialisation de mot de passe (via lien email)
55 -password_reset:
56 - page_title: "Réinitialiser le mot de passe"
57 - title: "Réinitialiser le mot de passe"
58 - subtitle: "Entrez votre nouveau mot de passe ci-dessous."
59 - new_password_label: "Nouveau mot de passe"
60 - new_password_placeholder: "Nouveau mot de passe sécurisé"
61 - requirements: "⚠️ Doit contenir : 8+ caractères, majuscule, minuscule, chiffre, caractère spécial"
62 - confirm_password_label: "Confirmer le mot de passe"
63 - confirm_password_placeholder: "Confirmez votre nouveau mot de passe"
64 - missing_token_url: "Token de réinitialisation manquant dans l'URL."
65 - missing_token: "Token de réinitialisation manquant."
66 - missing_new_password: "Veuillez entrer un nouveau mot de passe."
67 - passwords_not_match: "Les mots de passe ne correspondent pas."
68 - success_message: "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter."
69 - error_link_invalid: "Impossible de réinitialiser le mot de passe. Le lien peut être expiré ou invalide."
70 - error_generic: "Impossible de réinitialiser le mot de passe pour l'instant."
71 -
72 -# Messages d'erreur
73 -error:
74 - invalid_credentials: "Identifiants incorrects"
75 - email_invalid: "Email invalide"
76 - required_field: "Ce champ est requis"
77 - unauthorized: "Non autorisé"
78 - not_found: "Ressource non trouvée"
79 - validation: "Erreur de validation : {message}"
80 - database: "Erreur de base de données"
81 - internal: "Erreur interne du serveur"
82 - invalid_token: "Token invalide"
83 - token_expired: "Token expiré"
84 - token_reused: "Token réutilisé - veuillez vous ré-authentifier"
85 - must_change_password: "Changement de mot de passe requis"
86 - authentication_required: "Authentification requise"
87 - insufficient_permissions: "Permissions insuffisantes"
88 - config: "Erreur de configuration du serveur"
89 - template: "Erreur de rendu de template"
90 - validation_field:
91 - username_min_length: "Le nom d'utilisateur doit contenir au moins {min} caractères"
92 - password_min_length: "Le mot de passe doit contenir au moins {min} caractères"
93 - email_format: "L'adresse email n'est pas valide"
94 - password_require_uppercase: "Le mot de passe doit contenir au moins une majuscule"
95 - password_require_lowercase: "Le mot de passe doit contenir au moins une minuscule"
96 - password_require_digits: "Le mot de passe doit contenir au moins un chiffre"
97 - password_require_special: "Le mot de passe doit contenir au moins un caractère spécial (!@#$%^&*()_+-=[]{}|;:,.<>?)"
98 -
99 -# Paramètres
100 -settings:
101 - title: "Paramètres"
102 - page_title: "Paramètres - RustWarden"
103 - subtitle: "Gérez vos préférences et paramètres de compte"
104 - profile_title: "Mon Profil"
105 - profile_desc: "Modifiez vos informations personnelles (nom d'utilisateur, email)"
106 - password_title: "Mot de passe"
107 - password_desc: "Changez votre mot de passe pour sécuriser votre compte"
108 - sessions_title: "Sessions actives"
109 - sessions_desc: "Gérez vos sessions actives et révoquez l'accès depuis d'autres appareils"
110 - preferences_title: "Préférences"
111 - preferences_desc: "Personnalisez votre expérience (thème, langue)"
112 - access: "Accéder"
113 - info_title: "Informations"
114 - info_desc: "Utilisez les sections ci-dessus pour gérer différents aspects de votre compte. Toutes les modifications sont sécurisées et nécessitent votre authentification."
115 - language: "Langue"
116 - language_description: "Choisissez votre langue préférée"
117 - save: "Enregistrer"
118 - saved: "Paramètres enregistrés"
119 -
120 -# Page d'accueil
121 -home:
122 - welcome: "Bienvenue, {username} !"
123 - subtitle: "Bienvenue sur votre espace personnel"
124 - my_account: "Mon Compte"
125 - stat_username: "Nom d'utilisateur"
126 - stat_email: "Email"
127 - stat_member_since: "Membre depuis"
128 - stat_roles: "Rôles"
129 -
130 -# Messages généraux
131 -common:
132 - welcome: "Bienvenue {username}"
133 - loading: "Chargement..."
134 - save: "Enregistrer"
135 - cancel: "Annuler"
136 - delete: "Supprimer"
137 - edit: "Modifier"
138 - create: "Créer"
139 - load_more: "Charger plus"
140 -
141 -# Messages i18n
142 -i18n:
143 - language_updated: "Préférence de langue mise à jour. Veuillez recharger la page pour appliquer les changements."
144 - invalid_language: "Code de langue invalide. Langues supportées : fr, en, de, es"
145 -
146 -# Préférences
147 -preferences:
148 - title: "Préférences d'affichage"
149 - page_title: "Préférences d'affichage - RustCloud"
150 - subtitle: "Personnalisez l'apparence de l'interface"
151 - theme: "Thème"
152 - theme_light: "Clair"
153 - theme_light_desc: "Interface lumineuse et épurée"
154 - theme_dark: "Sombre"
155 - theme_dark_desc: "Réduit la fatigue oculaire"
156 - theme_auto: "Automatique"
157 - theme_auto_desc: "S'adapte à l'heure de la journée"
158 - theme_preview: "Le thème sera appliqué immédiatement après l'enregistrement"
159 - language: "Langue"
160 - language_fr: "Français"
161 - language_fr_desc: "Interface en français"
162 - language_en: "English"
163 - language_en_desc: "English interface"
164 - language_de: "Deutsch"
165 - language_de_desc: "Deutsche Benutzeroberfläche"
166 - language_es: "Español"
167 - language_es_desc: "Interfaz en español"
168 - translation_warning: "La traduction complète de l'interface est en cours de développement. Certains éléments peuvent rester en français."
169 - modified: "Préférences modifiées"
170 - modified_desc: "Enregistrez vos changements pour les appliquer"
171 - save: "Enregistrer les préférences"
172 - saving: "Enregistrement..."
173 - cancel: "Annuler"
174 - saved_success: "Préférences enregistrées avec succès !"
175 - saved_reload: "Préférences enregistrées avec succès !\n\nLa langue a été modifiée. Voulez-vous recharger la page maintenant pour appliquer les changements ?"
176 - error: "Erreur : {message}"
177 - network_error: "Erreur réseau. Veuillez réessayer."
178 - sync_title: "Vos préférences sont synchronisées"
179 - sync_desc: "Vos préférences d'affichage sont enregistrées et s'appliquent automatiquement sur tous vos appareils connectés à votre compte RustCloud."
180 - preview_dark: "Aperçu : Thème sombre sera appliqué"
181 - preview_light: "Aperçu : Thème clair sera appliqué"
182 - preview_auto: "Aperçu : Thème automatique sera appliqué"
183 -
184 -# Profil
185 -profile:
186 - title: "Mon Profil"
187 - page_title: "Mon Profil - RustCloud"
188 - subtitle: "Modifiez vos informations personnelles"
189 - account_info: "Informations du compte"
190 - username: "Nom d'utilisateur"
191 - username_public: "Visible publiquement"
192 - username_placeholder: "Votre nom d'utilisateur"
193 - username_pattern: "Lettres, chiffres, tirets et underscores uniquement"
194 - email: "Email"
195 - email_placeholder: "votre@email.com"
196 - save: "Enregistrer les modifications"
197 - saving: "Enregistrement..."
198 - saved: "Profil mis à jour avec succès"
199 - error: "Erreur lors de la mise à jour du profil"
200 - email_label: "Adresse email"
201 - email_used_for: "Utilisée pour la connexion"
202 - email_warning: "⚠️ Vous serez déconnecté après la modification"
203 - email_change_info: "Les modifications d'email nécessitent une reconnexion pour des raisons de sécurité."
204 - username_help: "3-50 caractères (lettres, chiffres, -, _)"
205 - additional_info: "Informations supplémentaires"
206 - account_created: "Compte créé le"
207 - roles: "Rôles"
208 -
209 -# Breadcrumb
210 -breadcrumb:
211 - home: "Accueil"
212 - settings: "Paramètres"
213 - admin: "Administration"
214 - preferences: "Préférences"
215 - profile: "Profil"
216 - password: "Mot de passe"
217 - users: "Utilisateurs"
218 - sessions: "Sessions"
219 -
220 -# Admin Settings
221 -admin:
222 - dashboard:
223 - title: "Administration"
224 - heading: "Tableau de bord Administrateur"
225 - users_card_title: "Utilisateurs"
226 - users_card_subtitle: "Total utilisateurs"
227 - access_button: "Accéder"
228 - sessions_card_title: "Sessions actives"
229 - sessions_card_subtitle: "Sessions en cours"
230 - admins_card_title: "Administrateurs"
231 - admins_card_subtitle: "Comptes admin"
232 - last_activity_card_title: "Dernière activité"
233 - last_activity_card_subtitle: "Dernière connexion"
234 - audit_section_title: "Statistiques des logs d'audit"
235 - audit_total_logs: "Total des logs"
236 - audit_last_24h: "Dernières 24h"
237 - audit_by_level: "Par niveau"
238 - chart_label: "Logs par niveau"
239 - roles_card_title: "Gestion des Rôles"
240 - roles_card_desc: "Gérer les rôles et les permissions granulaires"
241 - roles_stat_title: "Rôles"
242 - settings_card_title: "Paramètres Application"
243 - settings_card_desc: "Configurer les paramètres globaux de l'application"
244 - apps_card_title: "Gestion des Applications"
245 - apps_card_desc: "Activer ou désactiver les applications modulaires"
246 - orgs_card_title: "Gestion des Organisations"
247 - orgs_card_desc: "Créer et gérer les organisations et leurs membres"
248 - orgs_stat_title: "Organisations"
249 - orgs_members_stat_title: "Membres totaux"
250 - roles_list:
251 - page_title: "Gestion des rôles"
252 - breadcrumb_roles: "Rôles"
253 - title: "Rôles"
254 - create_role: "Créer un rôle"
255 - table_name: "Nom"
256 - table_description: "Description"
257 - table_users: "Utilisateurs"
258 - table_permissions: "Permissions"
259 - table_actions: "Actions"
260 - edit: "Modifier"
261 - delete: "Supprimer"
262 - no_roles: "Aucun rôle trouvé."
263 - protected_hint: "Protégé"
264 - delete_confirm_title: "Confirmer la suppression"
265 - delete_confirm_message: "Êtes-vous sûr de vouloir supprimer le rôle"
266 - delete_button: "Supprimer"
267 - role_form:
268 - title_create: "Créer un rôle"
269 - title_edit: "Modifier le rôle"
270 - breadcrumb_create: "Créer"
271 - breadcrumb_edit: "Modifier"
272 - back_to_list: "Retour à la liste"
273 - name_label: "Nom du rôle"
274 - name_placeholder: "ex: moderator"
275 - description_label: "Description"
276 - description_placeholder: "Description du rôle..."
277 - permissions_label: "Permissions"
278 - protected_name_hint: "Le nom de ce rôle ne peut pas être modifié."
279 - cancel: "Annuler"
280 - submit_create: "Créer le rôle"
281 - submit_edit: "Enregistrer les modifications"
282 - errors:
283 - title: "Codes d'Erreur"
284 - description: "Documentation des codes d'erreur de l'application. Chaque code d'erreur est associé à un middleware et fournit des informations de contexte pour le débogage."
285 - breadcrumb_errors: "Codes d'Erreur"
286 - table_code: "Code"
287 - table_description: "Description"
288 - table_category: "Catégorie"
289 - table_middleware: "Middleware"
290 - table_context: "Contexte"
291 - table_example: "Exemple"
292 - legend_title: "Légende"
293 - legend_code: "Identifiant unique de l'erreur (format: CATEGORY_NUMBER)"
294 - legend_description: "Description en français de l'erreur"
295 - legend_category: "Type d'erreur (Authentification, CSRF, Rate Limiting, etc.)"
296 - legend_middleware: "Middleware responsable de générer cette erreur"
297 - legend_context: "Explication détaillée de quand cette erreur se produit"
298 - legend_example: "Exemple concret de situation générant cette erreur"
299 - error_auth001_desc: "Identifiants invalides"
300 - error_auth001_category: "Authentification"
301 - error_auth001_context: "L'utilisateur a fourni des identifiants incorrects lors de la connexion"
302 - error_auth001_example: "Tentative de connexion avec un mot de passe incorrect"
303 - error_auth002_desc: "Token invalide"
304 - error_auth002_category: "Authentification"
305 - error_auth002_context: "Le token JWT fourni est invalide ou malformé"
306 - error_auth002_example: "Token JWT corrompu ou signé avec une clé différente"
307 - error_auth003_desc: "Token expiré"
308 - error_auth003_category: "Authentification"
309 - error_auth003_context: "Le token JWT a expiré et doit être renouvelé"
310 - error_auth003_example: "Tentative d'accès avec un token expiré après la durée de validité"
311 - error_csrf001_desc: "Token CSRF manquant"
312 - error_csrf001_category: "CSRF Protection"
313 - error_csrf001_context: "La requête POST/PUT/DELETE ne contient pas de token CSRF"
314 - error_csrf001_example: "Formulaire soumis sans token CSRF dans le header ou le body"
315 - error_csrf002_desc: "Token CSRF invalide"
316 - error_csrf002_category: "CSRF Protection"
317 - error_csrf002_context: "Le token CSRF fourni ne correspond pas au token de la session"
318 - error_csrf002_example: "Token CSRF modifié ou volé"
319 - error_rate001_desc: "Limite de taux dépassée"
320 - error_rate001_category: "Rate Limiting"
321 - error_rate001_context: "Le nombre de requêtes par minute a été dépassé"
322 - error_rate001_example: "Plus de 100 requêtes/min sur les endpoints admin"
323 - error_valid001_desc: "Données d'entrée invalides"
324 - error_valid001_category: "Validation"
325 - error_valid001_context: "Les données fournies ne respectent pas les règles de validation"
326 - error_valid001_example: "Email au format invalide ou champ requis manquant"
327 - error_valid002_desc: "Complexité du mot de passe non respectée"
328 - error_valid002_category: "Validation"
329 - error_valid002_context: "Le mot de passe ne respecte pas les règles de complexité configurées"
330 - error_valid002_example: "Mot de passe trop court ou ne contenant pas les caractères requis"
331 - error_rbac001_desc: "Permissions insuffisantes"
332 - error_rbac001_category: "Autorisation"
333 - error_rbac001_context: "L'utilisateur n'a pas les permissions nécessaires pour cette action"
334 - error_rbac001_example: "Utilisateur non-admin tentant d'accéder à /admin/users"
335 - error_rbac002_desc: "Rôle administrateur requis"
336 - error_rbac002_category: "Autorisation"
337 - error_rbac002_context: "L'action nécessite le rôle administrateur"
338 - error_rbac002_example: "Tentative de suppression d'utilisateur sans rôle admin"
339 - error_db001_desc: "Erreur d'opération de base de données"
340 - error_db001_category: "Base de données"
341 - error_db001_context: "Une opération de base de données a échoué"
342 - error_db001_example: "Contrainte de clé étrangère violée ou connexion DB perdue"
343 - error_db002_desc: "Ressource non trouvée"
344 - error_db002_category: "Base de données"
345 - error_db002_context: "La ressource demandée n'existe pas dans la base de données"
346 - error_db002_example: "Utilisateur avec ID inexistant"
347 - user_form:
348 - breadcrumb_create: "Créer"
349 - breadcrumb_edit: "Modifier"
350 - title_create: "Créer un utilisateur"
351 - title_edit: "Modifier un utilisateur"
352 - back_to_list: "Retour à la liste"
353 - username_label: "Nom d'utilisateur"
354 - username_placeholder: "nom_utilisateur"
355 - username_readonly_hint: "Le nom d'utilisateur ne peut pas être modifié après création"
356 - email_label: "Email"
357 - email_placeholder: "utilisateur@example.com"
358 - password_label: "Mot de passe"
359 - password_optional: "Optionnel"
360 - password_placeholder: "Laisser vide pour générer un mot de passe aléatoire"
361 - password_hint: "Si laissé vide, un mot de passe sécurisé sera généré automatiquement"
362 - roles_label: "Rôles"
363 - roles_hint: "Sélectionnez au moins un rôle"
364 - must_change_password_label: "Forcer le changement de mot de passe"
365 - must_change_password_desc: "L'utilisateur devra changer son mot de passe à la prochaine connexion"
366 - cancel: "Annuler"
367 - submit_create: "Créer l'utilisateur"
368 - submit_edit: "Enregistrer les modifications"
369 - settings:
370 - title: "Paramètres de l'Application"
371 - page_title: "Paramètres de l'Application - Admin - RustCloud"
372 - subtitle: "Configuration globale de RustCloud"
373 - general: "Général"
374 - app_name: "Nom de l'application"
375 - app_domain: "Domaine"
376 - app_domain_placeholder: "example.com"
377 - app_description: "Description"
378 - allow_registration: "Permettre l'enregistrement"
379 - allow_registration_desc: "Les utilisateurs peuvent créer des comptes depuis la page d'accueil"
380 - email_validation_required: "Validation email requise"
381 - email_validation_required_desc: "Les utilisateurs doivent valider leur email avant de pouvoir se connecter"
382 - save: "Enregistrer les paramètres"
383 - cancel: "Annuler"
384 - saved: "Paramètres enregistrés avec succès"
385 - error: "Erreur lors de l'enregistrement"
386 - network_error: "Erreur réseau. Veuillez réessayer."
387 - password_security: "Sécurité des Mots de Passe"
388 - password_min_length: "Longueur minimale"
389 - password_min_length_hint: "Entre 6 et 128 caractères"
390 - password_complexity: "Exigences de complexité"
391 - require_uppercase: "Majuscule requise"
392 - require_lowercase: "Minuscule requise"
393 - require_digits: "Chiffre requis"
394 - require_special: "Caractère spécial requis"
395 - password_change_require_email: "Confirmation par email du changement de mot de passe"
396 - password_change_require_email_desc: "Lorsqu'activé, un email de confirmation est envoyé pour valider tout changement de mot de passe. Sinon, le changement est appliqué immédiatement."
397 - password_expiration: "Expiration des mots de passe"
398 - password_expiration_desc: "Forcer le changement périodique"
399 - expiration_days: "Expiration après (jours)"
400 - expiration_alert: "Alerte email avant expiration"
401 - expiration_alert_days: "Alerte (jours avant)"
402 - audit_logging: "Journalisation des Audits"
403 - log_level: "Niveau de log"
404 - log_level_debug: "DEBUG - Tous les détails"
405 - log_level_info: "INFO - Informations normales"
406 - log_level_warn: "WARN - Avertissements uniquement"
407 - log_level_error: "ERROR - Erreurs uniquement"
408 - log_level_hint: "Contrôle la verbosité des logs d'audit"
409 - log_retention: "Rétention des logs (jours)"
410 - log_retention_hint: "Durée de conservation des logs (1-365 jours)"
411 - audit_info_message: "Les logs d'audit enregistrent toutes les actions administratives (création, modification, suppression d'utilisateurs)."
412 - save_changes: "Sauvegarder les modifications"
413 - save_changes_desc: "Les paramètres seront appliqués immédiatement"
414 - saving: "Enregistrement..."
415 - save_success: "Paramètres enregistrés avec succès !"
416 - save_error_prefix: "Erreur"
417 - users_list:
418 - page_title: "Gestion des Utilisateurs - Admin - RustCloud"
419 - title: "Gestion des Utilisateurs"
420 - breadcrumb_users: "Utilisateurs"
421 - create_user: "Créer un utilisateur"
422 - search_label: "Rechercher"
423 - search_placeholder: "Nom d'utilisateur ou email..."
424 - role_filter_label: "Rôle"
425 - all_roles: "Tous les rôles"
426 - per_page_label: "Par page"
427 - search_button: "Rechercher"
428 - results_summary: "Affichage de {count} utilisateur(s) sur {total} total"
429 - page_info: "Page {page} sur {total_pages}"
430 - table_username: "Nom d'utilisateur"
431 - table_email: "Email"
432 - table_roles: "Rôles"
433 - table_created_at: "Créé le"
434 - table_last_login: "Dernière connexion"
435 - table_actions: "Actions"
436 - never: "Jamais"
437 - edit: "Modifier"
438 - delete: "Supprimer"
439 - reset_password: "Réinitialiser le mot de passe"
440 - no_users: "Aucun utilisateur trouvé"
441 - reset_password_title: "Réinitialiser le mot de passe"
442 - reset_generate: "Générer un mot de passe aléatoire"
443 - reset_custom: "Définir un mot de passe personnalisé"
444 - reset_email: "Réinitialiser par email"
445 - reset_new_password_label: "Nouveau mot de passe"
446 - reset_new_password_placeholder: "Minimum 8 caractères, majuscule, minuscule, chiffre, caractère spécial"
447 - reset_password_requirements: "⚠️ Doit contenir : 8+ caractères, majuscule, minuscule, chiffre, caractère spécial"
448 - reset_invalidate_sessions: "Invalider toutes les sessions actives de cet utilisateur"
449 - reset_invalidate_sessions_desc: "L'utilisateur devra se reconnecter après le changement"
450 - reset_button: "Réinitialiser"
451 - delete_confirm_title: "⚠️ Confirmer la suppression"
452 - delete_confirm_message: "Êtes-vous sûr de vouloir supprimer cet utilisateur ?"
453 - delete_confirm_username: "Nom d'utilisateur :"
454 - delete_confirm_email: "Email :"
455 - delete_irreversible: "Cette action est irréversible !"
456 - delete_button: "Supprimer définitivement"
457 - edit_user_title: "Modifier l'utilisateur"
458 - delete_error: "Erreur lors de la suppression de l'utilisateur"
459 - network_error: "Erreur réseau"
460 - resetting: "Réinitialisation..."
461 - reset_by_email_title: "Réinitialisation par email"
462 - close: "Fermer"
463 - password_reset_success_title: "Mot de passe réinitialisé"
464 - password_warning_once: "Ce mot de passe ne sera affiché qu'une seule fois. Copiez-le maintenant !"
465 - new_password_label: "Nouveau mot de passe"
466 - copy: "Copier"
467 - password_copied: "Mot de passe copié dans le presse-papiers"
468 - password_set_success: "Mot de passe réinitialisé avec succès"
469 - reset_error: "Erreur lors de la réinitialisation du mot de passe"
470 -
471 -# Password Change
472 -password:
473 - title: "Changer mon mot de passe"
474 - page_title: "Changer mon mot de passe - RustCloud"
475 - subtitle: "Modifiez votre mot de passe pour sécuriser votre compte. Un email de confirmation vous sera envoyé pour valider le changement."
476 - subtitle_no_email: "Modifiez votre mot de passe pour sécuriser votre compte."
477 - must_change_message: "⚠️ Vous devez changer votre mot de passe avant de continuer."
478 - new_password: "Nouveau mot de passe"
479 - current_password: "Mot de passe actuel"
480 - current_password_placeholder: "Votre mot de passe actuel"
481 - new_password_placeholder: "Nouveau mot de passe sécurisé"
482 - required: "Requis"
483 - requirements: "📌 Respectez les exigences de complexité indiquées ci-dessous."
484 - strength: "Force du mot de passe :"
485 - strength_weak: "Faible"
486 - strength_medium: "Moyen"
487 - strength_strong: "Fort ✓"
488 - strength_too_short: "trop court"
489 - strength_missing_lowercase: "minuscule manquante"
490 - strength_missing_uppercase: "majuscule manquante"
491 - strength_missing_digit: "chiffre manquant"
492 - strength_missing_special: "caractère spécial manquant"
493 - confirm_password: "Confirmer le nouveau mot de passe"
494 - confirm_password_placeholder: "Répétez le nouveau mot de passe"
495 - passwords_match: "✓ Les mots de passe correspondent"
496 - passwords_not_match: "✗ Les mots de passe ne correspondent pas"
497 - change_button: "Changer le mot de passe"
498 - changing: "Changement en cours..."
499 - logout_warning_title: "Déconnexion des autres sessions"
500 - logout_warning_desc: "Toutes vos autres sessions actives seront automatiquement déconnectées après le changement de mot de passe. Seule votre session actuelle restera active."
501 - save: "Enregistrer le nouveau mot de passe"
502 - saving: "Enregistrement..."
503 - saved: "Un email de confirmation a été envoyé à votre adresse. Cliquez sur le lien dans l'email pour valider le changement de mot de passe."
504 - saved_direct: "Mot de passe changé avec succès ! Vos autres sessions ont été déconnectées."
505 - error: "Erreur lors de la modification du mot de passe"
506 - error_all_fields_required: "Tous les champs sont requis."
507 - error_empty_data: "Les données à envoyer sont vides."
508 - error_invalid_data: "Données invalides. Vérifiez que tous les champs sont remplis."
509 - error_incorrect_current: "Mot de passe actuel incorrect."
510 - error_reading_response: "Erreur lors de la lecture de la réponse du serveur"
511 - error_415: "Erreur 415 : Le serveur n'a pas reçu les données au format JSON attendu. Veuillez réessayer."
512 - network_error: "Erreur réseau. Veuillez réessayer."
513 - security_tips_title: "Conseils de sécurité"
514 - security_tip_unique: "Utilisez un mot de passe unique que vous n'utilisez pas ailleurs"
515 - security_tip_share: "Ne partagez jamais votre mot de passe avec qui que ce soit"
516 - security_tip_regular: "Changez votre mot de passe régulièrement (tous les 3-6 mois)"
517 - security_tip_2fa: "Activez l'authentification à deux facteurs si disponible"
518 - security_tip_manager: "Utilisez un gestionnaire de mots de passe pour plus de sécurité"
519 - pending_request_message: "⚠️ Une demande de changement de mot de passe est en attente de confirmation par email."
520 - pending_request_info: "Un email de confirmation a été envoyé à votre adresse. Cliquez sur le lien dans l'email pour confirmer le changement."
521 - pending_request_cancel: "Annuler la demande en attente"
522 - pending_request_canceled: "Demande annulée avec succès"
523 - pending_request_cancel_error: "Erreur lors de l'annulation de la demande"
524 - confirmation_sent: "Email de confirmation envoyé ! Vérifiez votre boîte de réception."
525 - confirmation_error: "Erreur lors de l'envoi de l'email de confirmation"
526 -
527 -# Password Change Confirmation Email
528 -password_change_confirmation:
529 - subject: "Confirmation de changement de mot de passe"
530 - greeting: "Bonjour {username}"
531 - message: "Vous avez demandé à changer votre mot de passe pour votre compte {app_name}. Pour confirmer ce changement, cliquez sur le lien ci-dessous."
532 - button_text: "Confirmer le changement de mot de passe"
533 - expires_message: "Ce lien est valide pendant {hours} heures et est à usage unique."
534 - footer: "Cet email a été envoyé automatiquement, merci de ne pas y répondre."
535 -
536 -# Sessions
537 -sessions:
538 - title: "Sessions actives"
539 - page_title: "Sessions actives - RustCloud"
540 - subtitle: "Gérez les appareils connectés à votre compte"
541 - current_session: "Session actuelle"
542 - current_badge: "Actuelle"
543 - unknown_device: "Appareil inconnu"
544 - ip_address: "Adresse IP :"
545 - created_at: "Créée le :"
546 - last_activity: "Dernière activité :"
547 - session_id: "ID Session :"
548 - revoke: "Révoquer"
549 - revoke_confirm: "Êtes-vous sûr de vouloir révoquer la session pour \"{device}\" ?\n\nCet appareil sera déconnecté immédiatement."
550 - revoke_success: "Session révoquée avec succès !"
551 - revoke_error: "Erreur lors de la révocation de la session"
552 - no_sessions: "Aucune session active"
553 - no_sessions_desc: "Vous n'avez actuellement aucune session active"
554 - danger_zone: "Zone de danger"
555 - danger_zone_desc: "Déconnectez tous les autres appareils en une seule action (votre session actuelle restera active)."
556 - revoke_all: "Révoquer toutes les autres sessions"
557 - revoke_all_confirm: "⚠️ ATTENTION\n\nÊtes-vous sûr de vouloir révoquer TOUTES vos autres sessions ?\n\nTous vos autres appareils seront déconnectés. Seule votre session actuelle restera active."
558 - revoke_all_success: "Toutes les autres sessions ont été révoquées !"
559 - revoke_all_error: "Erreur lors de la révocation. Certaines sessions n'ont peut-être pas été révoquées."
560 - about_title: "À propos des sessions"
561 - about_desc: "Une session est créée à chaque connexion depuis un appareil ou navigateur différent. Révoquer une session déconnecte immédiatement cet appareil. Votre session actuelle ne peut pas être révoquée depuis cette page (déconnectez-vous pour cela)."
562 - back_to_settings: "Retour aux paramètres"
563 -
564 -# Navigation menu (navbar)
565 -nav:
566 - home: "Accueil"
567 - settings: "Paramètres"
568 - admin: "Administration"
569 -
570 -# User menu
571 -user_menu:
572 - profile: "Profil"
573 - logout: "Déconnexion"
574 - settings: "Paramètres"
575 -
576 -# Organisations
577 -organisations:
578 - list:
579 - title: "Gestion des Organisations"
580 - create: "Créer une organisation"
581 - search: "Rechercher"
582 - search_placeholder: "Nom ou description..."
583 - no_organisations: "Aucune organisation trouvée"
584 - results_summary: "Résultats"
585 - manage_members: "Gérer les membres"
586 - table:
587 - name: "Nom"
588 - description: "Description"
589 - members: "Membres"
590 - created_at: "Créée le"
591 - actions: "Actions"
592 - create:
593 - title: "Créer une organisation"
594 - submit: "Créer"
595 - success: "Organisation créée avec succès"
596 - error:
597 - already_exists: "L'organisation '{gid}' existe déjà"
598 - invalid_gid: "Format de gid invalide: {gid}"
599 - generic: "Une erreur est survenue lors de la création de l'organisation"
600 - edit:
601 - title: "Modifier l'organisation"
602 - submit: "Enregistrer"
603 - update:
604 - success: "Organisation mise à jour avec succès"
605 - delete:
606 - success: "Organisation supprimée avec succès"
607 - confirm: "Êtes-vous sûr de vouloir supprimer cette organisation ? Cette action est irréversible."
608 - form:
609 - name_label: "Nom"
610 - name_placeholder: "Nom de l'organisation"
611 - description_label: "Description"
612 - description_placeholder: "Description de l'organisation (optionnel)"
613 - gid_label: "Identifiant (GID)"
614 - gid_placeholder: "identifiant-unique"
615 - gid_help: "Identifiant unique (lettres, chiffres, underscores uniquement, max 64 caractères)"
616 - members:
617 - title: "Membres de l'organisation"
618 - add_member: "Ajouter un membre"
619 - search_user_label: "Rechercher un utilisateur"
620 - search_user_placeholder: "Nom d'utilisateur ou email..."
621 - no_members: "Aucun membre dans cette organisation"
622 - back_to_list: "Retour à la liste"
623 - table:
624 - username: "Nom d'utilisateur"
625 - role: "Rôle"
626 - joined_at: "Rejoint le"
627 - actions: "Actions"
628 - add_success: "Membre ajouté avec succès"
629 - remove_success: "Membre retiré avec succès"
630 - add_error: "Erreur lors de l'ajout du membre"
631 - remove_error: "Erreur lors du retrait du membre"
632 - user_not_found: "Utilisateur '{search}' non trouvé"
633 - error:
634 - organisation_not_found: "Organisation '{gid}' non trouvée"
635 - already_member: "Cet utilisateur est déjà membre de l'organisation"
636 -
637 -# Sharing
638 -sharing:
639 - modal:
640 - title: "Partager une ressource"
641 - resource_label: "Ressource"
642 - resource_type_label: "Type"
643 - expiration_label: "Date d'expiration (optionnel)"
644 - expiration_help: "Le partage expirera automatiquement à cette date"
645 - cancel_button: "Annuler"
646 - submit_button: "Partager"
647 - close_button: "Fermer"
648 - success_message: "Partage créé avec succès"
649 - error_message: "Erreur lors de la création du partage"
650 - error:
651 - already_exists: "Ce partage existe déjà pour cette ressource et cette organisation"
652 - components:
653 - share_button_label: "Partager"
654 - permissions_label: "Permissions"
655 - permissions_help: "Choisissez les permissions accordées pour ce partage"
656 - share_with_label: "Partager avec"
657 - user_tab_label: "Utilisateur"
658 - organisation_tab_label: "Organisation"
659 - select_user_label: "Sélectionner un utilisateur"
660 - search_user_placeholder: "Rechercher un utilisateur..."
661 - select_organisation_label: "Sélectionner une organisation"
662 - select_organisation_placeholder: "Choisir une organisation..."
663 - no_organisations_message: "Aucune organisation disponible"
664 - permissions:
665 - read_only: "Lecture seule"
666 - read_only_value: "1"
667 - read_only_description: "Peut uniquement consulter la ressource"
668 - read_write: "Lecture + Écriture"
669 - read_write_value: "3"
670 - read_write_description: "Peut consulter et modifier la ressource"
671 - all: "Toutes les permissions"
672 - all_value: "15"
673 - all_description: "Peut consulter, modifier, supprimer et partager"
674 -
675 -# Email verification messages
676 -email_verify:
677 - config_error: "Erreur de configuration du serveur. Veuillez contacter l'administrateur."
678 - success: "Votre adresse email a été vérifiée avec succès. Vous pouvez maintenant vous connecter à votre compte."
679 - invalid_link: "Le lien de vérification est invalide ou a expiré."
680 -
681 -# Email subjects
682 -email_subject:
683 - password_reset: "Réinitialisation de votre mot de passe"
684 - email_validation: "Vérification de votre adresse email"
685 - welcome: "Bienvenue sur RustWarden !"
686 -
687 -# Rate limiting
688 -rate_limit:
689 - exceeded: "Trop de requêtes. Veuillez réessayer plus tard."
690 - password_change: "Trop de demandes de changement de mot de passe. Veuillez patienter avant de réessayer."
691 -
692 -# Sessions
693 -session:
694 - ip_unavailable: "Non disponible"
695 -
A crates/rustwarden-ui/locales_old/de.yml
+572 -0
@@ -0,0 +1,572 @@
1 +# Deutsche Übersetzungen für RustCloud
2 +# Format: hierarchische Schlüssel mit Punkten
3 +
4 +# Anmeldung
5 +login:
6 + title: "Anmeldung"
7 + page_title: "Anmeldung - RustCloud"
8 + page_description: "Anmeldung bei RustCloud"
9 + subtitle: "Melden Sie sich in Ihrem Cloud-Bereich an"
10 + username: "Benutzername"
11 + password: "Passwort"
12 + submit: "Anmelden"
13 + remember_me: "Angemeldet bleiben"
14 + tab_login: "Anmeldung"
15 + tab_register: "Registrierung"
16 + register_title: "Konto erstellen"
17 + register_submit: "Mein Konto erstellen"
18 + username_placeholder: "Ihr Benutzername"
19 + password_placeholder: "Ihr Passwort"
20 + username_placeholder_register: "Ihr Benutzername (min. 3 Zeichen)"
21 + password_placeholder_register: "Ihr Passwort (min. 8 Zeichen)"
22 + email_placeholder: "ihre@email.de"
23 + forgot_password: "Passwort vergessen?"
24 + email_not_verified: "E-Mail nicht verifiziert?"
25 + resend_verification: "Bestätigungs-E-Mail erneut senden"
26 + already_have_account: "Bereits ein Konto?"
27 + sign_in_link: "Anmelden"
28 + success_message: "Anmeldung erfolgreich! Weiterleitung..."
29 + error_message: "Anmeldefehler. Überprüfen Sie Ihre Anmeldedaten."
30 + email_unverified_message: "Ihre E-Mail ist nicht verifiziert. Klicken Sie unten, um den Link erneut zu senden."
31 + resend_button: "Bestätigungs-E-Mail erneut senden"
32 + resend_success: "Bestätigungs-E-Mail gesendet, falls das Konto existiert."
33 + resend_error: "Anfrage kann derzeit nicht gesendet werden."
34 + resend_prompt: "Bitte geben Sie Ihre E-Mail ein, bevor Sie erneut senden."
35 + register_success: "Konto erfolgreich erstellt! Bitte überprüfen Sie Ihre E-Mail."
36 + register_error: "Registrierungsfehler"
37 + email_label: "E-Mail"
38 + footer_text: "Nextcloud Fork in Rust"
39 +
40 +# Fehlermeldungen
41 +error:
42 + invalid_credentials: "Ungültige Anmeldedaten"
43 + email_invalid: "Ungültige E-Mail"
44 + required_field: "Dieses Feld ist erforderlich"
45 + unauthorized: "Nicht autorisiert"
46 + not_found: "Ressource nicht gefunden"
47 + validation: "Validierungsfehler: {message}"
48 + database: "Datenbankfehler"
49 + internal: "Interner Serverfehler"
50 + invalid_token: "Ungültiges Token"
51 + token_expired: "Token abgelaufen"
52 + token_reused: "Token wiederverwendet - bitte erneut authentifizieren"
53 + must_change_password: "Passwortänderung erforderlich"
54 + authentication_required: "Authentifizierung erforderlich"
55 + insufficient_permissions: "Unzureichende Berechtigungen"
56 + config: "Serverkonfigurationsfehler"
57 + template: "Template-Fehler"
58 + validation_field:
59 + username_min_length: "Der Benutzername muss mindestens {min} Zeichen enthalten"
60 + password_min_length: "Das Passwort muss mindestens {min} Zeichen enthalten"
61 + email_format: "Die E-Mail-Adresse ist ungültig"
62 + password_require_uppercase: "Das Passwort muss mindestens einen Großbuchstaben enthalten"
63 + password_require_lowercase: "Das Passwort muss mindestens einen Kleinbuchstaben enthalten"
64 + password_require_digits: "Das Passwort muss mindestens eine Ziffer enthalten"
65 + password_require_special: "Das Passwort muss mindestens ein Sonderzeichen (!@#$%^&*()_+-=[]{}|;:,.<>?) enthalten"
66 +
67 +# Bestätigungs-E-Mail erneut senden
68 +resend_verification:
69 + page_title: "Bestätigungs-E-Mail erneut senden - RustWarden"
70 + title: "Bestätigungs-E-Mail erneut senden"
71 + subtitle: "Geben Sie Ihre E-Mail-Adresse ein, um einen neuen Bestätigungslink zu erhalten."
72 + email_label: "E-Mail-Adresse"
73 + email_placeholder: "sie@beispiel.de"
74 + submit: "Bestätigungslink senden"
75 + back_to_login: "Zurück zur Anmeldung"
76 + error_default: "Die Anfrage konnte nicht gesendet werden."
77 + success_default: "Wenn das Konto existiert und nicht verifiziert ist, wurde eine E-Mail gesendet."
78 + error_missing_email: "Bitte geben Sie Ihre E-Mail ein."
79 + error_cannot_send: "Die Anfrage kann derzeit nicht gesendet werden."
80 +
81 +# Passwort zurücksetzen (über E-Mail-Link)
82 +password_reset:
83 + page_title: "Passwort zurücksetzen"
84 + title: "Passwort zurücksetzen"
85 + subtitle: "Geben Sie unten Ihr neues Passwort ein."
86 + new_password_label: "Neues Passwort"
87 + new_password_placeholder: "Neues sicheres Passwort"
88 + requirements: "⚠️ Muss enthalten: 8+ Zeichen, Großbuchstabe, Kleinbuchstabe, Ziffer, Sonderzeichen"
89 + confirm_password_label: "Passwort bestätigen"
90 + confirm_password_placeholder: "Bestätigen Sie Ihr neues Passwort"
91 + missing_token_url: "Zurücksetzungs-Token fehlt in der URL."
92 + missing_token: "Zurücksetzungs-Token fehlt."
93 + missing_new_password: "Bitte geben Sie ein neues Passwort ein."
94 + passwords_not_match: "Die Passwörter stimmen nicht überein."
95 + success_message: "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt anmelden."
96 + error_link_invalid: "Das Passwort konnte nicht zurückgesetzt werden. Der Link ist möglicherweise abgelaufen oder ungültig."
97 + error_generic: "Das Passwort kann derzeit nicht zurückgesetzt werden."
98 +
99 +# Einstellungen
100 +settings:
101 + title: "Einstellungen"
102 + page_title: "Einstellungen - RustWarden"
103 + subtitle: "Verwalten Sie Ihre Präferenzen und Kontoeinstellungen"
104 + profile_title: "Mein Profil"
105 + profile_desc: "Bearbeiten Sie Ihre persönlichen Informationen (Benutzername, E-Mail)"
106 + password_title: "Passwort"
107 + password_desc: "Ändern Sie Ihr Passwort, um Ihr Konto zu sichern"
108 + sessions_title: "Aktive Sitzungen"
109 + sessions_desc: "Verwalten Sie Ihre aktiven Sitzungen und widerrufen Sie den Zugriff von anderen Geräten"
110 + preferences_title: "Präferenzen"
111 + preferences_desc: "Passen Sie Ihre Erfahrung an (Design, Sprache)"
112 + access: "Zugriff"
113 + info_title: "Informationen"
114 + info_desc: "Verwenden Sie die obigen Abschnitte, um verschiedene Aspekte Ihres Kontos zu verwalten. Alle Änderungen sind sicher und erfordern Ihre Authentifizierung."
115 + language: "Sprache"
116 + language_description: "Wählen Sie Ihre bevorzugte Sprache"
117 + save: "Speichern"
118 + saved: "Einstellungen gespeichert"
119 +
120 +# Startseite
121 +home:
122 + welcome: "Willkommen, {username}!"
123 + subtitle: "Willkommen in Ihrem persönlichen Bereich"
124 + my_account: "Mein Konto"
125 + stat_username: "Benutzername"
126 + stat_email: "E-Mail"
127 + stat_member_since: "Mitglied seit"
128 + stat_roles: "Rollen"
129 +
130 +# Allgemeine Nachrichten
131 +common:
132 + welcome: "Willkommen {username}"
133 + loading: "Laden..."
134 + save: "Speichern"
135 + cancel: "Abbrechen"
136 + delete: "Löschen"
137 + edit: "Bearbeiten"
138 + create: "Erstellen"
139 +
140 +# Präferenzen
141 +preferences:
142 + title: "Anzeigeeinstellungen"
143 + page_title: "Anzeigeeinstellungen - RustCloud"
144 + subtitle: "Passen Sie das Erscheinungsbild der Benutzeroberfläche an"
145 + theme: "Design"
146 + theme_light: "Hell"
147 + theme_light_desc: "Helle und saubere Benutzeroberfläche"
148 + theme_dark: "Dunkel"
149 + theme_dark_desc: "Reduziert Augenbelastung"
150 + theme_auto: "Automatisch"
151 + theme_auto_desc: "Passt sich der Tageszeit an"
152 + theme_preview: "Das Design wird sofort nach dem Speichern angewendet"
153 + language: "Sprache"
154 + language_fr: "Français"
155 + language_fr_desc: "Benutzeroberfläche auf Französisch"
156 + language_en: "English"
157 + language_en_desc: "Englische Benutzeroberfläche"
158 + language_de: "Deutsch"
159 + language_de_desc: "Deutsche Benutzeroberfläche"
160 + language_es: "Español"
161 + language_es_desc: "Spanische Benutzeroberfläche"
162 + translation_warning: "Die vollständige Übersetzung der Benutzeroberfläche befindet sich in der Entwicklung. Einige Elemente können auf Französisch bleiben."
163 + modified: "Einstellungen geändert"
164 + modified_desc: "Speichern Sie Ihre Änderungen, um sie anzuwenden"
165 + save: "Einstellungen speichern"
166 + saving: "Speichern..."
167 + cancel: "Abbrechen"
168 + saved_success: "Einstellungen erfolgreich gespeichert!"
169 + saved_reload: "Einstellungen erfolgreich gespeichert!\n\nDie Sprache wurde geändert. Möchten Sie die Seite jetzt neu laden, um die Änderungen anzuwenden?"
170 + error: "Fehler: {message}"
171 + network_error: "Netzwerkfehler. Bitte versuchen Sie es erneut."
172 + sync_title: "Ihre Einstellungen sind synchronisiert"
173 + sync_desc: "Ihre Anzeigeeinstellungen werden gespeichert und automatisch auf allen Geräten angewendet, die mit Ihrem RustCloud-Konto verbunden sind."
174 + preview_dark: "Vorschau: Dunkles Design wird angewendet"
175 + preview_light: "Vorschau: Helles Design wird angewendet"
176 + preview_auto: "Vorschau: Automatisches Design wird angewendet"
177 +
178 +# Profil
179 +profile:
180 + title: "Mein Profil"
181 + page_title: "Mein Profil - RustCloud"
182 + subtitle: "Bearbeiten Sie Ihre persönlichen Informationen"
183 + account_info: "Kontoinformationen"
184 + username: "Benutzername"
185 + username_public: "Öffentlich sichtbar"
186 + username_placeholder: "Ihr Benutzername"
187 + username_pattern: "Nur Buchstaben, Zahlen, Bindestriche und Unterstriche"
188 + email: "E-Mail"
189 + email_placeholder: "ihre@email.de"
190 + save: "Änderungen speichern"
191 + saving: "Speichern..."
192 + saved: "Profil erfolgreich aktualisiert"
193 + error: "Fehler beim Aktualisieren des Profils"
194 + email_label: "E-Mail-Adresse"
195 + email_used_for: "Wird für die Anmeldung verwendet"
196 + email_warning: "⚠️ Sie werden nach der Änderung abgemeldet"
197 + email_change_info: "E-Mail-Änderungen erfordern aus Sicherheitsgründen eine erneute Anmeldung."
198 + username_help: "3-50 Zeichen (Buchstaben, Zahlen, -, _)"
199 + additional_info: "Zusätzliche Informationen"
200 + account_created: "Konto erstellt am"
201 + roles: "Rollen"
202 +
203 +# Breadcrumb
204 +breadcrumb:
205 + home: "Startseite"
206 + settings: "Einstellungen"
207 + admin: "Verwaltung"
208 + preferences: "Präferenzen"
209 + profile: "Profil"
210 + password: "Passwort"
211 + sessions: "Sitzungen"
212 +
213 +# Admin
214 +admin:
215 + dashboard:
216 + title: "Verwaltung"
217 + heading: "Administrator-Dashboard"
218 + users_card_title: "Benutzer"
219 + users_card_subtitle: "Benutzer gesamt"
220 + access_button: "Zugreifen"
221 + sessions_card_title: "Aktive Sitzungen"
222 + sessions_card_subtitle: "Laufende Sitzungen"
223 + admins_card_title: "Administratoren"
224 + admins_card_subtitle: "Admin-Konten"
225 + last_activity_card_title: "Letzte Aktivität"
226 + last_activity_card_subtitle: "Letzte Anmeldung"
227 + audit_section_title: "Audit-Log-Statistiken"
228 + audit_total_logs: "Logs gesamt"
229 + audit_last_24h: "Letzte 24h"
230 + audit_by_level: "Nach Stufe"
231 + chart_label: "Logs nach Stufe"
232 + roles_card_title: "Rollenverwaltung"
233 + roles_card_desc: "Rollen und granulare Berechtigungen verwalten"
234 + roles_stat_title: "Rollen"
235 + settings_card_title: "Anwendungseinstellungen"
236 + settings_card_desc: "Globale Anwendungseinstellungen konfigurieren"
237 + apps_card_title: "Anwendungsverwaltung"
238 + apps_card_desc: "Modulare Anwendungen aktivieren oder deaktivieren"
239 + orgs_card_title: "Organisationsverwaltung"
240 + orgs_card_desc: "Organisationen und deren Mitglieder erstellen und verwalten"
241 + orgs_stat_title: "Organisationen"
242 + orgs_members_stat_title: "Mitglieder gesamt"
243 + roles_list:
244 + page_title: "Rollenverwaltung"
245 + breadcrumb_roles: "Rollen"
246 + title: "Rollen"
247 + create_role: "Rolle erstellen"
248 + table_name: "Name"
249 + table_description: "Beschreibung"
250 + table_users: "Benutzer"
251 + table_permissions: "Berechtigungen"
252 + table_actions: "Aktionen"
253 + edit: "Bearbeiten"
254 + delete: "Löschen"
255 + no_roles: "Keine Rollen gefunden."
256 + protected_hint: "Geschützt"
257 + delete_confirm_title: "Löschen bestätigen"
258 + delete_confirm_message: "Möchten Sie die Rolle wirklich löschen"
259 + delete_button: "Löschen"
260 + role_form:
261 + title_create: "Rolle erstellen"
262 + title_edit: "Rolle bearbeiten"
263 + breadcrumb_create: "Erstellen"
264 + breadcrumb_edit: "Bearbeiten"
265 + back_to_list: "Zurück zur Liste"
266 + name_label: "Rollenname"
267 + name_placeholder: "z.B. moderator"
268 + description_label: "Beschreibung"
269 + description_placeholder: "Rollenbeschreibung..."
270 + permissions_label: "Berechtigungen"
271 + protected_name_hint: "Der Name dieser Rolle kann nicht geändert werden."
272 + cancel: "Abbrechen"
273 + submit_create: "Rolle erstellen"
274 + submit_edit: "Änderungen speichern"
275 + errors:
276 + title: "Fehlercodes"
277 + description: "Dokumentation der Anwendungsfehlercodes. Jeder Fehlercode ist mit einer Middleware verknüpft und liefert Kontextinformationen für die Fehlersuche."
278 + breadcrumb_errors: "Fehlercodes"
279 + table_code: "Code"
280 + table_description: "Beschreibung"
281 + table_category: "Kategorie"
282 + table_middleware: "Middleware"
283 + table_context: "Kontext"
284 + table_example: "Beispiel"
285 + legend_title: "Legende"
286 + legend_code: "Eindeutige Fehler-ID (Format: CATEGORY_NUMBER)"
287 + legend_description: "Beschreibung des Fehlers"
288 + legend_category: "Fehlertyp (Authentifizierung, CSRF, Rate Limiting, etc.)"
289 + legend_middleware: "Middleware, die diesen Fehler erzeugt"
290 + legend_context: "Detaillierte Erklärung, wann dieser Fehler auftritt"
291 + legend_example: "Konkretes Beispiel einer Situation, die diesen Fehler erzeugt"
292 + error_auth001_desc: "Ungültige Anmeldedaten"
293 + error_auth001_category: "Authentifizierung"
294 + error_auth001_context: "Der Benutzer hat bei der Anmeldung falsche Anmeldedaten eingegeben"
295 + error_auth001_example: "Anmeldeversuch mit falschem Passwort"
296 + error_auth002_desc: "Ungültiges Token"
297 + error_auth002_category: "Authentifizierung"
298 + error_auth002_context: "Das bereitgestellte JWT-Token ist ungültig oder fehlerhaft"
299 + error_auth002_example: "Beschädigtes JWT-Token oder mit anderem Schlüssel signiert"
300 + error_auth003_desc: "Token abgelaufen"
301 + error_auth003_category: "Authentifizierung"
302 + error_auth003_context: "Das JWT-Token ist abgelaufen und muss erneuert werden"
303 + error_auth003_example: "Zugriffsversuch mit abgelaufenem Token nach der Gültigkeitsdauer"
304 + error_csrf001_desc: "CSRF-Token fehlt"
305 + error_csrf001_category: "CSRF-Schutz"
306 + error_csrf001_context: "Die POST/PUT/DELETE-Anfrage enthält kein CSRF-Token"
307 + error_csrf001_example: "Formular ohne CSRF-Token im Header oder Body abgesendet"
308 + error_csrf002_desc: "Ungültiges CSRF-Token"
309 + error_csrf002_category: "CSRF-Schutz"
310 + error_csrf002_context: "Das bereitgestellte CSRF-Token stimmt nicht mit dem Sitzungs-Token überein"
311 + error_csrf002_example: "Geändertes oder gestohlenes CSRF-Token"
312 + error_rate001_desc: "Ratenlimit überschritten"
313 + error_rate001_category: "Rate Limiting"
314 + error_rate001_context: "Die Anzahl der Anfragen pro Minute wurde überschritten"
315 + error_rate001_example: "Mehr als 100 Anfragen/Min auf Admin-Endpunkten"
316 + error_valid001_desc: "Ungültige Eingabedaten"
317 + error_valid001_category: "Validierung"
318 + error_valid001_context: "Die bereitgestellten Daten entsprechen nicht den Validierungsregeln"
319 + error_valid001_example: "Ungültiges E-Mail-Format oder fehlendes Pflichtfeld"
320 + error_valid002_desc: "Passwortkomplexität nicht erfüllt"
321 + error_valid002_category: "Validierung"
322 + error_valid002_context: "Das Passwort erfüllt nicht die konfigurierten Komplexitätsregeln"
323 + error_valid002_example: "Passwort zu kurz oder fehlende erforderliche Zeichen"
324 + error_rbac001_desc: "Unzureichende Berechtigungen"
325 + error_rbac001_category: "Autorisierung"
326 + error_rbac001_context: "Der Benutzer hat nicht die erforderlichen Berechtigungen für diese Aktion"
327 + error_rbac001_example: "Nicht-Admin-Benutzer versucht auf /admin/users zuzugreifen"
328 + error_rbac002_desc: "Administratorrolle erforderlich"
329 + error_rbac002_category: "Autorisierung"
330 + error_rbac002_context: "Die Aktion erfordert eine Administratorrolle"
331 + error_rbac002_example: "Versuch einen Benutzer ohne Admin-Rolle zu löschen"
332 + error_db001_desc: "Datenbankoperationsfehler"
333 + error_db001_category: "Datenbank"
334 + error_db001_context: "Eine Datenbankoperation ist fehlgeschlagen"
335 + error_db001_example: "Fremdschlüssel-Constraint verletzt oder DB-Verbindung verloren"
336 + error_db002_desc: "Ressource nicht gefunden"
337 + error_db002_category: "Datenbank"
338 + error_db002_context: "Die angeforderte Ressource existiert nicht in der Datenbank"
339 + error_db002_example: "Benutzer mit nicht existierender ID"
340 + user_form:
341 + breadcrumb_create: "Erstellen"
342 + breadcrumb_edit: "Bearbeiten"
343 + title_create: "Benutzer erstellen"
344 + title_edit: "Benutzer bearbeiten"
345 + back_to_list: "Zurück zur Liste"
346 + username_label: "Benutzername"
347 + username_placeholder: "benutzername"
348 + username_readonly_hint: "Der Benutzername kann nach der Erstellung nicht geändert werden"
349 + email_label: "E-Mail"
350 + email_placeholder: "benutzer@example.com"
351 + password_label: "Passwort"
352 + password_optional: "Optional"
353 + password_placeholder: "Leer lassen, um ein zufälliges Passwort zu generieren"
354 + password_hint: "Wenn leer gelassen, wird automatisch ein sicheres Passwort generiert"
355 + roles_label: "Rollen"
356 + roles_hint: "Wählen Sie mindestens eine Rolle"
357 + must_change_password_label: "Passwortänderung erzwingen"
358 + must_change_password_desc: "Der Benutzer muss sein Passwort bei der nächsten Anmeldung ändern"
359 + cancel: "Abbrechen"
360 + submit_create: "Benutzer erstellen"
361 + submit_edit: "Änderungen speichern"
362 + settings:
363 + title: "Anwendungseinstellungen"
364 + page_title: "Anwendungseinstellungen - Admin - RustCloud"
365 + subtitle: "Globale RustCloud-Konfiguration"
366 + general: "Allgemein"
367 + app_name: "Anwendungsname"
368 + app_domain: "Domäne"
369 + app_domain_placeholder: "example.com"
370 + app_description: "Beschreibung"
371 + allow_registration: "Registrierung erlauben"
372 + allow_registration_desc: "Benutzer können Konten von der Startseite aus erstellen"
373 + email_validation_required: "E-Mail-Validierung erforderlich"
374 + email_validation_required_desc: "Benutzer müssen ihre E-Mail validieren, bevor sie sich anmelden können"
375 + save: "Einstellungen speichern"
376 + cancel: "Abbrechen"
377 + saved: "Einstellungen erfolgreich gespeichert"
378 + error: "Fehler beim Speichern der Einstellungen"
379 + network_error: "Netzwerkfehler. Bitte versuchen Sie es erneut."
380 + password_security: "Passwortsicherheit"
381 + password_min_length: "Mindestlänge"
382 + password_min_length_hint: "Zwischen 6 und 128 Zeichen"
383 + password_complexity: "Komplexitätsanforderungen"
384 + require_uppercase: "Großbuchstabe erforderlich"
385 + require_lowercase: "Kleinbuchstabe erforderlich"
386 + require_digits: "Ziffer erforderlich"
387 + require_special: "Sonderzeichen erforderlich"
388 + password_change_require_email: "E-Mail-Bestätigung für Passwortänderung"
389 + password_change_require_email_desc: "Wenn aktiviert, wird eine Bestätigungs-E-Mail gesendet, um jede Passwortänderung zu validieren. Andernfalls wird die Änderung sofort angewendet."
390 + password_expiration: "Passwortablauf"
391 + password_expiration_desc: "Periodische Passwortänderung erzwingen"
392 + expiration_days: "Ablauf nach (Tagen)"
393 + expiration_alert: "E-Mail-Warnung vor Ablauf"
394 + expiration_alert_days: "Warnung (Tage vorher)"
395 + audit_logging: "Audit-Protokollierung"
396 + log_level: "Protokollebene"
397 + log_level_debug: "DEBUG - Alle Details"
398 + log_level_info: "INFO - Normale Informationen"
399 + log_level_warn: "WARN - Nur Warnungen"
400 + log_level_error: "ERROR - Nur Fehler"
401 + log_level_hint: "Steuert die Ausführlichkeit der Audit-Logs"
402 + log_retention: "Log-Aufbewahrung (Tage)"
403 + log_retention_hint: "Aufbewahrungsdauer der Logs (1-365 Tage)"
404 + audit_info_message: "Audit-Logs zeichnen alle administrativen Aktionen auf (Erstellung, Änderung, Löschung von Benutzern)."
405 + save_changes: "Änderungen speichern"
406 + save_changes_desc: "Einstellungen werden sofort angewendet"
407 + saving: "Speichern..."
408 + save_success: "Einstellungen erfolgreich gespeichert!"
409 + save_error_prefix: "Fehler"
410 + users_list:
411 + page_title: "Benutzerverwaltung - Admin - RustCloud"
412 + title: "Benutzerverwaltung"
413 + breadcrumb_users: "Benutzer"
414 + create_user: "Benutzer erstellen"
415 + search_label: "Suchen"
416 + search_placeholder: "Benutzername oder E-Mail..."
417 + role_filter_label: "Rolle"
418 + all_roles: "Alle Rollen"
419 + per_page_label: "Pro Seite"
420 + search_button: "Suchen"
421 + results_summary: "Anzeige von {count} Benutzer(n) von {total} insgesamt"
422 + page_info: "Seite {page} von {total_pages}"
423 + table_username: "Benutzername"
424 + table_email: "E-Mail"
425 + table_roles: "Rollen"
426 + table_created_at: "Erstellt am"
427 + table_last_login: "Letzte Anmeldung"
428 + table_actions: "Aktionen"
429 + never: "Nie"
430 + edit: "Bearbeiten"
431 + no_users: "Keine Benutzer gefunden"
432 + reset_password_title: "Passwort zurücksetzen"
433 + reset_generate: "Zufälliges Passwort generieren"
434 + reset_custom: "Benutzerdefiniertes Passwort festlegen"
435 + reset_new_password_label: "Neues Passwort"
436 + reset_new_password_placeholder: "Mindestens 8 Zeichen, Großbuchstabe, Kleinbuchstabe, Ziffer, Sonderzeichen"
437 + reset_password_requirements: "⚠️ Muss enthalten: 8+ Zeichen, Großbuchstabe, Kleinbuchstabe, Ziffer, Sonderzeichen"
438 + reset_invalidate_sessions: "Alle aktiven Sitzungen für diesen Benutzer ungültig machen"
439 + reset_invalidate_sessions_desc: "Der Benutzer muss sich nach der Änderung erneut anmelden"
440 + reset_button: "Zurücksetzen"
441 + delete_confirm_title: "⚠️ Löschung bestätigen"
442 + delete_confirm_message: "Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?"
443 + delete_confirm_username: "Benutzername:"
444 + delete_confirm_email: "E-Mail:"
445 + delete_irreversible: "Diese Aktion ist unwiderruflich!"
446 + delete_button: "Endgültig löschen"
447 + delete: "Löschen"
448 + delete_error: "Fehler beim Löschen des Benutzers"
449 + network_error: "Netzwerkfehler"
450 + edit_user_title: "Benutzer bearbeiten"
451 + resetting: "Zurücksetzen..."
452 + reset_by_email_title: "Zurücksetzen per E-Mail"
453 + reset_email: "Per E-Mail zurücksetzen"
454 + close: "Schließen"
455 + password_reset_success_title: "Passwort zurückgesetzt"
456 + password_warning_once: "Dieses Passwort wird nur einmal angezeigt. Kopieren Sie es jetzt!"
457 + new_password_label: "Neues Passwort"
458 + copy: "Kopieren"
459 + password_copied: "Passwort in die Zwischenablage kopiert"
460 + password_set_success: "Passwort erfolgreich zurückgesetzt"
461 + reset_error: "Fehler beim Zurücksetzen des Passworts"
462 +
463 +# Passwort ändern
464 +password:
465 + title: "Mein Passwort ändern"
466 + page_title: "Mein Passwort ändern - RustCloud"
467 + subtitle: "Ändern Sie Ihr Passwort, um Ihr Konto zu sichern. Eine Bestätigungs-E-Mail wird gesendet, um die Änderung zu validieren."
468 + subtitle_no_email: "Ändern Sie Ihr Passwort, um Ihr Konto zu sichern."
469 + must_change_message: "⚠️ Sie müssen Ihr Passwort ändern, bevor Sie fortfahren können."
470 + new_password: "Neues Passwort"
471 + current_password: "Aktuelles Passwort"
472 + current_password_placeholder: "Ihr aktuelles Passwort"
473 + new_password_placeholder: "Sicheres neues Passwort"
474 + required: "Erforderlich"
475 + requirements: "📌 Anforderungen: 8+ Zeichen, Großbuchstabe, Kleinbuchstabe, Ziffer, Sonderzeichen"
476 + strength: "Passwortstärke:"
477 + strength_weak: "Schwach"
478 + strength_medium: "Mittel"
479 + strength_strong: "Stark ✓"
480 + strength_too_short: "zu kurz"
481 + strength_missing_lowercase: "Kleinbuchstabe fehlt"
482 + strength_missing_uppercase: "Großbuchstabe fehlt"
483 + strength_missing_digit: "Ziffer fehlt"
484 + strength_missing_special: "Sonderzeichen fehlt"
485 + confirm_password: "Neues Passwort bestätigen"
486 + confirm_password_placeholder: "Wiederholen Sie das neue Passwort"
487 + passwords_match: "✓ Passwörter stimmen überein"
488 + passwords_not_match: "✗ Passwörter stimmen nicht überein"
489 + change_button: "Passwort ändern"
490 + changing: "Änderung läuft..."
491 + logout_warning_title: "Abmeldung von anderen Sitzungen"
492 + logout_warning_desc: "Alle Ihre anderen aktiven Sitzungen werden nach der Passwortänderung automatisch abgemeldet. Nur Ihre aktuelle Sitzung bleibt aktiv."
493 + save: "Neues Passwort speichern"
494 + saving: "Speichern..."
495 + saved: "Eine Bestätigungs-E-Mail wurde an Ihre Adresse gesendet. Klicken Sie auf den Link in der E-Mail, um die Passwortänderung zu bestätigen."
496 + saved_direct: "Passwort erfolgreich geändert! Ihre anderen Sitzungen wurden abgemeldet."
497 + error: "Fehler beim Ändern des Passworts"
498 + error_all_fields_required: "Alle Felder sind erforderlich."
499 + error_empty_data: "Die zu sendenden Daten sind leer."
500 + error_invalid_data: "Ungültige Daten. Überprüfen Sie, ob alle Felder ausgefüllt sind."
501 + error_incorrect_current: "Aktuelles Passwort ist falsch."
502 + error_reading_response: "Fehler beim Lesen der Serverantwort"
503 + error_415: "Fehler 415: Der Server hat die Daten nicht im erwarteten JSON-Format erhalten. Bitte versuchen Sie es erneut."
504 + network_error: "Netzwerkfehler. Bitte versuchen Sie es erneut."
505 + security_tips_title: "Sicherheitstipps"
506 + security_tip_unique: "Verwenden Sie ein eindeutiges Passwort, das Sie nicht anderswo verwenden"
507 + security_tip_share: "Teilen Sie Ihr Passwort niemals mit jemandem"
508 + security_tip_regular: "Ändern Sie Ihr Passwort regelmäßig (alle 3-6 Monate)"
509 + security_tip_2fa: "Aktivieren Sie die Zwei-Faktor-Authentifizierung, falls verfügbar"
510 + security_tip_manager: "Verwenden Sie einen Passwort-Manager für mehr Sicherheit"
511 +
512 +# Sitzungen
513 +sessions:
514 + title: "Aktive Sitzungen"
515 + page_title: "Aktive Sitzungen - RustCloud"
516 + subtitle: "Verwalten Sie Geräte, die mit Ihrem Konto verbunden sind"
517 + current_session: "Aktuelle Sitzung"
518 + current_badge: "Aktuell"
519 + unknown_device: "Unbekanntes Gerät"
520 + ip_address: "IP-Adresse:"
521 + created_at: "Erstellt am:"
522 + last_activity: "Letzte Aktivität:"
523 + session_id: "Sitzungs-ID:"
524 + revoke: "Widerrufen"
525 + revoke_confirm: "Sind Sie sicher, dass Sie die Sitzung für \"{device}\" widerrufen möchten?\n\nDieses Gerät wird sofort getrennt."
526 + revoke_success: "Sitzung erfolgreich widerrufen!"
527 + revoke_error: "Fehler beim Widerrufen der Sitzung"
528 + no_sessions: "Keine aktiven Sitzungen"
529 + no_sessions_desc: "Sie haben derzeit keine aktiven Sitzungen"
530 + danger_zone: "Gefahrenzone"
531 + danger_zone_desc: "Trennen Sie alle anderen Geräte in einer Aktion (Ihre aktuelle Sitzung bleibt aktiv)."
532 + revoke_all: "Alle anderen Sitzungen widerrufen"
533 + revoke_all_confirm: "⚠️ WARNUNG\n\nSind Sie sicher, dass Sie ALLE Ihre anderen Sitzungen widerrufen möchten?\n\nAlle Ihre anderen Geräte werden getrennt. Nur Ihre aktuelle Sitzung bleibt aktiv."
534 + revoke_all_success: "Alle anderen Sitzungen wurden widerrufen!"
535 + revoke_all_error: "Fehler beim Widerrufen. Einige Sitzungen wurden möglicherweise nicht widerrufen."
536 + about_title: "Über Sitzungen"
537 + about_desc: "Eine Sitzung wird bei jeder Anmeldung von einem anderen Gerät oder Browser erstellt. Das Widerrufen einer Sitzung trennt dieses Gerät sofort. Ihre aktuelle Sitzung kann von dieser Seite nicht widerrufen werden (melden Sie sich ab, um dies zu tun)."
538 + back_to_settings: "Zurück zu den Einstellungen"
539 +
540 +# Navigation menu (navbar)
541 +nav:
542 + home: "Startseite"
543 + settings: "Einstellungen"
544 + admin: "Administration"
545 +
546 +# User menu
547 +user_menu:
548 + profile: "Profil"
549 + logout: "Abmelden"
550 + settings: "Einstellungen"
551 +
552 +# Email verification messages
553 +email_verify:
554 + config_error: "Server-Konfigurationsfehler. Bitte kontaktieren Sie den Administrator."
555 + success: "Ihre E-Mail-Adresse wurde erfolgreich verifiziert. Sie können sich jetzt in Ihr Konto einloggen."
556 + invalid_link: "Der Verifizierungslink ist ungültig oder abgelaufen."
557 +
558 +# Email subjects
559 +email_subject:
560 + password_reset: "Passwort zurücksetzen"
561 + email_validation: "Bestätigung Ihrer E-Mail-Adresse"
562 + welcome: "Willkommen bei RustWarden!"
563 +
564 +# Rate limiting
565 +rate_limit:
566 + exceeded: "Zu viele Anfragen. Bitte versuchen Sie es später erneut."
567 + password_change: "Zu viele Passwortänderungsanfragen. Bitte warten Sie, bevor Sie es erneut versuchen."
568 +
569 +# Sessions
570 +session:
571 + ip_unavailable: "Nicht verfügbar"
572 +
A crates/rustwarden-ui/locales_old/en.yml
+593 -0
@@ -0,0 +1,593 @@
1 +# English translations for RustCloud
2 +# Format: hierarchical keys with dots
3 +
4 +# Login
5 +login:
6 + title: "Login"
7 + page_title: "Login - RustCloud"
8 + page_description: "Login to RustCloud"
9 + subtitle: "Sign in to your cloud space"
10 + username: "Username"
11 + password: "Password"
12 + submit: "Sign in"
13 + remember_me: "Remember me"
14 + tab_login: "Login"
15 + tab_register: "Register"
16 + register_title: "Create an account"
17 + register_submit: "Create my account"
18 + username_placeholder: "Your username"
19 + password_placeholder: "Your password"
20 + username_placeholder_register: "Your username (min. 3 characters)"
21 + password_placeholder_register: "Your password (min. 8 characters)"
22 + email_placeholder: "your@email.com"
23 + forgot_password: "Forgot password?"
24 + email_not_verified: "Email not verified?"
25 + resend_verification: "Resend verification email"
26 + already_have_account: "Already have an account?"
27 + sign_in_link: "Sign in"
28 + success_message: "Login successful! Redirecting..."
29 + error_message: "Login error. Check your credentials."
30 + email_unverified_message: "Your email is not verified. Click below to resend the link."
31 + resend_button: "Resend verification email"
32 + resend_success: "Verification email sent if the account exists."
33 + resend_error: "Unable to send the request at this time."
34 + resend_prompt: "Please enter your email before resending."
35 + register_success: "Account created successfully! Please check your email."
36 + register_error: "Registration error"
37 + email_label: "Email"
38 + footer_text: "Nextcloud fork in Rust"
39 +
40 +# Resend verification email
41 +resend_verification:
42 + page_title: "Resend verification email - RustWarden"
43 + title: "Resend verification email"
44 + subtitle: "Enter your email address to receive a new verification link."
45 + email_label: "Email address"
46 + email_placeholder: "you@example.com"
47 + submit: "Send verification link"
48 + back_to_login: "Back to login"
49 + error_default: "Unable to send the request."
50 + success_default: "If the account exists and is not verified, an email has been sent."
51 + error_missing_email: "Please enter your email."
52 + error_cannot_send: "Unable to send the request at this time."
53 +
54 +# Password reset (from email link)
55 +password_reset:
56 + page_title: "Reset password"
57 + title: "Reset password"
58 + subtitle: "Enter your new password below."
59 + new_password_label: "New password"
60 + new_password_placeholder: "New secure password"
61 + requirements: "⚠️ Must contain: 8+ characters, uppercase, lowercase, digit, special character"
62 + confirm_password_label: "Confirm password"
63 + confirm_password_placeholder: "Confirm your new password"
64 + missing_token_url: "Reset token missing in URL."
65 + missing_token: "Reset token is missing."
66 + missing_new_password: "Please enter a new password."
67 + passwords_not_match: "Passwords do not match."
68 + success_message: "Your password has been reset successfully. You can now log in."
69 + error_link_invalid: "Unable to reset password. The link may be expired or invalid."
70 + error_generic: "Unable to reset password at this time."
71 +
72 +# Error messages
73 +error:
74 + invalid_credentials: "Invalid credentials"
75 + email_invalid: "Invalid email"
76 + required_field: "This field is required"
77 + unauthorized: "Unauthorized"
78 + not_found: "Resource not found"
79 + validation: "Validation error: {message}"
80 + database: "Database error occurred"
81 + internal: "Internal server error"
82 + invalid_token: "Invalid token"
83 + token_expired: "Token expired"
84 + token_reused: "Token reused - please re-authenticate"
85 + must_change_password: "Password change required"
86 + authentication_required: "Authentication required"
87 + insufficient_permissions: "Insufficient permissions"
88 + config: "Server configuration error"
89 + template: "Template error"
90 + validation_field:
91 + username_min_length: "Username must contain at least {min} characters"
92 + password_min_length: "Password must contain at least {min} characters"
93 + email_format: "Email address is not valid"
94 + password_require_uppercase: "Password must contain at least one uppercase letter"
95 + password_require_lowercase: "Password must contain at least one lowercase letter"
96 + password_require_digits: "Password must contain at least one digit"
97 + password_require_special: "Password must contain at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)"
98 +
99 +# Settings
100 +settings:
101 + title: "Settings"
102 + page_title: "Settings - RustWarden"
103 + subtitle: "Manage your preferences and account settings"
104 + profile_title: "My Profile"
105 + profile_desc: "Edit your personal information (username, email)"
106 + password_title: "Password"
107 + password_desc: "Change your password to secure your account"
108 + sessions_title: "Active Sessions"
109 + sessions_desc: "Manage your active sessions and revoke access from other devices"
110 + preferences_title: "Preferences"
111 + preferences_desc: "Customize your experience (theme, language)"
112 + access: "Access"
113 + info_title: "Information"
114 + info_desc: "Use the sections above to manage different aspects of your account. All changes are secure and require your authentication."
115 + language: "Language"
116 + language_description: "Choose your preferred language"
117 + save: "Save"
118 + saved: "Settings saved"
119 +
120 +# Home page
121 +home:
122 + welcome: "Welcome, {username}!"
123 + subtitle: "Welcome to your personal space"
124 + my_account: "My Account"
125 + stat_username: "Username"
126 + stat_email: "Email"
127 + stat_member_since: "Member since"
128 + stat_roles: "Roles"
129 +
130 +# Common messages
131 +common:
132 + welcome: "Welcome {username}"
133 + loading: "Loading..."
134 + save: "Save"
135 + cancel: "Cancel"
136 + delete: "Delete"
137 + edit: "Edit"
138 + create: "Create"
139 +
140 +# i18n messages
141 +i18n:
142 + language_updated: "Language preference updated. Please reload the page to apply changes."
143 + invalid_language: "Invalid language code. Supported: fr, en, de, es"
144 +
145 +# Preferences
146 +preferences:
147 + title: "Display Preferences"
148 + page_title: "Display Preferences - RustCloud"
149 + subtitle: "Customize the interface appearance"
150 + theme: "Theme"
151 + theme_light: "Light"
152 + theme_light_desc: "Bright and clean interface"
153 + theme_dark: "Dark"
154 + theme_dark_desc: "Reduces eye strain"
155 + theme_auto: "Automatic"
156 + theme_auto_desc: "Adapts to time of day"
157 + theme_preview: "The theme will be applied immediately after saving"
158 + language: "Language"
159 + language_fr: "Français"
160 + language_fr_desc: "Interface in French"
161 + language_en: "English"
162 + language_en_desc: "English interface"
163 + language_de: "Deutsch"
164 + language_de_desc: "German interface"
165 + language_es: "Español"
166 + language_es_desc: "Spanish interface"
167 + translation_warning: "Complete interface translation is under development. Some elements may remain in French."
168 + modified: "Preferences modified"
169 + modified_desc: "Save your changes to apply them"
170 + save: "Save preferences"
171 + saving: "Saving..."
172 + cancel: "Cancel"
173 + saved_success: "Preferences saved successfully!"
174 + saved_reload: "Preferences saved successfully!\n\nThe language has been changed. Do you want to reload the page now to apply the changes?"
175 + error: "Error: {message}"
176 + network_error: "Network error. Please try again."
177 + sync_title: "Your preferences are synchronized"
178 + sync_desc: "Your display preferences are saved and automatically apply to all devices connected to your RustCloud account."
179 + preview_dark: "Preview: Dark theme will be applied"
180 + preview_light: "Preview: Light theme will be applied"
181 + preview_auto: "Preview: Automatic theme will be applied"
182 +
183 +# Profile
184 +profile:
185 + title: "My Profile"
186 + page_title: "My Profile - RustCloud"
187 + subtitle: "Edit your personal information"
188 + account_info: "Account Information"
189 + username: "Username"
190 + username_public: "Publicly visible"
191 + username_placeholder: "Your username"
192 + username_pattern: "Letters, numbers, dashes and underscores only"
193 + email: "Email"
194 + email_placeholder: "your@email.com"
195 + save: "Save changes"
196 + saving: "Saving..."
197 + saved: "Profile updated successfully"
198 + error: "Error updating profile"
199 + email_label: "Email address"
200 + email_used_for: "Used for login"
201 + email_warning: "⚠️ You will be logged out after modification"
202 + email_change_info: "Email changes require reconnection for security reasons."
203 + username_help: "3-50 characters (letters, numbers, -, _)"
204 + additional_info: "Additional Information"
205 + account_created: "Account created on"
206 + roles: "Roles"
207 +
208 +# Breadcrumb
209 +breadcrumb:
210 + home: "Home"
211 + settings: "Settings"
212 + admin: "Administration"
213 + preferences: "Preferences"
214 + profile: "Profile"
215 + password: "Password"
216 + sessions: "Sessions"
217 +
218 +# Admin
219 +admin:
220 + dashboard:
221 + title: "Administration"
222 + heading: "Administrator Dashboard"
223 + users_card_title: "Users"
224 + users_card_subtitle: "Total users"
225 + access_button: "Access"
226 + sessions_card_title: "Active Sessions"
227 + sessions_card_subtitle: "Ongoing sessions"
228 + admins_card_title: "Administrators"
229 + admins_card_subtitle: "Admin accounts"
230 + last_activity_card_title: "Last Activity"
231 + last_activity_card_subtitle: "Last login"
232 + audit_section_title: "Audit Log Statistics"
233 + audit_total_logs: "Total logs"
234 + audit_last_24h: "Last 24h"
235 + audit_by_level: "By level"
236 + chart_label: "Logs by level"
237 + roles_card_title: "Roles Management"
238 + roles_card_desc: "Manage roles and granular permissions"
239 + roles_stat_title: "Roles"
240 + settings_card_title: "Application Settings"
241 + settings_card_desc: "Configure global application settings"
242 + apps_card_title: "Application Management"
243 + apps_card_desc: "Enable or disable modular applications"
244 + orgs_card_title: "Organisation Management"
245 + orgs_card_desc: "Create and manage organisations and their members"
246 + orgs_stat_title: "Organisations"
247 + orgs_members_stat_title: "Total members"
248 + roles_list:
249 + page_title: "Roles Management"
250 + breadcrumb_roles: "Roles"
251 + title: "Roles"
252 + create_role: "Create a role"
253 + table_name: "Name"
254 + table_description: "Description"
255 + table_users: "Users"
256 + table_permissions: "Permissions"
257 + table_actions: "Actions"
258 + edit: "Edit"
259 + delete: "Delete"
260 + no_roles: "No roles found."
261 + protected_hint: "Protected"
262 + delete_confirm_title: "Confirm deletion"
263 + delete_confirm_message: "Are you sure you want to delete the role"
264 + delete_button: "Delete"
265 + role_form:
266 + title_create: "Create a role"
267 + title_edit: "Edit role"
268 + breadcrumb_create: "Create"
269 + breadcrumb_edit: "Edit"
270 + back_to_list: "Back to list"
271 + name_label: "Role name"
272 + name_placeholder: "e.g. moderator"
273 + description_label: "Description"
274 + description_placeholder: "Role description..."
275 + permissions_label: "Permissions"
276 + protected_name_hint: "This role name cannot be changed."
277 + cancel: "Cancel"
278 + submit_create: "Create role"
279 + submit_edit: "Save changes"
280 + errors:
281 + title: "Error Codes"
282 + description: "Application error code documentation. Each error code is associated with a middleware and provides context information for debugging."
283 + breadcrumb_errors: "Error Codes"
284 + table_code: "Code"
285 + table_description: "Description"
286 + table_category: "Category"
287 + table_middleware: "Middleware"
288 + table_context: "Context"
289 + table_example: "Example"
290 + legend_title: "Legend"
291 + legend_code: "Unique error identifier (format: CATEGORY_NUMBER)"
292 + legend_description: "Description of the error"
293 + legend_category: "Error type (Authentication, CSRF, Rate Limiting, etc.)"
294 + legend_middleware: "Middleware responsible for generating this error"
295 + legend_context: "Detailed explanation of when this error occurs"
296 + legend_example: "Concrete example of a situation generating this error"
297 + error_auth001_desc: "Invalid credentials"
298 + error_auth001_category: "Authentication"
299 + error_auth001_context: "The user provided incorrect credentials during login"
300 + error_auth001_example: "Login attempt with an incorrect password"
301 + error_auth002_desc: "Invalid token"
302 + error_auth002_category: "Authentication"
303 + error_auth002_context: "The provided JWT token is invalid or malformed"
304 + error_auth002_example: "Corrupted JWT token or signed with a different key"
305 + error_auth003_desc: "Token expired"
306 + error_auth003_category: "Authentication"
307 + error_auth003_context: "The JWT token has expired and needs to be renewed"
308 + error_auth003_example: "Access attempt with an expired token after the validity period"
309 + error_csrf001_desc: "Missing CSRF token"
310 + error_csrf001_category: "CSRF Protection"
311 + error_csrf001_context: "The POST/PUT/DELETE request does not contain a CSRF token"
312 + error_csrf001_example: "Form submitted without a CSRF token in the header or body"
313 + error_csrf002_desc: "Invalid CSRF token"
314 + error_csrf002_category: "CSRF Protection"
315 + error_csrf002_context: "The provided CSRF token does not match the session token"
316 + error_csrf002_example: "Modified or stolen CSRF token"
317 + error_rate001_desc: "Rate limit exceeded"
318 + error_rate001_category: "Rate Limiting"
319 + error_rate001_context: "The number of requests per minute has been exceeded"
320 + error_rate001_example: "More than 100 requests/min on admin endpoints"
321 + error_valid001_desc: "Invalid input data"
322 + error_valid001_category: "Validation"
323 + error_valid001_context: "The provided data does not comply with validation rules"
324 + error_valid001_example: "Invalid email format or missing required field"
325 + error_valid002_desc: "Password complexity not met"
326 + error_valid002_category: "Validation"
327 + error_valid002_context: "The password does not meet the configured complexity rules"
328 + error_valid002_example: "Password too short or missing required characters"
329 + error_rbac001_desc: "Insufficient permissions"
330 + error_rbac001_category: "Authorization"
331 + error_rbac001_context: "The user does not have the necessary permissions for this action"
332 + error_rbac001_example: "Non-admin user attempting to access /admin/users"
333 + error_rbac002_desc: "Admin role required"
334 + error_rbac002_category: "Authorization"
335 + error_rbac002_context: "The action requires an administrator role"
336 + error_rbac002_example: "Attempting to delete a user without admin role"
337 + error_db001_desc: "Database operation error"
338 + error_db001_category: "Database"
339 + error_db001_context: "A database operation failed"
340 + error_db001_example: "Foreign key constraint violated or DB connection lost"
341 + error_db002_desc: "Resource not found"
342 + error_db002_category: "Database"
343 + error_db002_context: "The requested resource does not exist in the database"
344 + error_db002_example: "User with non-existent ID"
345 + user_form:
346 + breadcrumb_create: "Create"
347 + breadcrumb_edit: "Edit"
348 + title_create: "Create a user"
349 + title_edit: "Edit a user"
350 + back_to_list: "Back to list"
351 + username_label: "Username"
352 + username_placeholder: "username"
353 + username_readonly_hint: "Username cannot be changed after creation"
354 + email_label: "Email"
355 + email_placeholder: "user@example.com"
356 + password_label: "Password"
357 + password_optional: "Optional"
358 + password_placeholder: "Leave empty to generate a random password"
359 + password_hint: "If left empty, a secure password will be automatically generated"
360 + roles_label: "Roles"
361 + roles_hint: "Select at least one role"
362 + must_change_password_label: "Force password change"
363 + must_change_password_desc: "The user will have to change their password at next login"
364 + cancel: "Cancel"
365 + submit_create: "Create user"
366 + submit_edit: "Save changes"
367 + settings:
368 + title: "Application Settings"
369 + page_title: "Application Settings - Admin - RustCloud"
370 + subtitle: "Global RustCloud configuration"
371 + general: "General"
372 + app_name: "Application name"
373 + app_domain: "Domain"
374 + app_domain_placeholder: "example.com"
375 + app_description: "Description"
376 + allow_registration: "Allow registration"
377 + allow_registration_desc: "Users can create accounts from the home page"
378 + email_validation_required: "Email validation required"
379 + email_validation_required_desc: "Users must validate their email before they can log in"
380 + save: "Save settings"
381 + cancel: "Cancel"
382 + saved: "Settings saved successfully"
383 + error: "Error saving settings"
384 + network_error: "Network error. Please try again."
385 + password_security: "Password Security"
386 + password_min_length: "Minimum length"
387 + password_min_length_hint: "Between 6 and 128 characters"
388 + password_complexity: "Complexity requirements"
389 + require_uppercase: "Uppercase required"
390 + require_lowercase: "Lowercase required"
391 + require_digits: "Digit required"
392 + require_special: "Special character required"
393 + password_change_require_email: "Email confirmation for password change"
394 + password_change_require_email_desc: "When enabled, a confirmation email is sent to validate any password change. Otherwise, the change is applied immediately."
395 + password_expiration: "Password expiration"
396 + password_expiration_desc: "Force periodic password change"
397 + expiration_days: "Expiration after (days)"
398 + expiration_alert: "Email alert before expiration"
399 + expiration_alert_days: "Alert (days before)"
400 + audit_logging: "Audit Logging"
401 + log_level: "Log level"
402 + log_level_debug: "DEBUG - All details"
403 + log_level_info: "INFO - Normal information"
404 + log_level_warn: "WARN - Warnings only"
405 + log_level_error: "ERROR - Errors only"
406 + log_level_hint: "Controls the verbosity of audit logs"
407 + log_retention: "Log retention (days)"
408 + log_retention_hint: "Log retention period (1-365 days)"
409 + audit_info_message: "Audit logs record all administrative actions (creation, modification, deletion of users)."
410 + save_changes: "Save changes"
411 + save_changes_desc: "Settings will be applied immediately"
412 + saving: "Saving..."
413 + save_success: "Settings saved successfully!"
414 + save_error_prefix: "Error"
415 + users_list:
416 + page_title: "User Management - Admin - RustCloud"
417 + title: "User Management"
418 + breadcrumb_users: "Users"
419 + create_user: "Create user"
420 + search_label: "Search"
421 + search_placeholder: "Username or email..."
422 + role_filter_label: "Role"
423 + all_roles: "All roles"
424 + per_page_label: "Per page"
425 + search_button: "Search"
426 + results_summary: "Showing {count} user(s) out of {total} total"
427 + page_info: "Page {page} of {total_pages}"
428 + table_username: "Username"
429 + table_email: "Email"
430 + table_roles: "Roles"
431 + table_created_at: "Created on"
432 + table_last_login: "Last login"
433 + table_actions: "Actions"
434 + never: "Never"
435 + edit: "Edit"
436 + no_users: "No users found"
437 + reset_password_title: "Reset password"
438 + reset_generate: "Generate random password"
439 + reset_custom: "Set custom password"
440 + reset_email: "Reset by email"
441 + reset_new_password_label: "New password"
442 + reset_new_password_placeholder: "Minimum 8 characters, uppercase, lowercase, digit, special character"
443 + reset_password_requirements: "⚠️ Must contain: 8+ characters, uppercase, lowercase, digit, special character"
444 + reset_invalidate_sessions: "Invalidate all active sessions for this user"
445 + reset_invalidate_sessions_desc: "The user will need to log in again after the change"
446 + reset_button: "Reset"
447 + delete_confirm_title: "⚠️ Confirm deletion"
448 + delete_confirm_message: "Are you sure you want to delete this user?"
449 + delete_confirm_username: "Username:"
450 + delete_confirm_email: "Email:"
451 + delete_irreversible: "This action is irreversible!"
452 + delete_button: "Delete permanently"
453 + delete: "Delete"
454 + delete_error: "Error deleting user"
455 + network_error: "Network error"
456 + edit_user_title: "Edit user"
457 + resetting: "Resetting..."
458 + reset_by_email_title: "Reset by email"
459 + close: "Close"
460 + password_reset_success_title: "Password reset"
461 + password_warning_once: "This password will only be displayed once. Copy it now!"
462 + new_password_label: "New password"
463 + copy: "Copy"
464 + password_copied: "Password copied to clipboard"
465 + password_set_success: "Password reset successfully"
466 + reset_error: "Error resetting password"
467 +
468 +# Password Change
469 +password:
470 + title: "Change my password"
471 + page_title: "Change my password - RustCloud"
472 + subtitle: "Modify your password to secure your account. A confirmation email will be sent to validate the change."
473 + subtitle_no_email: "Modify your password to secure your account."
474 + must_change_message: "⚠️ You must change your password before continuing."
475 + new_password: "New password"
476 + current_password: "Current password"
477 + current_password_placeholder: "Your current password"
478 + new_password_placeholder: "Secure new password"
479 + required: "Required"
480 + requirements: "📌 Requirements: 8+ characters, uppercase, lowercase, digit, special character"
481 + strength: "Password strength:"
482 + strength_weak: "Weak"
483 + strength_medium: "Medium"
484 + strength_strong: "Strong ✓"
485 + strength_too_short: "too short"
486 + strength_missing_lowercase: "missing lowercase"
487 + strength_missing_uppercase: "missing uppercase"
488 + strength_missing_digit: "missing digit"
489 + strength_missing_special: "missing special character"
490 + confirm_password: "Confirm new password"
491 + confirm_password_placeholder: "Repeat the new password"
492 + passwords_match: "✓ Passwords match"
493 + passwords_not_match: "✗ Passwords do not match"
494 + change_button: "Change password"
495 + changing: "Changing..."
496 + logout_warning_title: "Logout from other sessions"
497 + logout_warning_desc: "All your other active sessions will be automatically logged out after the password change. Only your current session will remain active."
498 + save: "Save new password"
499 + saving: "Saving..."
500 + saved: "A confirmation email has been sent to your address. Click the link in the email to validate the password change."
501 + saved_direct: "Password changed successfully! Your other sessions have been logged out."
502 + error: "Error changing password"
503 + error_all_fields_required: "All fields are required."
504 + error_empty_data: "The data to send is empty."
505 + error_invalid_data: "Invalid data. Check that all fields are filled."
506 + error_incorrect_current: "Current password is incorrect."
507 + error_reading_response: "Error reading server response"
508 + error_415: "Error 415: The server did not receive the data in the expected JSON format. Please try again."
509 + network_error: "Network error. Please try again."
510 + security_tips_title: "Security Tips"
511 + security_tip_unique: "Use a unique password that you don't use elsewhere"
512 + security_tip_share: "Never share your password with anyone"
513 + security_tip_regular: "Change your password regularly (every 3-6 months)"
514 + security_tip_2fa: "Enable two-factor authentication if available"
515 + security_tip_manager: "Use a password manager for better security"
516 + pending_request_message: "⚠️ A password change request is pending email confirmation."
517 + pending_request_info: "A confirmation email has been sent to your address. Click the link in the email to confirm the change."
518 + pending_request_cancel: "Cancel pending request"
519 + pending_request_canceled: "Request canceled successfully"
520 + pending_request_cancel_error: "Error canceling the request"
521 + confirmation_sent: "Confirmation email sent! Check your inbox."
522 + confirmation_error: "Error sending confirmation email"
523 +
524 +# Password Change Confirmation Email
525 +password_change_confirmation:
526 + subject: "Password Change Confirmation"
527 + greeting: "Hello {username}"
528 + message: "You have requested to change your password for your {app_name} account. To confirm this change, click the link below."
529 + button_text: "Confirm Password Change"
530 + expires_message: "This link is valid for {hours} hours and is single-use."
531 + footer: "This email was sent automatically, please do not reply."
532 +
533 +# Sessions
534 +sessions:
535 + title: "Active sessions"
536 + page_title: "Active sessions - RustCloud"
537 + subtitle: "Manage devices connected to your account"
538 + current_session: "Current session"
539 + current_badge: "Current"
540 + unknown_device: "Unknown device"
541 + ip_address: "IP Address:"
542 + created_at: "Created on:"
543 + last_activity: "Last activity:"
544 + session_id: "Session ID:"
545 + revoke: "Revoke"
546 + revoke_confirm: "Are you sure you want to revoke the session for \"{device}\"?\n\nThis device will be disconnected immediately."
547 + revoke_success: "Session revoked successfully!"
548 + revoke_error: "Error revoking session"
549 + no_sessions: "No active sessions"
550 + no_sessions_desc: "You currently have no active sessions"
551 + danger_zone: "Danger Zone"
552 + danger_zone_desc: "Disconnect all other devices in one action (your current session will remain active)."
553 + revoke_all: "Revoke all other sessions"
554 + revoke_all_confirm: "⚠️ WARNING\n\nAre you sure you want to revoke ALL your other sessions?\n\nAll your other devices will be disconnected. Only your current session will remain active."
555 + revoke_all_success: "All other sessions have been revoked!"
556 + revoke_all_error: "Error revoking. Some sessions may not have been revoked."
557 + about_title: "About sessions"
558 + about_desc: "A session is created each time you log in from a different device or browser. Revoking a session immediately disconnects that device. Your current session cannot be revoked from this page (log out to do that)."
559 + back_to_settings: "Back to settings"
560 +
561 +# Navigation menu (navbar)
562 +nav:
563 + home: "Home"
564 + settings: "Settings"
565 + admin: "Administration"
566 +
567 +# User menu
568 +user_menu:
569 + profile: "Profile"
570 + logout: "Logout"
571 + settings: "Settings"
572 +
573 +# Email verification messages
574 +email_verify:
575 + config_error: "Server configuration error. Please contact the administrator."
576 + success: "Your email address has been successfully verified. You can now log in to your account."
577 + invalid_link: "The verification link is invalid or has expired."
578 +
579 +# Email subjects
580 +email_subject:
581 + password_reset: "Password Reset"
582 + email_validation: "Verify Your Email Address"
583 + welcome: "Welcome to RustWarden!"
584 +
585 +# Rate limiting
586 +rate_limit:
587 + exceeded: "Too many requests. Please try again later."
588 + password_change: "Too many password change requests. Please wait before trying again."
589 +
590 +# Sessions
591 +session:
592 + ip_unavailable: "Unavailable"
593 +
A crates/rustwarden-ui/locales_old/es.yml
+572 -0
@@ -0,0 +1,572 @@
1 +# Traducciones en español para RustCloud
2 +# Formato: claves jerárquicas con puntos
3 +
4 +# Inicio de sesión
5 +login:
6 + title: "Iniciar sesión"
7 + page_title: "Iniciar sesión - RustCloud"
8 + page_description: "Iniciar sesión en RustCloud"
9 + subtitle: "Inicie sesión en su espacio en la nube"
10 + username: "Nombre de usuario"
11 + password: "Contraseña"
12 + submit: "Iniciar sesión"
13 + remember_me: "Recordarme"
14 + tab_login: "Iniciar sesión"
15 + tab_register: "Registrarse"
16 + register_title: "Crear una cuenta"
17 + register_submit: "Crear mi cuenta"
18 + username_placeholder: "Su nombre de usuario"
19 + password_placeholder: "Su contraseña"
20 + username_placeholder_register: "Su nombre de usuario (mín. 3 caracteres)"
21 + password_placeholder_register: "Su contraseña (mín. 8 caracteres)"
22 + email_placeholder: "su@email.com"
23 + forgot_password: "¿Olvidó su contraseña?"
24 + email_not_verified: "¿Email no verificado?"
25 + resend_verification: "Reenviar email de verificación"
26 + already_have_account: "¿Ya tiene una cuenta?"
27 + sign_in_link: "Iniciar sesión"
28 + success_message: "¡Inicio de sesión exitoso! Redirigiendo..."
29 + error_message: "Error de inicio de sesión. Verifique sus credenciales."
30 + email_unverified_message: "Su email no está verificado. Haga clic a continuación para reenviar el enlace."
31 + resend_button: "Reenviar email de verificación"
32 + resend_success: "Email de verificación enviado si la cuenta existe."
33 + resend_error: "No se puede enviar la solicitud en este momento."
34 + resend_prompt: "Por favor ingrese su email antes de reenviar."
35 + register_success: "¡Cuenta creada con éxito! Por favor verifique su email."
36 + register_error: "Error en el registro"
37 + email_label: "Correo electrónico"
38 + footer_text: "Fork de Nextcloud en Rust"
39 +
40 +# Mensajes de error
41 +error:
42 + invalid_credentials: "Credenciales inválidas"
43 + email_invalid: "Email inválido"
44 + required_field: "Este campo es obligatorio"
45 + unauthorized: "No autorizado"
46 + not_found: "Recurso no encontrado"
47 + validation: "Error de validación: {message}"
48 + database: "Error de base de datos"
49 + internal: "Error interno del servidor"
50 + invalid_token: "Token inválido"
51 + token_expired: "Token expirado"
52 + token_reused: "Token reutilizado - por favor vuelva a autenticarse"
53 + must_change_password: "Cambio de contraseña requerido"
54 + authentication_required: "Autenticación requerida"
55 + insufficient_permissions: "Permisos insuficientes"
56 + config: "Error de configuración del servidor"
57 + template: "Error de plantilla"
58 + validation_field:
59 + username_min_length: "El nombre de usuario debe contener al menos {min} caracteres"
60 + password_min_length: "La contraseña debe contener al menos {min} caracteres"
61 + email_format: "La dirección de correo electrónico no es válida"
62 + password_require_uppercase: "La contraseña debe contener al menos una letra mayúscula"
63 + password_require_lowercase: "La contraseña debe contener al menos una letra minúscula"
64 + password_require_digits: "La contraseña debe contener al menos un dígito"
65 + password_require_special: "La contraseña debe contener al menos un carácter especial (!@#$%^&*()_+-=[]{}|;:,.<>?)"
66 +
67 +# Reenviar email de verificación
68 +resend_verification:
69 + page_title: "Reenviar email de verificación - RustWarden"
70 + title: "Reenviar email de verificación"
71 + subtitle: "Ingrese su dirección de correo electrónico para recibir un nuevo enlace de verificación."
72 + email_label: "Dirección de correo electrónico"
73 + email_placeholder: "usted@ejemplo.com"
74 + submit: "Enviar enlace de verificación"
75 + back_to_login: "Volver al inicio de sesión"
76 + error_default: "No se pudo enviar la solicitud."
77 + success_default: "Si la cuenta existe y no está verificada, se ha enviado un email."
78 + error_missing_email: "Por favor ingrese su correo electrónico."
79 + error_cannot_send: "No se puede enviar la solicitud en este momento."
80 +
81 +# Restablecimiento de contraseña (desde enlace de email)
82 +password_reset:
83 + page_title: "Restablecer contraseña"
84 + title: "Restablecer contraseña"
85 + subtitle: "Ingrese su nueva contraseña a continuación."
86 + new_password_label: "Nueva contraseña"
87 + new_password_placeholder: "Nueva contraseña segura"
88 + requirements: "⚠️ Debe contener: 8+ caracteres, mayúscula, minúscula, dígito, carácter especial"
89 + confirm_password_label: "Confirmar contraseña"
90 + confirm_password_placeholder: "Confirme su nueva contraseña"
91 + missing_token_url: "Falta el token de restablecimiento en la URL."
92 + missing_token: "Falta el token de restablecimiento."
93 + missing_new_password: "Por favor ingrese una nueva contraseña."
94 + passwords_not_match: "Las contraseñas no coinciden."
95 + success_message: "Su contraseña ha sido restablecida con éxito. Ahora puede iniciar sesión."
96 + error_link_invalid: "No se pudo restablecer la contraseña. El enlace puede haber expirado o ser inválido."
97 + error_generic: "No se puede restablecer la contraseña en este momento."
98 +
99 +# Configuración
100 +settings:
101 + title: "Configuración"
102 + page_title: "Configuración - RustWarden"
103 + subtitle: "Gestione sus preferencias y configuración de cuenta"
104 + profile_title: "Mi Perfil"
105 + profile_desc: "Edite su información personal (nombre de usuario, email)"
106 + password_title: "Contraseña"
107 + password_desc: "Cambie su contraseña para asegurar su cuenta"
108 + sessions_title: "Sesiones activas"
109 + sessions_desc: "Gestione sus sesiones activas y revoque el acceso desde otros dispositivos"
110 + preferences_title: "Preferencias"
111 + preferences_desc: "Personalice su experiencia (tema, idioma)"
112 + access: "Acceder"
113 + info_title: "Información"
114 + info_desc: "Utilice las secciones anteriores para gestionar diferentes aspectos de su cuenta. Todos los cambios son seguros y requieren su autenticación."
115 + language: "Idioma"
116 + language_description: "Elija su idioma preferido"
117 + save: "Guardar"
118 + saved: "Configuración guardada"
119 +
120 +# Página de inicio
121 +home:
122 + welcome: "¡Bienvenido, {username}!"
123 + subtitle: "Bienvenido a tu espacio personal"
124 + my_account: "Mi Cuenta"
125 + stat_username: "Nombre de usuario"
126 + stat_email: "Correo electrónico"
127 + stat_member_since: "Miembro desde"
128 + stat_roles: "Roles"
129 +
130 +# Mensajes comunes
131 +common:
132 + welcome: "Bienvenido {username}"
133 + loading: "Cargando..."
134 + save: "Guardar"
135 + cancel: "Cancelar"
136 + delete: "Eliminar"
137 + edit: "Editar"
138 + create: "Crear"
139 +
140 +# Preferencias
141 +preferences:
142 + title: "Preferencias de visualización"
143 + page_title: "Preferencias de visualización - RustCloud"
144 + subtitle: "Personalice la apariencia de la interfaz"
145 + theme: "Tema"
146 + theme_light: "Claro"
147 + theme_light_desc: "Interfaz brillante y limpia"
148 + theme_dark: "Oscuro"
149 + theme_dark_desc: "Reduce la fatiga visual"
150 + theme_auto: "Automático"
151 + theme_auto_desc: "Se adapta a la hora del día"
152 + theme_preview: "El tema se aplicará inmediatamente después de guardar"
153 + language: "Idioma"
154 + language_fr: "Français"
155 + language_fr_desc: "Interfaz en francés"
156 + language_en: "English"
157 + language_en_desc: "Interfaz en inglés"
158 + language_de: "Deutsch"
159 + language_de_desc: "Interfaz en alemán"
160 + language_es: "Español"
161 + language_es_desc: "Interfaz en español"
162 + translation_warning: "La traducción completa de la interfaz está en desarrollo. Algunos elementos pueden permanecer en francés."
163 + modified: "Preferencias modificadas"
164 + modified_desc: "Guarde sus cambios para aplicarlos"
165 + save: "Guardar preferencias"
166 + saving: "Guardando..."
167 + cancel: "Cancelar"
168 + saved_success: "¡Preferencias guardadas con éxito!"
169 + saved_reload: "¡Preferencias guardadas con éxito!\n\nEl idioma ha sido cambiado. ¿Desea recargar la página ahora para aplicar los cambios?"
170 + error: "Error: {message}"
171 + network_error: "Error de red. Por favor intente nuevamente."
172 + sync_title: "Sus preferencias están sincronizadas"
173 + sync_desc: "Sus preferencias de visualización se guardan y se aplican automáticamente en todos los dispositivos conectados a su cuenta RustCloud."
174 + preview_dark: "Vista previa: Se aplicará el tema oscuro"
175 + preview_light: "Vista previa: Se aplicará el tema claro"
176 + preview_auto: "Vista previa: Se aplicará el tema automático"
177 +
178 +# Perfil
179 +profile:
180 + title: "Mi Perfil"
181 + page_title: "Mi Perfil - RustCloud"
182 + subtitle: "Edite su información personal"
183 + account_info: "Información de la cuenta"
184 + username: "Nombre de usuario"
185 + username_public: "Visible públicamente"
186 + username_placeholder: "Su nombre de usuario"
187 + username_pattern: "Solo letras, números, guiones y guiones bajos"
188 + email: "Email"
189 + email_placeholder: "su@email.com"
190 + save: "Guardar cambios"
191 + saving: "Guardando..."
192 + saved: "Perfil actualizado con éxito"
193 + error: "Error al actualizar el perfil"
194 + email_label: "Dirección de correo electrónico"
195 + email_used_for: "Se utiliza para iniciar sesión"
196 + email_warning: "⚠️ Será desconectado después de la modificación"
197 + email_change_info: "Los cambios de correo electrónico requieren reconexión por razones de seguridad."
198 + username_help: "3-50 caracteres (letras, números, -, _)"
199 + additional_info: "Información adicional"
200 + account_created: "Cuenta creada el"
201 + roles: "Roles"
202 +
203 +# Breadcrumb
204 +breadcrumb:
205 + home: "Inicio"
206 + settings: "Configuración"
207 + admin: "Administración"
208 + preferences: "Preferencias"
209 + profile: "Perfil"
210 + password: "Contraseña"
211 + sessions: "Sesiones"
212 +
213 +# Administración
214 +admin:
215 + dashboard:
216 + title: "Administración"
217 + heading: "Panel de Administración"
218 + users_card_title: "Usuarios"
219 + users_card_subtitle: "Usuarios totales"
220 + access_button: "Acceder"
221 + sessions_card_title: "Sesiones activas"
222 + sessions_card_subtitle: "Sesiones en curso"
223 + admins_card_title: "Administradores"
224 + admins_card_subtitle: "Cuentas admin"
225 + last_activity_card_title: "Última actividad"
226 + last_activity_card_subtitle: "Último inicio de sesión"
227 + audit_section_title: "Estadísticas de logs de auditoría"
228 + audit_total_logs: "Logs totales"
229 + audit_last_24h: "Últimas 24h"
230 + audit_by_level: "Por nivel"
231 + chart_label: "Logs por nivel"
232 + roles_card_title: "Gestión de Roles"
233 + roles_card_desc: "Gestionar roles y permisos granulares"
234 + roles_stat_title: "Roles"
235 + settings_card_title: "Configuración de la Aplicación"
236 + settings_card_desc: "Configurar los parámetros globales de la aplicación"
237 + apps_card_title: "Gestión de Aplicaciones"
238 + apps_card_desc: "Activar o desactivar aplicaciones modulares"
239 + orgs_card_title: "Gestión de Organizaciones"
240 + orgs_card_desc: "Crear y gestionar organizaciones y sus miembros"
241 + orgs_stat_title: "Organizaciones"
242 + orgs_members_stat_title: "Miembros totales"
243 + roles_list:
244 + page_title: "Gestión de roles"
245 + breadcrumb_roles: "Roles"
246 + title: "Roles"
247 + create_role: "Crear un rol"
248 + table_name: "Nombre"
249 + table_description: "Descripción"
250 + table_users: "Usuarios"
251 + table_permissions: "Permisos"
252 + table_actions: "Acciones"
253 + edit: "Editar"
254 + delete: "Eliminar"
255 + no_roles: "No se encontraron roles."
256 + protected_hint: "Protegido"
257 + delete_confirm_title: "Confirmar eliminación"
258 + delete_confirm_message: "¿Está seguro de que desea eliminar el rol"
259 + delete_button: "Eliminar"
260 + role_form:
261 + title_create: "Crear un rol"
262 + title_edit: "Editar rol"
263 + breadcrumb_create: "Crear"
264 + breadcrumb_edit: "Editar"
265 + back_to_list: "Volver a la lista"
266 + name_label: "Nombre del rol"
267 + name_placeholder: "ej: moderator"
268 + description_label: "Descripción"
269 + description_placeholder: "Descripción del rol..."
270 + permissions_label: "Permisos"
271 + protected_name_hint: "El nombre de este rol no se puede cambiar."
272 + cancel: "Cancelar"
273 + submit_create: "Crear rol"
274 + submit_edit: "Guardar cambios"
275 + errors:
276 + title: "Códigos de Error"
277 + description: "Documentación de los códigos de error de la aplicación. Cada código de error está asociado a un middleware y proporciona información de contexto para la depuración."
278 + breadcrumb_errors: "Códigos de Error"
279 + table_code: "Código"
280 + table_description: "Descripción"
281 + table_category: "Categoría"
282 + table_middleware: "Middleware"
283 + table_context: "Contexto"
284 + table_example: "Ejemplo"
285 + legend_title: "Leyenda"
286 + legend_code: "Identificador único del error (formato: CATEGORY_NUMBER)"
287 + legend_description: "Descripción del error"
288 + legend_category: "Tipo de error (Autenticación, CSRF, Rate Limiting, etc.)"
289 + legend_middleware: "Middleware responsable de generar este error"
290 + legend_context: "Explicación detallada de cuándo ocurre este error"
291 + legend_example: "Ejemplo concreto de una situación que genera este error"
292 + error_auth001_desc: "Credenciales inválidas"
293 + error_auth001_category: "Autenticación"
294 + error_auth001_context: "El usuario proporcionó credenciales incorrectas al iniciar sesión"
295 + error_auth001_example: "Intento de inicio de sesión con contraseña incorrecta"
296 + error_auth002_desc: "Token inválido"
297 + error_auth002_category: "Autenticación"
298 + error_auth002_context: "El token JWT proporcionado es inválido o está malformado"
299 + error_auth002_example: "Token JWT corrupto o firmado con una clave diferente"
300 + error_auth003_desc: "Token expirado"
301 + error_auth003_category: "Autenticación"
302 + error_auth003_context: "El token JWT ha expirado y debe renovarse"
303 + error_auth003_example: "Intento de acceso con un token expirado después del período de validez"
304 + error_csrf001_desc: "Token CSRF faltante"
305 + error_csrf001_category: "Protección CSRF"
306 + error_csrf001_context: "La solicitud POST/PUT/DELETE no contiene un token CSRF"
307 + error_csrf001_example: "Formulario enviado sin token CSRF en el header o body"
308 + error_csrf002_desc: "Token CSRF inválido"
309 + error_csrf002_category: "Protección CSRF"
310 + error_csrf002_context: "El token CSRF proporcionado no coincide con el token de sesión"
311 + error_csrf002_example: "Token CSRF modificado o robado"
312 + error_rate001_desc: "Límite de tasa excedido"
313 + error_rate001_category: "Rate Limiting"
314 + error_rate001_context: "Se ha excedido el número de solicitudes por minuto"
315 + error_rate001_example: "Más de 100 solicitudes/min en endpoints admin"
316 + error_valid001_desc: "Datos de entrada inválidos"
317 + error_valid001_category: "Validación"
318 + error_valid001_context: "Los datos proporcionados no cumplen con las reglas de validación"
319 + error_valid001_example: "Formato de email inválido o campo requerido faltante"
320 + error_valid002_desc: "Complejidad de contraseña no cumplida"
321 + error_valid002_category: "Validación"
322 + error_valid002_context: "La contraseña no cumple con las reglas de complejidad configuradas"
323 + error_valid002_example: "Contraseña demasiado corta o sin los caracteres requeridos"
324 + error_rbac001_desc: "Permisos insuficientes"
325 + error_rbac001_category: "Autorización"
326 + error_rbac001_context: "El usuario no tiene los permisos necesarios para esta acción"
327 + error_rbac001_example: "Usuario no administrador intentando acceder a /admin/users"
328 + error_rbac002_desc: "Rol de administrador requerido"
329 + error_rbac002_category: "Autorización"
330 + error_rbac002_context: "La acción requiere un rol de administrador"
331 + error_rbac002_example: "Intento de eliminar un usuario sin rol admin"
332 + error_db001_desc: "Error de operación de base de datos"
333 + error_db001_category: "Base de datos"
334 + error_db001_context: "Una operación de base de datos falló"
335 + error_db001_example: "Restricción de clave foránea violada o conexión DB perdida"
336 + error_db002_desc: "Recurso no encontrado"
337 + error_db002_category: "Base de datos"
338 + error_db002_context: "El recurso solicitado no existe en la base de datos"
339 + error_db002_example: "Usuario con ID inexistente"
340 + user_form:
341 + breadcrumb_create: "Crear"
342 + breadcrumb_edit: "Modificar"
343 + title_create: "Crear un usuario"
344 + title_edit: "Modificar un usuario"
345 + back_to_list: "Volver a la lista"
346 + username_label: "Nombre de usuario"
347 + username_placeholder: "nombre_usuario"
348 + username_readonly_hint: "El nombre de usuario no se puede cambiar después de la creación"
349 + email_label: "Email"
350 + email_placeholder: "usuario@example.com"
351 + password_label: "Contraseña"
352 + password_optional: "Opcional"
353 + password_placeholder: "Dejar vacío para generar una contraseña aleatoria"
354 + password_hint: "Si se deja vacío, se generará automáticamente una contraseña segura"
355 + roles_label: "Roles"
356 + roles_hint: "Seleccione al menos un rol"
357 + must_change_password_label: "Forzar cambio de contraseña"
358 + must_change_password_desc: "El usuario deberá cambiar su contraseña en el próximo inicio de sesión"
359 + cancel: "Cancelar"
360 + submit_create: "Crear usuario"
361 + submit_edit: "Guardar cambios"
362 + settings:
363 + title: "Configuración de la aplicación"
364 + page_title: "Configuración de la aplicación - Admin - RustCloud"
365 + subtitle: "Configuración global de RustCloud"
366 + general: "General"
367 + app_name: "Nombre de la aplicación"
368 + app_domain: "Dominio"
369 + app_domain_placeholder: "example.com"
370 + app_description: "Descripción"
371 + allow_registration: "Permitir registro"
372 + allow_registration_desc: "Los usuarios pueden crear cuentas desde la página de inicio"
373 + email_validation_required: "Validación de email requerida"
374 + email_validation_required_desc: "Los usuarios deben validar su email antes de poder iniciar sesión"
375 + save: "Guardar configuración"
376 + cancel: "Cancelar"
377 + saved: "Configuración guardada con éxito"
378 + error: "Error al guardar la configuración"
379 + network_error: "Error de red. Por favor intente nuevamente."
380 + password_security: "Seguridad de Contraseñas"
381 + password_min_length: "Longitud mínima"
382 + password_min_length_hint: "Entre 6 y 128 caracteres"
383 + password_complexity: "Requisitos de complejidad"
384 + require_uppercase: "Mayúscula requerida"
385 + require_lowercase: "Minúscula requerida"
386 + require_digits: "Dígito requerido"
387 + require_special: "Carácter especial requerido"
388 + password_change_require_email: "Confirmación por email del cambio de contraseña"
389 + password_change_require_email_desc: "Cuando está activado, se envía un correo de confirmación para validar cualquier cambio de contraseña. De lo contrario, el cambio se aplica inmediatamente."
390 + password_expiration: "Expiración de contraseñas"
391 + password_expiration_desc: "Forzar cambio periódico de contraseña"
392 + expiration_days: "Expiración después de (días)"
393 + expiration_alert: "Alerta por email antes de la expiración"
394 + expiration_alert_days: "Alerta (días antes)"
395 + audit_logging: "Registro de Auditoría"
396 + log_level: "Nivel de log"
397 + log_level_debug: "DEBUG - Todos los detalles"
398 + log_level_info: "INFO - Información normal"
399 + log_level_warn: "WARN - Solo advertencias"
400 + log_level_error: "ERROR - Solo errores"
401 + log_level_hint: "Controla la verbosidad de los logs de auditoría"
402 + log_retention: "Retención de logs (días)"
403 + log_retention_hint: "Período de retención de logs (1-365 días)"
404 + audit_info_message: "Los logs de auditoría registran todas las acciones administrativas (creación, modificación, eliminación de usuarios)."
405 + save_changes: "Guardar cambios"
406 + save_changes_desc: "La configuración se aplicará inmediatamente"
407 + saving: "Guardando..."
408 + save_success: "¡Configuración guardada con éxito!"
409 + save_error_prefix: "Error"
410 + users_list:
411 + page_title: "Gestión de Usuarios - Admin - RustCloud"
412 + title: "Gestión de Usuarios"
413 + breadcrumb_users: "Usuarios"
414 + create_user: "Crear usuario"
415 + search_label: "Buscar"
416 + search_placeholder: "Nombre de usuario o email..."
417 + role_filter_label: "Rol"
418 + all_roles: "Todos los roles"
419 + per_page_label: "Por página"
420 + search_button: "Buscar"
421 + results_summary: "Mostrando {count} usuario(s) de {total} total"
422 + page_info: "Página {page} de {total_pages}"
423 + table_username: "Nombre de usuario"
424 + table_email: "Email"
425 + table_roles: "Roles"
426 + table_created_at: "Creado el"
427 + table_last_login: "Último inicio de sesión"
428 + table_actions: "Acciones"
429 + never: "Nunca"
430 + edit: "Modificar"
431 + no_users: "No se encontraron usuarios"
432 + reset_password_title: "Restablecer contraseña"
433 + reset_generate: "Generar contraseña aleatoria"
434 + reset_custom: "Establecer contraseña personalizada"
435 + reset_new_password_label: "Nueva contraseña"
436 + reset_new_password_placeholder: "Mínimo 8 caracteres, mayúscula, minúscula, dígito, carácter especial"
437 + reset_password_requirements: "⚠️ Debe contener: 8+ caracteres, mayúscula, minúscula, dígito, carácter especial"
438 + reset_invalidate_sessions: "Invalidar todas las sesiones activas de este usuario"
439 + reset_invalidate_sessions_desc: "El usuario deberá iniciar sesión nuevamente después del cambio"
440 + reset_button: "Restablecer"
441 + delete_confirm_title: "⚠️ Confirmar eliminación"
442 + delete_confirm_message: "¿Está seguro de que desea eliminar este usuario?"
443 + delete_confirm_username: "Nombre de usuario:"
444 + delete_confirm_email: "Email:"
445 + delete_irreversible: "¡Esta acción es irreversible!"
446 + delete_button: "Eliminar permanentemente"
447 + delete: "Eliminar"
448 + delete_error: "Error al eliminar el usuario"
449 + network_error: "Error de red"
450 + edit_user_title: "Modificar usuario"
451 + resetting: "Restableciendo..."
452 + reset_by_email_title: "Restablecer por email"
453 + reset_email: "Restablecer por email"
454 + close: "Cerrar"
455 + password_reset_success_title: "Contraseña restablecida"
456 + password_warning_once: "¡Esta contraseña solo se mostrará una vez. Cópiela ahora!"
457 + new_password_label: "Nueva contraseña"
458 + copy: "Copiar"
459 + password_copied: "Contraseña copiada al portapapeles"
460 + password_set_success: "Contraseña restablecida con éxito"
461 + reset_error: "Error al restablecer la contraseña"
462 +
463 +# Cambio de contraseña
464 +password:
465 + title: "Cambiar mi contraseña"
466 + page_title: "Cambiar mi contraseña - RustCloud"
467 + subtitle: "Modifique su contraseña para asegurar su cuenta. Se enviará un correo electrónico de confirmación para validar el cambio."
468 + subtitle_no_email: "Modifique su contraseña para asegurar su cuenta."
469 + must_change_message: "⚠️ Debe cambiar su contraseña antes de continuar."
470 + new_password: "Nueva contraseña"
471 + current_password: "Contraseña actual"
472 + current_password_placeholder: "Su contraseña actual"
473 + new_password_placeholder: "Nueva contraseña segura"
474 + required: "Requerido"
475 + requirements: "📌 Requisitos: 8+ caracteres, mayúscula, minúscula, dígito, carácter especial"
476 + strength: "Fortaleza de la contraseña:"
477 + strength_weak: "Débil"
478 + strength_medium: "Medio"
479 + strength_strong: "Fuerte ✓"
480 + strength_too_short: "demasiado corta"
481 + strength_missing_lowercase: "falta minúscula"
482 + strength_missing_uppercase: "falta mayúscula"
483 + strength_missing_digit: "falta dígito"
484 + strength_missing_special: "falta carácter especial"
485 + confirm_password: "Confirmar nueva contraseña"
486 + confirm_password_placeholder: "Repita la nueva contraseña"
487 + passwords_match: "✓ Las contraseñas coinciden"
488 + passwords_not_match: "✗ Las contraseñas no coinciden"
489 + change_button: "Cambiar contraseña"
490 + changing: "Cambiando..."
491 + logout_warning_title: "Cerrar sesión de otras sesiones"
492 + logout_warning_desc: "Todas sus otras sesiones activas se cerrarán automáticamente después del cambio de contraseña. Solo su sesión actual permanecerá activa."
493 + save: "Guardar nueva contraseña"
494 + saving: "Guardando..."
495 + saved: "Se ha enviado un correo electrónico de confirmación a su dirección. Haga clic en el enlace del correo para validar el cambio de contraseña."
496 + saved_direct: "¡Contraseña cambiada con éxito! Sus otras sesiones han sido cerradas."
497 + error: "Error al cambiar la contraseña"
498 + error_all_fields_required: "Todos los campos son obligatorios."
499 + error_empty_data: "Los datos a enviar están vacíos."
500 + error_invalid_data: "Datos inválidos. Verifique que todos los campos estén completos."
501 + error_incorrect_current: "La contraseña actual es incorrecta."
502 + error_reading_response: "Error al leer la respuesta del servidor"
503 + error_415: "Error 415: El servidor no recibió los datos en el formato JSON esperado. Por favor intente nuevamente."
504 + network_error: "Error de red. Por favor intente nuevamente."
505 + security_tips_title: "Consejos de seguridad"
506 + security_tip_unique: "Use una contraseña única que no use en otro lugar"
507 + security_tip_share: "Nunca comparta su contraseña con nadie"
508 + security_tip_regular: "Cambie su contraseña regularmente (cada 3-6 meses)"
509 + security_tip_2fa: "Active la autenticación de dos factores si está disponible"
510 + security_tip_manager: "Use un administrador de contraseñas para mayor seguridad"
511 +
512 +# Sesiones
513 +sessions:
514 + title: "Sesiones activas"
515 + page_title: "Sesiones activas - RustCloud"
516 + subtitle: "Gestione los dispositivos conectados a su cuenta"
517 + current_session: "Sesión actual"
518 + current_badge: "Actual"
519 + unknown_device: "Dispositivo desconocido"
520 + ip_address: "Dirección IP:"
521 + created_at: "Creada el:"
522 + last_activity: "Última actividad:"
523 + session_id: "ID de sesión:"
524 + revoke: "Revocar"
525 + revoke_confirm: "¿Está seguro de que desea revocar la sesión para \"{device}\"?\n\nEste dispositivo será desconectado inmediatamente."
526 + revoke_success: "¡Sesión revocada con éxito!"
527 + revoke_error: "Error al revocar la sesión"
528 + no_sessions: "No hay sesiones activas"
529 + no_sessions_desc: "Actualmente no tiene sesiones activas"
530 + danger_zone: "Zona de peligro"
531 + danger_zone_desc: "Desconecte todos los demás dispositivos en una sola acción (su sesión actual permanecerá activa)."
532 + revoke_all: "Revocar todas las demás sesiones"
533 + revoke_all_confirm: "⚠️ ADVERTENCIA\n\n¿Está seguro de que desea revocar TODAS sus demás sesiones?\n\nTodos sus demás dispositivos serán desconectados. Solo su sesión actual permanecerá activa."
534 + revoke_all_success: "¡Todas las demás sesiones han sido revocadas!"
535 + revoke_all_error: "Error al revocar. Es posible que algunas sesiones no hayan sido revocadas."
536 + about_title: "Acerca de las sesiones"
537 + about_desc: "Se crea una sesión cada vez que inicia sesión desde un dispositivo o navegador diferente. Revocar una sesión desconecta inmediatamente ese dispositivo. Su sesión actual no puede ser revocada desde esta página (cierre sesión para hacerlo)."
538 + back_to_settings: "Volver a configuración"
539 +
540 +# Navigation menu (navbar)
541 +nav:
542 + home: "Inicio"
543 + settings: "Configuración"
544 + admin: "Administración"
545 +
546 +# User menu
547 +user_menu:
548 + profile: "Perfil"
549 + logout: "Cerrar sesión"
550 + settings: "Configuración"
551 +
552 +# Email verification messages
553 +email_verify:
554 + config_error: "Error de configuración del servidor. Por favor, contacte al administrador."
555 + success: "Su dirección de correo electrónico ha sido verificada con éxito. Ahora puede iniciar sesión en su cuenta."
556 + invalid_link: "El enlace de verificación es inválido o ha expirado."
557 +
558 +# Email subjects
559 +email_subject:
560 + password_reset: "Restablecimiento de contraseña"
561 + email_validation: "Verifique su dirección de correo electrónico"
562 + welcome: "¡Bienvenido a RustWarden!"
563 +
564 +# Rate limiting
565 +rate_limit:
566 + exceeded: "Demasiadas solicitudes. Por favor, inténtelo de nuevo más tarde."
567 + password_change: "Demasiadas solicitudes de cambio de contraseña. Por favor, espere antes de intentarlo de nuevo."
568 +
569 +# Sessions
570 +session:
571 + ip_unavailable: "No disponible"
572 +
A crates/rustwarden-ui/locales_old/fr.yml
+695 -0
@@ -0,0 +1,695 @@
1 +# Traductions françaises pour RustCloud
2 +# Format: clés hiérarchiques avec points
3 +
4 +# Connexion
5 +login:
6 + title: "Connexion"
7 + page_title: "Connexion - RustCloud"
8 + page_description: "Connexion à RustCloud"
9 + subtitle: "Connectez-vous à votre espace cloud"
10 + username: "Nom d'utilisateur"
11 + password: "Mot de passe"
12 + submit: "Se connecter"
13 + remember_me: "Se souvenir de moi"
14 + tab_login: "Connexion"
15 + tab_register: "Inscription"
16 + register_title: "Créer un compte"
17 + register_submit: "Créer mon compte"
18 + username_placeholder: "Votre nom d'utilisateur"
19 + password_placeholder: "Votre mot de passe"
20 + username_placeholder_register: "Votre nom d'utilisateur (min. 3 caractères)"
21 + password_placeholder_register: "Votre mot de passe (min. 8 caractères)"
22 + email_placeholder: "votre@email.com"
23 + forgot_password: "Mot de passe oublié ?"
24 + email_not_verified: "Email non vérifié ?"
25 + resend_verification: "Renvoyer l'email de validation"
26 + already_have_account: "Déjà un compte ?"
27 + sign_in_link: "Se connecter"
28 + success_message: "Connexion réussie ! Redirection..."
29 + error_message: "Erreur de connexion. Vérifiez vos identifiants."
30 + email_unverified_message: "Votre email n'est pas vérifié. Cliquez ci-dessous pour renvoyer le lien."
31 + resend_button: "Renvoyer l'email de validation"
32 + resend_success: "Email de validation renvoyé si le compte existe."
33 + resend_error: "Impossible d'envoyer la demande pour l'instant."
34 + resend_prompt: "Veuillez renseigner votre email avant de renvoyer."
35 + register_success: "Compte créé avec succès ! Veuillez vérifier votre email."
36 + register_error: "Erreur lors de l'inscription"
37 + email_label: "Email"
38 + footer_text: "Fork de Nextcloud en Rust"
39 +
40 +# Renvoyer l'email de validation
41 +resend_verification:
42 + page_title: "Renvoyer l'email de validation - RustWarden"
43 + title: "Renvoyer l'email de validation"
44 + subtitle: "Entrez votre adresse email pour recevoir un nouveau lien de validation."
45 + email_label: "Adresse email"
46 + email_placeholder: "vous@example.com"
47 + submit: "Envoyer le lien de validation"
48 + back_to_login: "Revenir à la connexion"
49 + error_default: "Impossible d'envoyer la demande."
50 + success_default: "Si le compte existe et n'est pas vérifié, un email a été envoyé."
51 + error_missing_email: "Veuillez renseigner votre email."
52 + error_cannot_send: "Impossible d'envoyer la demande pour l'instant."
53 +
54 +# Réinitialisation de mot de passe (via lien email)
55 +password_reset:
56 + page_title: "Réinitialiser le mot de passe"
57 + title: "Réinitialiser le mot de passe"
58 + subtitle: "Entrez votre nouveau mot de passe ci-dessous."
59 + new_password_label: "Nouveau mot de passe"
60 + new_password_placeholder: "Nouveau mot de passe sécurisé"
61 + requirements: "⚠️ Doit contenir : 8+ caractères, majuscule, minuscule, chiffre, caractère spécial"
62 + confirm_password_label: "Confirmer le mot de passe"
63 + confirm_password_placeholder: "Confirmez votre nouveau mot de passe"
64 + missing_token_url: "Token de réinitialisation manquant dans l'URL."
65 + missing_token: "Token de réinitialisation manquant."
66 + missing_new_password: "Veuillez entrer un nouveau mot de passe."
67 + passwords_not_match: "Les mots de passe ne correspondent pas."
68 + success_message: "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter."
69 + error_link_invalid: "Impossible de réinitialiser le mot de passe. Le lien peut être expiré ou invalide."
70 + error_generic: "Impossible de réinitialiser le mot de passe pour l'instant."
71 +
72 +# Messages d'erreur
73 +error:
74 + invalid_credentials: "Identifiants incorrects"
75 + email_invalid: "Email invalide"
76 + required_field: "Ce champ est requis"
77 + unauthorized: "Non autorisé"
78 + not_found: "Ressource non trouvée"
79 + validation: "Erreur de validation : {message}"
80 + database: "Erreur de base de données"
81 + internal: "Erreur interne du serveur"
82 + invalid_token: "Token invalide"
83 + token_expired: "Token expiré"
84 + token_reused: "Token réutilisé - veuillez vous ré-authentifier"
85 + must_change_password: "Changement de mot de passe requis"
86 + authentication_required: "Authentification requise"
87 + insufficient_permissions: "Permissions insuffisantes"
88 + config: "Erreur de configuration du serveur"
89 + template: "Erreur de rendu de template"
90 + validation_field:
91 + username_min_length: "Le nom d'utilisateur doit contenir au moins {min} caractères"
92 + password_min_length: "Le mot de passe doit contenir au moins {min} caractères"
93 + email_format: "L'adresse email n'est pas valide"
94 + password_require_uppercase: "Le mot de passe doit contenir au moins une majuscule"
95 + password_require_lowercase: "Le mot de passe doit contenir au moins une minuscule"
96 + password_require_digits: "Le mot de passe doit contenir au moins un chiffre"
97 + password_require_special: "Le mot de passe doit contenir au moins un caractère spécial (!@#$%^&*()_+-=[]{}|;:,.<>?)"
98 +
99 +# Paramètres
100 +settings:
101 + title: "Paramètres"
102 + page_title: "Paramètres - RustWarden"
103 + subtitle: "Gérez vos préférences et paramètres de compte"
104 + profile_title: "Mon Profil"
105 + profile_desc: "Modifiez vos informations personnelles (nom d'utilisateur, email)"
106 + password_title: "Mot de passe"
107 + password_desc: "Changez votre mot de passe pour sécuriser votre compte"
108 + sessions_title: "Sessions actives"
109 + sessions_desc: "Gérez vos sessions actives et révoquez l'accès depuis d'autres appareils"
110 + preferences_title: "Préférences"
111 + preferences_desc: "Personnalisez votre expérience (thème, langue)"
112 + access: "Accéder"
113 + info_title: "Informations"
114 + info_desc: "Utilisez les sections ci-dessus pour gérer différents aspects de votre compte. Toutes les modifications sont sécurisées et nécessitent votre authentification."
115 + language: "Langue"
116 + language_description: "Choisissez votre langue préférée"
117 + save: "Enregistrer"
118 + saved: "Paramètres enregistrés"
119 +
120 +# Page d'accueil
121 +home:
122 + welcome: "Bienvenue, {username} !"
123 + subtitle: "Bienvenue sur votre espace personnel"
124 + my_account: "Mon Compte"
125 + stat_username: "Nom d'utilisateur"
126 + stat_email: "Email"
127 + stat_member_since: "Membre depuis"
128 + stat_roles: "Rôles"
129 +
130 +# Messages généraux
131 +common:
132 + welcome: "Bienvenue {username}"
133 + loading: "Chargement..."
134 + save: "Enregistrer"
135 + cancel: "Annuler"
136 + delete: "Supprimer"
137 + edit: "Modifier"
138 + create: "Créer"
139 + load_more: "Charger plus"
140 +
141 +# Messages i18n
142 +i18n:
143 + language_updated: "Préférence de langue mise à jour. Veuillez recharger la page pour appliquer les changements."
144 + invalid_language: "Code de langue invalide. Langues supportées : fr, en, de, es"
145 +
146 +# Préférences
147 +preferences:
148 + title: "Préférences d'affichage"
149 + page_title: "Préférences d'affichage - RustCloud"
150 + subtitle: "Personnalisez l'apparence de l'interface"
151 + theme: "Thème"
152 + theme_light: "Clair"
153 + theme_light_desc: "Interface lumineuse et épurée"
154 + theme_dark: "Sombre"
155 + theme_dark_desc: "Réduit la fatigue oculaire"
156 + theme_auto: "Automatique"
157 + theme_auto_desc: "S'adapte à l'heure de la journée"
158 + theme_preview: "Le thème sera appliqué immédiatement après l'enregistrement"
159 + language: "Langue"
160 + language_fr: "Français"
161 + language_fr_desc: "Interface en français"
162 + language_en: "English"
163 + language_en_desc: "English interface"
164 + language_de: "Deutsch"
165 + language_de_desc: "Deutsche Benutzeroberfläche"
166 + language_es: "Español"
167 + language_es_desc: "Interfaz en español"
168 + translation_warning: "La traduction complète de l'interface est en cours de développement. Certains éléments peuvent rester en français."
169 + modified: "Préférences modifiées"
170 + modified_desc: "Enregistrez vos changements pour les appliquer"
171 + save: "Enregistrer les préférences"
172 + saving: "Enregistrement..."
173 + cancel: "Annuler"
174 + saved_success: "Préférences enregistrées avec succès !"
175 + saved_reload: "Préférences enregistrées avec succès !\n\nLa langue a été modifiée. Voulez-vous recharger la page maintenant pour appliquer les changements ?"
176 + error: "Erreur : {message}"
177 + network_error: "Erreur réseau. Veuillez réessayer."
178 + sync_title: "Vos préférences sont synchronisées"
179 + sync_desc: "Vos préférences d'affichage sont enregistrées et s'appliquent automatiquement sur tous vos appareils connectés à votre compte RustCloud."
180 + preview_dark: "Aperçu : Thème sombre sera appliqué"
181 + preview_light: "Aperçu : Thème clair sera appliqué"
182 + preview_auto: "Aperçu : Thème automatique sera appliqué"
183 +
184 +# Profil
185 +profile:
186 + title: "Mon Profil"
187 + page_title: "Mon Profil - RustCloud"
188 + subtitle: "Modifiez vos informations personnelles"
189 + account_info: "Informations du compte"
190 + username: "Nom d'utilisateur"
191 + username_public: "Visible publiquement"
192 + username_placeholder: "Votre nom d'utilisateur"
193 + username_pattern: "Lettres, chiffres, tirets et underscores uniquement"
194 + email: "Email"
195 + email_placeholder: "votre@email.com"
196 + save: "Enregistrer les modifications"
197 + saving: "Enregistrement..."
198 + saved: "Profil mis à jour avec succès"
199 + error: "Erreur lors de la mise à jour du profil"
200 + email_label: "Adresse email"
201 + email_used_for: "Utilisée pour la connexion"
202 + email_warning: "⚠️ Vous serez déconnecté après la modification"
203 + email_change_info: "Les modifications d'email nécessitent une reconnexion pour des raisons de sécurité."
204 + username_help: "3-50 caractères (lettres, chiffres, -, _)"
205 + additional_info: "Informations supplémentaires"
206 + account_created: "Compte créé le"
207 + roles: "Rôles"
208 +
209 +# Breadcrumb
210 +breadcrumb:
211 + home: "Accueil"
212 + settings: "Paramètres"
213 + admin: "Administration"
214 + preferences: "Préférences"
215 + profile: "Profil"
216 + password: "Mot de passe"
217 + users: "Utilisateurs"
218 + sessions: "Sessions"
219 +
220 +# Admin Settings
221 +admin:
222 + dashboard:
223 + title: "Administration"
224 + heading: "Tableau de bord Administrateur"
225 + users_card_title: "Utilisateurs"
226 + users_card_subtitle: "Total utilisateurs"
227 + access_button: "Accéder"
228 + sessions_card_title: "Sessions actives"
229 + sessions_card_subtitle: "Sessions en cours"
230 + admins_card_title: "Administrateurs"
231 + admins_card_subtitle: "Comptes admin"
232 + last_activity_card_title: "Dernière activité"
233 + last_activity_card_subtitle: "Dernière connexion"
234 + audit_section_title: "Statistiques des logs d'audit"
235 + audit_total_logs: "Total des logs"
236 + audit_last_24h: "Dernières 24h"
237 + audit_by_level: "Par niveau"
238 + chart_label: "Logs par niveau"
239 + roles_card_title: "Gestion des Rôles"
240 + roles_card_desc: "Gérer les rôles et les permissions granulaires"
241 + roles_stat_title: "Rôles"
242 + settings_card_title: "Paramètres Application"
243 + settings_card_desc: "Configurer les paramètres globaux de l'application"
244 + apps_card_title: "Gestion des Applications"
245 + apps_card_desc: "Activer ou désactiver les applications modulaires"
246 + orgs_card_title: "Gestion des Organisations"
247 + orgs_card_desc: "Créer et gérer les organisations et leurs membres"
248 + orgs_stat_title: "Organisations"
249 + orgs_members_stat_title: "Membres totaux"
250 + roles_list:
251 + page_title: "Gestion des rôles"
252 + breadcrumb_roles: "Rôles"
253 + title: "Rôles"
254 + create_role: "Créer un rôle"
255 + table_name: "Nom"
256 + table_description: "Description"
257 + table_users: "Utilisateurs"
258 + table_permissions: "Permissions"
259 + table_actions: "Actions"
260 + edit: "Modifier"
261 + delete: "Supprimer"
262 + no_roles: "Aucun rôle trouvé."
263 + protected_hint: "Protégé"
264 + delete_confirm_title: "Confirmer la suppression"
265 + delete_confirm_message: "Êtes-vous sûr de vouloir supprimer le rôle"
266 + delete_button: "Supprimer"
267 + role_form:
268 + title_create: "Créer un rôle"
269 + title_edit: "Modifier le rôle"
270 + breadcrumb_create: "Créer"
271 + breadcrumb_edit: "Modifier"
272 + back_to_list: "Retour à la liste"
273 + name_label: "Nom du rôle"
274 + name_placeholder: "ex: moderator"
275 + description_label: "Description"
276 + description_placeholder: "Description du rôle..."
277 + permissions_label: "Permissions"
278 + protected_name_hint: "Le nom de ce rôle ne peut pas être modifié."
279 + cancel: "Annuler"
280 + submit_create: "Créer le rôle"
281 + submit_edit: "Enregistrer les modifications"
282 + errors:
283 + title: "Codes d'Erreur"
284 + description: "Documentation des codes d'erreur de l'application. Chaque code d'erreur est associé à un middleware et fournit des informations de contexte pour le débogage."
285 + breadcrumb_errors: "Codes d'Erreur"
286 + table_code: "Code"
287 + table_description: "Description"
288 + table_category: "Catégorie"
289 + table_middleware: "Middleware"
290 + table_context: "Contexte"
291 + table_example: "Exemple"
292 + legend_title: "Légende"
293 + legend_code: "Identifiant unique de l'erreur (format: CATEGORY_NUMBER)"
294 + legend_description: "Description en français de l'erreur"
295 + legend_category: "Type d'erreur (Authentification, CSRF, Rate Limiting, etc.)"
296 + legend_middleware: "Middleware responsable de générer cette erreur"
297 + legend_context: "Explication détaillée de quand cette erreur se produit"
298 + legend_example: "Exemple concret de situation générant cette erreur"
299 + error_auth001_desc: "Identifiants invalides"
300 + error_auth001_category: "Authentification"
301 + error_auth001_context: "L'utilisateur a fourni des identifiants incorrects lors de la connexion"
302 + error_auth001_example: "Tentative de connexion avec un mot de passe incorrect"
303 + error_auth002_desc: "Token invalide"
304 + error_auth002_category: "Authentification"
305 + error_auth002_context: "Le token JWT fourni est invalide ou malformé"
306 + error_auth002_example: "Token JWT corrompu ou signé avec une clé différente"
307 + error_auth003_desc: "Token expiré"
308 + error_auth003_category: "Authentification"
309 + error_auth003_context: "Le token JWT a expiré et doit être renouvelé"
310 + error_auth003_example: "Tentative d'accès avec un token expiré après la durée de validité"
311 + error_csrf001_desc: "Token CSRF manquant"
312 + error_csrf001_category: "CSRF Protection"
313 + error_csrf001_context: "La requête POST/PUT/DELETE ne contient pas de token CSRF"
314 + error_csrf001_example: "Formulaire soumis sans token CSRF dans le header ou le body"
315 + error_csrf002_desc: "Token CSRF invalide"
316 + error_csrf002_category: "CSRF Protection"
317 + error_csrf002_context: "Le token CSRF fourni ne correspond pas au token de la session"
318 + error_csrf002_example: "Token CSRF modifié ou volé"
319 + error_rate001_desc: "Limite de taux dépassée"
320 + error_rate001_category: "Rate Limiting"
321 + error_rate001_context: "Le nombre de requêtes par minute a été dépassé"
322 + error_rate001_example: "Plus de 100 requêtes/min sur les endpoints admin"
323 + error_valid001_desc: "Données d'entrée invalides"
324 + error_valid001_category: "Validation"
325 + error_valid001_context: "Les données fournies ne respectent pas les règles de validation"
326 + error_valid001_example: "Email au format invalide ou champ requis manquant"
327 + error_valid002_desc: "Complexité du mot de passe non respectée"
328 + error_valid002_category: "Validation"
329 + error_valid002_context: "Le mot de passe ne respecte pas les règles de complexité configurées"
330 + error_valid002_example: "Mot de passe trop court ou ne contenant pas les caractères requis"
331 + error_rbac001_desc: "Permissions insuffisantes"
332 + error_rbac001_category: "Autorisation"
333 + error_rbac001_context: "L'utilisateur n'a pas les permissions nécessaires pour cette action"
334 + error_rbac001_example: "Utilisateur non-admin tentant d'accéder à /admin/users"
335 + error_rbac002_desc: "Rôle administrateur requis"
336 + error_rbac002_category: "Autorisation"
337 + error_rbac002_context: "L'action nécessite le rôle administrateur"
338 + error_rbac002_example: "Tentative de suppression d'utilisateur sans rôle admin"
339 + error_db001_desc: "Erreur d'opération de base de données"
340 + error_db001_category: "Base de données"
341 + error_db001_context: "Une opération de base de données a échoué"
342 + error_db001_example: "Contrainte de clé étrangère violée ou connexion DB perdue"
343 + error_db002_desc: "Ressource non trouvée"
344 + error_db002_category: "Base de données"
345 + error_db002_context: "La ressource demandée n'existe pas dans la base de données"
346 + error_db002_example: "Utilisateur avec ID inexistant"
347 + user_form:
348 + breadcrumb_create: "Créer"
349 + breadcrumb_edit: "Modifier"
350 + title_create: "Créer un utilisateur"
351 + title_edit: "Modifier un utilisateur"
352 + back_to_list: "Retour à la liste"
353 + username_label: "Nom d'utilisateur"
354 + username_placeholder: "nom_utilisateur"
355 + username_readonly_hint: "Le nom d'utilisateur ne peut pas être modifié après création"
356 + email_label: "Email"
357 + email_placeholder: "utilisateur@example.com"
358 + password_label: "Mot de passe"
359 + password_optional: "Optionnel"
360 + password_placeholder: "Laisser vide pour générer un mot de passe aléatoire"
361 + password_hint: "Si laissé vide, un mot de passe sécurisé sera généré automatiquement"
362 + roles_label: "Rôles"
363 + roles_hint: "Sélectionnez au moins un rôle"
364 + must_change_password_label: "Forcer le changement de mot de passe"
365 + must_change_password_desc: "L'utilisateur devra changer son mot de passe à la prochaine connexion"
366 + cancel: "Annuler"
367 + submit_create: "Créer l'utilisateur"
368 + submit_edit: "Enregistrer les modifications"
369 + settings:
370 + title: "Paramètres de l'Application"
371 + page_title: "Paramètres de l'Application - Admin - RustCloud"
372 + subtitle: "Configuration globale de RustCloud"
373 + general: "Général"
374 + app_name: "Nom de l'application"
375 + app_domain: "Domaine"
376 + app_domain_placeholder: "example.com"
377 + app_description: "Description"
378 + allow_registration: "Permettre l'enregistrement"
379 + allow_registration_desc: "Les utilisateurs peuvent créer des comptes depuis la page d'accueil"
380 + email_validation_required: "Validation email requise"
381 + email_validation_required_desc: "Les utilisateurs doivent valider leur email avant de pouvoir se connecter"
382 + save: "Enregistrer les paramètres"
383 + cancel: "Annuler"
384 + saved: "Paramètres enregistrés avec succès"
385 + error: "Erreur lors de l'enregistrement"
386 + network_error: "Erreur réseau. Veuillez réessayer."
387 + password_security: "Sécurité des Mots de Passe"
388 + password_min_length: "Longueur minimale"
389 + password_min_length_hint: "Entre 6 et 128 caractères"
390 + password_complexity: "Exigences de complexité"
391 + require_uppercase: "Majuscule requise"
392 + require_lowercase: "Minuscule requise"
393 + require_digits: "Chiffre requis"
394 + require_special: "Caractère spécial requis"
395 + password_change_require_email: "Confirmation par email du changement de mot de passe"
396 + password_change_require_email_desc: "Lorsqu'activé, un email de confirmation est envoyé pour valider tout changement de mot de passe. Sinon, le changement est appliqué immédiatement."
397 + password_expiration: "Expiration des mots de passe"
398 + password_expiration_desc: "Forcer le changement périodique"
399 + expiration_days: "Expiration après (jours)"
400 + expiration_alert: "Alerte email avant expiration"
401 + expiration_alert_days: "Alerte (jours avant)"
402 + audit_logging: "Journalisation des Audits"
403 + log_level: "Niveau de log"
404 + log_level_debug: "DEBUG - Tous les détails"
405 + log_level_info: "INFO - Informations normales"
406 + log_level_warn: "WARN - Avertissements uniquement"
407 + log_level_error: "ERROR - Erreurs uniquement"
408 + log_level_hint: "Contrôle la verbosité des logs d'audit"
409 + log_retention: "Rétention des logs (jours)"
410 + log_retention_hint: "Durée de conservation des logs (1-365 jours)"
411 + audit_info_message: "Les logs d'audit enregistrent toutes les actions administratives (création, modification, suppression d'utilisateurs)."
412 + save_changes: "Sauvegarder les modifications"
413 + save_changes_desc: "Les paramètres seront appliqués immédiatement"
414 + saving: "Enregistrement..."
415 + save_success: "Paramètres enregistrés avec succès !"
416 + save_error_prefix: "Erreur"
417 + users_list:
418 + page_title: "Gestion des Utilisateurs - Admin - RustCloud"
419 + title: "Gestion des Utilisateurs"
420 + breadcrumb_users: "Utilisateurs"
421 + create_user: "Créer un utilisateur"
422 + search_label: "Rechercher"
423 + search_placeholder: "Nom d'utilisateur ou email..."
424 + role_filter_label: "Rôle"
425 + all_roles: "Tous les rôles"
426 + per_page_label: "Par page"
427 + search_button: "Rechercher"
428 + results_summary: "Affichage de {count} utilisateur(s) sur {total} total"
429 + page_info: "Page {page} sur {total_pages}"
430 + table_username: "Nom d'utilisateur"
431 + table_email: "Email"
432 + table_roles: "Rôles"
433 + table_created_at: "Créé le"
434 + table_last_login: "Dernière connexion"
435 + table_actions: "Actions"
436 + never: "Jamais"
437 + edit: "Modifier"
438 + delete: "Supprimer"
439 + reset_password: "Réinitialiser le mot de passe"
440 + no_users: "Aucun utilisateur trouvé"
441 + reset_password_title: "Réinitialiser le mot de passe"
442 + reset_generate: "Générer un mot de passe aléatoire"
443 + reset_custom: "Définir un mot de passe personnalisé"
444 + reset_email: "Réinitialiser par email"
445 + reset_new_password_label: "Nouveau mot de passe"
446 + reset_new_password_placeholder: "Minimum 8 caractères, majuscule, minuscule, chiffre, caractère spécial"
447 + reset_password_requirements: "⚠️ Doit contenir : 8+ caractères, majuscule, minuscule, chiffre, caractère spécial"
448 + reset_invalidate_sessions: "Invalider toutes les sessions actives de cet utilisateur"
449 + reset_invalidate_sessions_desc: "L'utilisateur devra se reconnecter après le changement"
450 + reset_button: "Réinitialiser"
451 + delete_confirm_title: "⚠️ Confirmer la suppression"
452 + delete_confirm_message: "Êtes-vous sûr de vouloir supprimer cet utilisateur ?"
453 + delete_confirm_username: "Nom d'utilisateur :"
454 + delete_confirm_email: "Email :"
455 + delete_irreversible: "Cette action est irréversible !"
456 + delete_button: "Supprimer définitivement"
457 + edit_user_title: "Modifier l'utilisateur"
458 + delete_error: "Erreur lors de la suppression de l'utilisateur"
459 + network_error: "Erreur réseau"
460 + resetting: "Réinitialisation..."
461 + reset_by_email_title: "Réinitialisation par email"
462 + close: "Fermer"
463 + password_reset_success_title: "Mot de passe réinitialisé"
464 + password_warning_once: "Ce mot de passe ne sera affiché qu'une seule fois. Copiez-le maintenant !"
465 + new_password_label: "Nouveau mot de passe"
466 + copy: "Copier"
467 + password_copied: "Mot de passe copié dans le presse-papiers"
468 + password_set_success: "Mot de passe réinitialisé avec succès"
469 + reset_error: "Erreur lors de la réinitialisation du mot de passe"
470 +
471 +# Password Change
472 +password:
473 + title: "Changer mon mot de passe"
474 + page_title: "Changer mon mot de passe - RustCloud"
475 + subtitle: "Modifiez votre mot de passe pour sécuriser votre compte. Un email de confirmation vous sera envoyé pour valider le changement."
476 + subtitle_no_email: "Modifiez votre mot de passe pour sécuriser votre compte."
477 + must_change_message: "⚠️ Vous devez changer votre mot de passe avant de continuer."
478 + new_password: "Nouveau mot de passe"
479 + current_password: "Mot de passe actuel"
480 + current_password_placeholder: "Votre mot de passe actuel"
481 + new_password_placeholder: "Nouveau mot de passe sécurisé"
482 + required: "Requis"
483 + requirements: "📌 Respectez les exigences de complexité indiquées ci-dessous."
484 + strength: "Force du mot de passe :"
485 + strength_weak: "Faible"
486 + strength_medium: "Moyen"
487 + strength_strong: "Fort ✓"
488 + strength_too_short: "trop court"
489 + strength_missing_lowercase: "minuscule manquante"
490 + strength_missing_uppercase: "majuscule manquante"
491 + strength_missing_digit: "chiffre manquant"
492 + strength_missing_special: "caractère spécial manquant"
493 + confirm_password: "Confirmer le nouveau mot de passe"
494 + confirm_password_placeholder: "Répétez le nouveau mot de passe"
495 + passwords_match: "✓ Les mots de passe correspondent"
496 + passwords_not_match: "✗ Les mots de passe ne correspondent pas"
497 + change_button: "Changer le mot de passe"
498 + changing: "Changement en cours..."
499 + logout_warning_title: "Déconnexion des autres sessions"
500 + logout_warning_desc: "Toutes vos autres sessions actives seront automatiquement déconnectées après le changement de mot de passe. Seule votre session actuelle restera active."
501 + save: "Enregistrer le nouveau mot de passe"
502 + saving: "Enregistrement..."
503 + saved: "Un email de confirmation a été envoyé à votre adresse. Cliquez sur le lien dans l'email pour valider le changement de mot de passe."
504 + saved_direct: "Mot de passe changé avec succès ! Vos autres sessions ont été déconnectées."
505 + error: "Erreur lors de la modification du mot de passe"
506 + error_all_fields_required: "Tous les champs sont requis."
507 + error_empty_data: "Les données à envoyer sont vides."
508 + error_invalid_data: "Données invalides. Vérifiez que tous les champs sont remplis."
509 + error_incorrect_current: "Mot de passe actuel incorrect."
510 + error_reading_response: "Erreur lors de la lecture de la réponse du serveur"
511 + error_415: "Erreur 415 : Le serveur n'a pas reçu les données au format JSON attendu. Veuillez réessayer."
512 + network_error: "Erreur réseau. Veuillez réessayer."
513 + security_tips_title: "Conseils de sécurité"
514 + security_tip_unique: "Utilisez un mot de passe unique que vous n'utilisez pas ailleurs"
515 + security_tip_share: "Ne partagez jamais votre mot de passe avec qui que ce soit"
516 + security_tip_regular: "Changez votre mot de passe régulièrement (tous les 3-6 mois)"
517 + security_tip_2fa: "Activez l'authentification à deux facteurs si disponible"
518 + security_tip_manager: "Utilisez un gestionnaire de mots de passe pour plus de sécurité"
519 + pending_request_message: "⚠️ Une demande de changement de mot de passe est en attente de confirmation par email."
520 + pending_request_info: "Un email de confirmation a été envoyé à votre adresse. Cliquez sur le lien dans l'email pour confirmer le changement."
521 + pending_request_cancel: "Annuler la demande en attente"
522 + pending_request_canceled: "Demande annulée avec succès"
523 + pending_request_cancel_error: "Erreur lors de l'annulation de la demande"
524 + confirmation_sent: "Email de confirmation envoyé ! Vérifiez votre boîte de réception."
525 + confirmation_error: "Erreur lors de l'envoi de l'email de confirmation"
526 +
527 +# Password Change Confirmation Email
528 +password_change_confirmation:
529 + subject: "Confirmation de changement de mot de passe"
530 + greeting: "Bonjour {username}"
531 + message: "Vous avez demandé à changer votre mot de passe pour votre compte {app_name}. Pour confirmer ce changement, cliquez sur le lien ci-dessous."
532 + button_text: "Confirmer le changement de mot de passe"
533 + expires_message: "Ce lien est valide pendant {hours} heures et est à usage unique."
534 + footer: "Cet email a été envoyé automatiquement, merci de ne pas y répondre."
535 +
536 +# Sessions
537 +sessions:
538 + title: "Sessions actives"
539 + page_title: "Sessions actives - RustCloud"
540 + subtitle: "Gérez les appareils connectés à votre compte"
541 + current_session: "Session actuelle"
542 + current_badge: "Actuelle"
543 + unknown_device: "Appareil inconnu"
544 + ip_address: "Adresse IP :"
545 + created_at: "Créée le :"
546 + last_activity: "Dernière activité :"
547 + session_id: "ID Session :"
548 + revoke: "Révoquer"
549 + revoke_confirm: "Êtes-vous sûr de vouloir révoquer la session pour \"{device}\" ?\n\nCet appareil sera déconnecté immédiatement."
550 + revoke_success: "Session révoquée avec succès !"
551 + revoke_error: "Erreur lors de la révocation de la session"
552 + no_sessions: "Aucune session active"
553 + no_sessions_desc: "Vous n'avez actuellement aucune session active"
554 + danger_zone: "Zone de danger"
555 + danger_zone_desc: "Déconnectez tous les autres appareils en une seule action (votre session actuelle restera active)."
556 + revoke_all: "Révoquer toutes les autres sessions"
557 + revoke_all_confirm: "⚠️ ATTENTION\n\nÊtes-vous sûr de vouloir révoquer TOUTES vos autres sessions ?\n\nTous vos autres appareils seront déconnectés. Seule votre session actuelle restera active."
558 + revoke_all_success: "Toutes les autres sessions ont été révoquées !"
559 + revoke_all_error: "Erreur lors de la révocation. Certaines sessions n'ont peut-être pas été révoquées."
560 + about_title: "À propos des sessions"
561 + about_desc: "Une session est créée à chaque connexion depuis un appareil ou navigateur différent. Révoquer une session déconnecte immédiatement cet appareil. Votre session actuelle ne peut pas être révoquée depuis cette page (déconnectez-vous pour cela)."
562 + back_to_settings: "Retour aux paramètres"
563 +
564 +# Navigation menu (navbar)
565 +nav:
566 + home: "Accueil"
567 + settings: "Paramètres"
568 + admin: "Administration"
569 +
570 +# User menu
571 +user_menu:
572 + profile: "Profil"
573 + logout: "Déconnexion"
574 + settings: "Paramètres"
575 +
576 +# Organisations
577 +organisations:
578 + list:
579 + title: "Gestion des Organisations"
580 + create: "Créer une organisation"
581 + search: "Rechercher"
582 + search_placeholder: "Nom ou description..."
583 + no_organisations: "Aucune organisation trouvée"
584 + results_summary: "Résultats"
585 + manage_members: "Gérer les membres"
586 + table:
587 + name: "Nom"
588 + description: "Description"
589 + members: "Membres"
590 + created_at: "Créée le"
591 + actions: "Actions"
592 + create:
593 + title: "Créer une organisation"
594 + submit: "Créer"
595 + success: "Organisation créée avec succès"
596 + error:
597 + already_exists: "L'organisation '{gid}' existe déjà"
598 + invalid_gid: "Format de gid invalide: {gid}"
599 + generic: "Une erreur est survenue lors de la création de l'organisation"
600 + edit:
601 + title: "Modifier l'organisation"
602 + submit: "Enregistrer"
603 + update:
604 + success: "Organisation mise à jour avec succès"
605 + delete:
606 + success: "Organisation supprimée avec succès"
607 + confirm: "Êtes-vous sûr de vouloir supprimer cette organisation ? Cette action est irréversible."
608 + form:
609 + name_label: "Nom"
610 + name_placeholder: "Nom de l'organisation"
611 + description_label: "Description"
612 + description_placeholder: "Description de l'organisation (optionnel)"
613 + gid_label: "Identifiant (GID)"
614 + gid_placeholder: "identifiant-unique"
615 + gid_help: "Identifiant unique (lettres, chiffres, underscores uniquement, max 64 caractères)"
616 + members:
617 + title: "Membres de l'organisation"
618 + add_member: "Ajouter un membre"
619 + search_user_label: "Rechercher un utilisateur"
620 + search_user_placeholder: "Nom d'utilisateur ou email..."
621 + no_members: "Aucun membre dans cette organisation"
622 + back_to_list: "Retour à la liste"
623 + table:
624 + username: "Nom d'utilisateur"
625 + role: "Rôle"
626 + joined_at: "Rejoint le"
627 + actions: "Actions"
628 + add_success: "Membre ajouté avec succès"
629 + remove_success: "Membre retiré avec succès"
630 + add_error: "Erreur lors de l'ajout du membre"
631 + remove_error: "Erreur lors du retrait du membre"
632 + user_not_found: "Utilisateur '{search}' non trouvé"
633 + error:
634 + organisation_not_found: "Organisation '{gid}' non trouvée"
635 + already_member: "Cet utilisateur est déjà membre de l'organisation"
636 +
637 +# Sharing
638 +sharing:
639 + modal:
640 + title: "Partager une ressource"
641 + resource_label: "Ressource"
642 + resource_type_label: "Type"
643 + expiration_label: "Date d'expiration (optionnel)"
644 + expiration_help: "Le partage expirera automatiquement à cette date"
645 + cancel_button: "Annuler"
646 + submit_button: "Partager"
647 + close_button: "Fermer"
648 + success_message: "Partage créé avec succès"
649 + error_message: "Erreur lors de la création du partage"
650 + error:
651 + already_exists: "Ce partage existe déjà pour cette ressource et cette organisation"
652 + components:
653 + share_button_label: "Partager"
654 + permissions_label: "Permissions"
655 + permissions_help: "Choisissez les permissions accordées pour ce partage"
656 + share_with_label: "Partager avec"
657 + user_tab_label: "Utilisateur"
658 + organisation_tab_label: "Organisation"
659 + select_user_label: "Sélectionner un utilisateur"
660 + search_user_placeholder: "Rechercher un utilisateur..."
661 + select_organisation_label: "Sélectionner une organisation"
662 + select_organisation_placeholder: "Choisir une organisation..."
663 + no_organisations_message: "Aucune organisation disponible"
664 + permissions:
665 + read_only: "Lecture seule"
666 + read_only_value: "1"
667 + read_only_description: "Peut uniquement consulter la ressource"
668 + read_write: "Lecture + Écriture"
669 + read_write_value: "3"
670 + read_write_description: "Peut consulter et modifier la ressource"
671 + all: "Toutes les permissions"
672 + all_value: "15"
673 + all_description: "Peut consulter, modifier, supprimer et partager"
674 +
675 +# Email verification messages
676 +email_verify:
677 + config_error: "Erreur de configuration du serveur. Veuillez contacter l'administrateur."
678 + success: "Votre adresse email a été vérifiée avec succès. Vous pouvez maintenant vous connecter à votre compte."
679 + invalid_link: "Le lien de vérification est invalide ou a expiré."
680 +
681 +# Email subjects
682 +email_subject:
683 + password_reset: "Réinitialisation de votre mot de passe"
684 + email_validation: "Vérification de votre adresse email"
685 + welcome: "Bienvenue sur RustWarden !"
686 +
687 +# Rate limiting
688 +rate_limit:
689 + exceeded: "Trop de requêtes. Veuillez réessayer plus tard."
690 + password_change: "Trop de demandes de changement de mot de passe. Veuillez patienter avant de réessayer."
691 +
692 +# Sessions
693 +session:
694 + ip_unavailable: "Non disponible"
695 +
M crates/rustwarden-ui/src/handlers/admin_handler.rs
+231 -0
@@ -21,9 +21,49 @@ use rustwarden_core::impl_template_response;
21 21 use rustwarden_core::models::{role, user_role};
22 22 use rustwarden_core::services::app_settings_service::AppSettingsService;
23 23 use crate::template_helpers::build_base_context_with_claims;
24 +use crate::dto::NavigationDTO;
24 25 use axum::extract::Extension;
25 26 use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
26 27
28 +#[derive(Debug, Clone)]
29 +struct NavItem {
30 + id: String,
31 + label: String,
32 + icon: String,
33 + url: String,
34 + active: bool,
35 + enabled: bool,
36 + badge: String,
37 + has_badge: bool,
38 + disabled_message: String,
39 + has_disabled_message: bool,
40 +}
41 +
42 +async fn build_admin_nav_items(
43 + roles: &[String],
44 + i18n_service: &Arc<I18nService>,
45 + locale: &Locale,
46 + db: &DatabaseConnection,
47 +) -> Vec<NavItem> {
48 + let navigation = NavigationDTO::for_user(roles, i18n_service, locale, Some(db)).await;
49 + navigation
50 + .items
51 + .iter()
52 + .map(|item| NavItem {
53 + id: item.id.clone(),
54 + label: item.label.clone(),
55 + icon: item.icon.clone(),
56 + url: item.url.clone(),
57 + active: item.id == "admin",
58 + enabled: item.enabled,
59 + badge: item.badge.clone(),
60 + has_badge: item.has_badge,
61 + disabled_message: item.disabled_message.clone(),
62 + has_disabled_message: item.has_disabled_message,
63 + })
64 + .collect()
65 +}
66 +
27 67 /// Load app_domain and app_description from the database with sane defaults.
28 68 async fn load_app_domain_and_description(
29 69 db: &DatabaseConnection,
@@ -121,6 +161,8 @@ struct AdminDashboardTemplate {
121 161 initials: String,
122 162 avatar_color: String,
123 163 roles_text: String,
164 + nav_items: Vec<NavItem>,
165 + has_pending_password_change: bool,
124 166 // Dashboard statistics
125 167 total_users: u64,
126 168 active_sessions: u64,
@@ -209,6 +251,8 @@ pub async fn admin_dashboard(
209 251 let all_roles_list = RoleService::list_all_roles(&db).await?;
210 252 let total_roles = all_roles_list.len() as u64;
211 253
254 + let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
255 +
212 256 let template = AdminDashboardTemplate {
213 257 page_title: i18n_service.translate("admin.dashboard.title", locale_str).await,
214 258 csrf_token,
@@ -221,6 +265,8 @@ pub async fn admin_dashboard(
221 265 initials: base_context.initials,
222 266 avatar_color: base_context.avatar_color,
223 267 roles_text: base_context.roles.join(", "),
268 + nav_items,
269 + has_pending_password_change: false,
224 270 total_users,
225 271 active_sessions,
226 272 total_admins,
@@ -319,6 +365,8 @@ struct UserListTemplate {
319 365 initials: String,
320 366 avatar_color: String,
321 367 roles_text: String,
368 + nav_items: Vec<NavItem>,
369 + has_pending_password_change: bool,
322 370 users: Vec<UserInfoDisplay>,
323 371 total: u64,
324 372 page: u64,
@@ -411,6 +459,8 @@ struct UserFormTemplate {
411 459 initials: String,
412 460 avatar_color: String,
413 461 roles_text: String,
462 + nav_items: Vec<NavItem>,
463 + has_pending_password_change: bool,
414 464 mode: String,
415 465 user_id: String,
416 466 form_username: String,
@@ -564,6 +614,8 @@ pub async fn list_users(
564 614 String::new()
565 615 };
566 616
617 + let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
618 +
567 619 let template = UserListTemplate {
568 620 page_title: i18n_service.translate("admin.users_list.page_title", locale_str).await,
569 621 csrf_token,
@@ -577,6 +629,8 @@ pub async fn list_users(
577 629 initials: base_context.initials,
578 630 avatar_color: base_context.avatar_color,
579 631 roles_text: base_context.roles.join(", "),
632 + nav_items,
633 + has_pending_password_change: false,
580 634 users: users_display,
581 635 total,
582 636 page,
@@ -677,6 +731,8 @@ pub async fn create_user_form(
677 731 })
678 732 .collect();
679 733
734 + let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
735 +
680 736 let template = UserFormTemplate {
681 737 page_title: i18n_service.translate("admin.users_list.create_user", locale_str).await,
682 738 csrf_token,
@@ -689,6 +745,8 @@ pub async fn create_user_form(
689 745 initials: base_context.initials,
690 746 avatar_color: base_context.avatar_color,
691 747 roles_text: base_context.roles.join(", "),
748 + nav_items,
749 + has_pending_password_change: false,
692 750 mode: "create".to_string(),
693 751 user_id: String::new(),
694 752 form_username: String::new(),
@@ -832,6 +890,8 @@ pub async fn edit_user_form(
832 890 .translate("admin.users_list.edit_user_title", locale_str)
833 891 .await;
834 892
893 + let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
894 +
835 895 let template = UserFormTemplate {
836 896 page_title: format!("{} {}", edit_title, user.username),
837 897 csrf_token,
@@ -844,6 +904,8 @@ pub async fn edit_user_form(
844 904 initials: base_context.initials,
845 905 avatar_color: base_context.avatar_color,
846 906 roles_text: base_context.roles.join(", "),
907 + nav_items,
908 + has_pending_password_change: false,
847 909 mode: "edit".to_string(),
848 910 user_id: user_id.to_string(),
849 911 form_username: user.username,
@@ -1091,6 +1153,8 @@ struct AdminSettingsTemplate {
1091 1153 initials: String,
1092 1154 avatar_color: String,
1093 1155 roles_text: String,
1156 + nav_items: Vec<NavItem>,
1157 + has_pending_password_change: bool,
1094 1158 settings: AppSettingsTemplateContext,
1095 1159 user_menu_profile: String,
1096 1160 user_menu_logout: String,
@@ -1148,6 +1212,34 @@ struct AdminSettingsTemplate {
1148 1212 saving: String,
1149 1213 save_success: String,
1150 1214 save_error_prefix: String,
1215 + // OAuth / SSO section
1216 + oauth_sso_title: String,
1217 + oauth_enabled_label: String,
1218 + oauth_enabled_desc: String,
1219 + oauth_redirect_url_label: String,
1220 + oauth_redirect_url_desc: String,
1221 + oauth_auto_register_label: String,
1222 + oauth_auto_register_desc: String,
1223 + oauth_link_existing_label: String,
1224 + oauth_link_existing_desc: String,
1225 + oauth_client_id: String,
1226 + oauth_client_secret: String,
1227 + oauth_tenant: String,
1228 + oauth_redirect_uri: String,
1229 + oauth_copy_uri: String,
1230 + oauth_secret_configured: String,
1231 + oauth_secret_not_configured: String,
1232 + oauth_secret_change: String,
1233 + oauth_restart_warning: String,
1234 + oauth_env_info: String,
1235 + oauth_google_label: String,
1236 + oauth_google_desc: String,
1237 + oauth_github_label: String,
1238 + oauth_github_desc: String,
1239 + oauth_discord_label: String,
1240 + oauth_discord_desc: String,
1241 + oauth_microsoft_label: String,
1242 + oauth_microsoft_desc: String,
1151 1243 }
1152 1244
1153 1245 impl_template_response!(AdminSettingsTemplate);
@@ -1172,6 +1264,24 @@ struct AppSettingsTemplateContext {
1172 1264 password_expiration_alert_days_before: String,
1173 1265 audit_log_level: String,
1174 1266 audit_log_retention_days: String,
1267 + // OAuth
1268 + oauth_enabled: String,
1269 + oauth_redirect_base_url: String,
1270 + oauth_auto_register: String,
1271 + oauth_link_existing_account: String,
1272 + oauth_google_enabled: String,
1273 + oauth_google_client_id: String,
1274 + oauth_google_has_secret: bool,
1275 + oauth_github_enabled: String,
1276 + oauth_github_client_id: String,
1277 + oauth_github_has_secret: bool,
1278 + oauth_discord_enabled: String,
1279 + oauth_discord_client_id: String,
1280 + oauth_discord_has_secret: bool,
1281 + oauth_microsoft_enabled: String,
1282 + oauth_microsoft_client_id: String,
1283 + oauth_microsoft_has_secret: bool,
1284 + oauth_microsoft_tenant: String,
1175 1285 }
1176 1286
1177 1287 /// GET /admin/settings - Application settings page
@@ -1217,8 +1327,28 @@ pub async fn get_app_settings(
1217 1327 password_expiration_alert_days_before: get_val("password_expiration_alert_days_before", "7"),
1218 1328 audit_log_level: get_val("audit_log_level", "INFO"),
1219 1329 audit_log_retention_days: get_val("audit_log_retention_days", "365"),
1330 + // OAuth
1331 + oauth_enabled: get_val("oauth_enabled", "false"),
1332 + oauth_redirect_base_url: get_val("oauth_redirect_base_url", ""),
1333 + oauth_auto_register: get_val("oauth_auto_register", "true"),
1334 + oauth_link_existing_account: get_val("oauth_link_existing_account", "true"),
1335 + oauth_google_enabled: get_val("oauth_google_enabled", "false"),
1336 + oauth_google_client_id: get_val("oauth_google_client_id", ""),
1337 + oauth_google_has_secret: !get_val("oauth_google_client_secret", "").is_empty(),
1338 + oauth_github_enabled: get_val("oauth_github_enabled", "false"),
1339 + oauth_github_client_id: get_val("oauth_github_client_id", ""),
1340 + oauth_github_has_secret: !get_val("oauth_github_client_secret", "").is_empty(),
1341 + oauth_discord_enabled: get_val("oauth_discord_enabled", "false"),
1342 + oauth_discord_client_id: get_val("oauth_discord_client_id", ""),
1343 + oauth_discord_has_secret: !get_val("oauth_discord_client_secret", "").is_empty(),
1344 + oauth_microsoft_enabled: get_val("oauth_microsoft_enabled", "false"),
1345 + oauth_microsoft_client_id: get_val("oauth_microsoft_client_id", ""),
1346 + oauth_microsoft_has_secret: !get_val("oauth_microsoft_client_secret", "").is_empty(),
1347 + oauth_microsoft_tenant: get_val("oauth_microsoft_tenant", ""),
1220 1348 };
1221 1349
1350 + let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
1351 +
1222 1352 let template = AdminSettingsTemplate {
1223 1353 page_title: i18n_service.translate("admin.settings.title", locale_str).await,
1224 1354 csrf_token,
@@ -1231,6 +1361,8 @@ pub async fn get_app_settings(
1231 1361 initials: base_context.initials,
1232 1362 avatar_color: base_context.avatar_color,
1233 1363 roles_text: base_context.roles.join(", "),
1364 + nav_items,
1365 + has_pending_password_change: false,
1234 1366 settings: settings_ctx,
1235 1367 title: i18n_service.translate("admin.settings.title", locale_str).await,
1236 1368 subtitle: i18n_service.translate("admin.settings.subtitle", locale_str).await,
@@ -1284,6 +1416,34 @@ pub async fn get_app_settings(
1284 1416 saving: i18n_service.translate("admin.settings.saving", locale_str).await,
1285 1417 save_success: i18n_service.translate("admin.settings.save_success", locale_str).await,
1286 1418 save_error_prefix: i18n_service.translate("admin.settings.save_error_prefix", locale_str).await,
1419 + // OAuth / SSO
1420 + oauth_sso_title: i18n_service.translate("admin.settings.oauth_sso_title", locale_str).await,
1421 + oauth_enabled_label: i18n_service.translate("admin.settings.oauth_enabled_label", locale_str).await,
1422 + oauth_enabled_desc: i18n_service.translate("admin.settings.oauth_enabled_desc", locale_str).await,
1423 + oauth_redirect_url_label: i18n_service.translate("admin.settings.oauth_redirect_url_label", locale_str).await,
1424 + oauth_redirect_url_desc: i18n_service.translate("admin.settings.oauth_redirect_url_desc", locale_str).await,
1425 + oauth_auto_register_label: i18n_service.translate("admin.settings.oauth_auto_register_label", locale_str).await,
1426 + oauth_auto_register_desc: i18n_service.translate("admin.settings.oauth_auto_register_desc", locale_str).await,
1427 + oauth_link_existing_label: i18n_service.translate("admin.settings.oauth_link_existing_label", locale_str).await,
1428 + oauth_link_existing_desc: i18n_service.translate("admin.settings.oauth_link_existing_desc", locale_str).await,
1429 + oauth_client_id: i18n_service.translate("admin.settings.oauth_client_id", locale_str).await,
1430 + oauth_client_secret: i18n_service.translate("admin.settings.oauth_client_secret", locale_str).await,
1431 + oauth_tenant: i18n_service.translate("admin.settings.oauth_tenant", locale_str).await,
1432 + oauth_redirect_uri: i18n_service.translate("admin.settings.oauth_redirect_uri", locale_str).await,
1433 + oauth_copy_uri: i18n_service.translate("admin.settings.oauth_copy_uri", locale_str).await,
1434 + oauth_secret_configured: i18n_service.translate("admin.settings.oauth_secret_configured", locale_str).await,
1435 + oauth_secret_not_configured: i18n_service.translate("admin.settings.oauth_secret_not_configured", locale_str).await,
1436 + oauth_secret_change: i18n_service.translate("admin.settings.oauth_secret_change", locale_str).await,
1437 + oauth_restart_warning: i18n_service.translate("admin.settings.oauth_restart_warning", locale_str).await,
1438 + oauth_env_info: i18n_service.translate("admin.settings.oauth_env_info", locale_str).await,
1439 + oauth_google_label: i18n_service.translate("admin.settings.oauth_google_label", locale_str).await,
1440 + oauth_google_desc: i18n_service.translate("admin.settings.oauth_google_desc", locale_str).await,
1441 + oauth_github_label: i18n_service.translate("admin.settings.oauth_github_label", locale_str).await,
1442 + oauth_github_desc: i18n_service.translate("admin.settings.oauth_github_desc", locale_str).await,
1443 + oauth_discord_label: i18n_service.translate("admin.settings.oauth_discord_label", locale_str).await,
1444 + oauth_discord_desc: i18n_service.translate("admin.settings.oauth_discord_desc", locale_str).await,
1445 + oauth_microsoft_label: i18n_service.translate("admin.settings.oauth_microsoft_label", locale_str).await,
1446 + oauth_microsoft_desc: i18n_service.translate("admin.settings.oauth_microsoft_desc", locale_str).await,
1287 1447 };
1288 1448
1289 1449 Ok(template)
@@ -1310,6 +1470,24 @@ pub struct AdminSettingsFormData {
1310 1470 pub password_expiration_alert_days: Option<String>,
1311 1471 pub audit_log_level: Option<String>,
1312 1472 pub audit_log_retention_days: Option<String>,
1473 + // OAuth
1474 + pub oauth_enabled: Option<String>,
1475 + pub oauth_redirect_base_url: Option<String>,
1476 + pub oauth_auto_register: Option<String>,
1477 + pub oauth_link_existing_account: Option<String>,
1478 + pub oauth_google_enabled: Option<String>,
1479 + pub oauth_google_client_id: Option<String>,
1480 + pub oauth_google_client_secret: Option<String>,
1481 + pub oauth_github_enabled: Option<String>,
1482 + pub oauth_github_client_id: Option<String>,
1483 + pub oauth_github_client_secret: Option<String>,
1484 + pub oauth_discord_enabled: Option<String>,
1485 + pub oauth_discord_client_id: Option<String>,
1486 + pub oauth_discord_client_secret: Option<String>,
1487 + pub oauth_microsoft_enabled: Option<String>,
1488 + pub oauth_microsoft_client_id: Option<String>,
1489 + pub oauth_microsoft_client_secret: Option<String>,
1490 + pub oauth_microsoft_tenant: Option<String>,
1313 1491 }
1314 1492
1315 1493 /// PUT /admin/settings - Update app settings
@@ -1345,6 +1523,20 @@ pub async fn update_app_settings(
1345 1523 ("password_expiration_alert_days_before", form.password_expiration_alert_days.unwrap_or_else(|| "7".to_string())),
1346 1524 ("audit_log_level", form.audit_log_level.unwrap_or_else(|| "INFO".to_string())),
1347 1525 ("audit_log_retention_days", form.audit_log_retention_days.unwrap_or_else(|| "365".to_string())),
1526 + // OAuth toggles & non-secret fields
1527 + ("oauth_enabled", checkbox_val(&form.oauth_enabled)),
1528 + ("oauth_redirect_base_url", form.oauth_redirect_base_url.unwrap_or_default()),
1529 + ("oauth_auto_register", checkbox_val(&form.oauth_auto_register)),
1530 + ("oauth_link_existing_account", checkbox_val(&form.oauth_link_existing_account)),
1531 + ("oauth_google_enabled", checkbox_val(&form.oauth_google_enabled)),
1532 + ("oauth_google_client_id", form.oauth_google_client_id.unwrap_or_default()),
1533 + ("oauth_github_enabled", checkbox_val(&form.oauth_github_enabled)),
1534 + ("oauth_github_client_id", form.oauth_github_client_id.unwrap_or_default()),
1535 + ("oauth_discord_enabled", checkbox_val(&form.oauth_discord_enabled)),
1536 + ("oauth_discord_client_id", form.oauth_discord_client_id.unwrap_or_default()),
1537 + ("oauth_microsoft_enabled", checkbox_val(&form.oauth_microsoft_enabled)),
1538 + ("oauth_microsoft_client_id", form.oauth_microsoft_client_id.unwrap_or_default()),
1539 + ("oauth_microsoft_tenant", form.oauth_microsoft_tenant.unwrap_or_default()),
1348 1540 ];
1349 1541
1350 1542 let mut updated_count = 0u32;
@@ -1353,6 +1545,25 @@ pub async fn update_app_settings(
1353 1545 updated_count += 1;
1354 1546 }
1355 1547
1548 + // OAuth secrets: only update if a non-empty value was submitted (otherwise keep existing)
1549 + use rustwarden_core::services::crypto_service::CryptoService;
1550 + let secret_fields = [
1551 + ("oauth_google_client_secret", &form.oauth_google_client_secret),
1552 + ("oauth_github_client_secret", &form.oauth_github_client_secret),
1553 + ("oauth_discord_client_secret", &form.oauth_discord_client_secret),
1554 + ("oauth_microsoft_client_secret", &form.oauth_microsoft_client_secret),
1555 + ];
1556 + for (key, value) in &secret_fields {
1557 + if let Some(v) = value {
1558 + let trimmed = v.trim();
1559 + if !trimmed.is_empty() {
1560 + let encrypted = CryptoService::encrypt(trimmed)?;
1561 + AppSettingsService::set_setting(&db, key.to_string(), encrypted, user_id).await?;
1562 + updated_count += 1;
1563 + }
1564 + }
1565 + }
1566 +
1356 1567 tracing::info!(
1357 1568 "Admin {} updated {} app settings",
1358 1569 claims.username,
@@ -1412,6 +1623,8 @@ struct RolesListTemplate {
1412 1623 initials: String,
1413 1624 avatar_color: String,
1414 1625 roles_text: String,
1626 + nav_items: Vec<NavItem>,
1627 + has_pending_password_change: bool,
1415 1628 roles: Vec<RoleInfoDisplay>,
1416 1629 user_menu_profile: String,
1417 1630 user_menu_logout: String,
@@ -1454,6 +1667,8 @@ struct RoleFormTemplate {
1454 1667 initials: String,
1455 1668 avatar_color: String,
1456 1669 roles_text: String,
1670 + nav_items: Vec<NavItem>,
1671 + has_pending_password_change: bool,
1457 1672 mode: String,
1458 1673 role_id: String,
1459 1674 form_name: String,
@@ -1514,6 +1729,8 @@ pub async fn list_roles(
1514 1729 });
1515 1730 }
1516 1731
1732 + let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
1733 +
1517 1734 let template = RolesListTemplate {
1518 1735 page_title: i18n_service.translate("admin.roles_list.page_title", locale_str).await,
1519 1736 csrf_token,
@@ -1526,6 +1743,8 @@ pub async fn list_roles(
1526 1743 initials: base_context.initials,
1527 1744 avatar_color: base_context.avatar_color,
1528 1745 roles_text: base_context.roles.join(", "),
1746 + nav_items,
1747 + has_pending_password_change: false,
1529 1748 roles: roles_display,
1530 1749 user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
1531 1750 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
@@ -1597,6 +1816,7 @@ pub async fn create_role_form(
1597 1816 let (app_domain, app_description) = load_app_domain_and_description(&db).await?;
1598 1817
1599 1818 let permission_groups = build_permission_groups(&db, &[]).await?;
1819 + let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
1600 1820
1601 1821 let template = RoleFormTemplate {
1602 1822 page_title: i18n_service.translate("admin.role_form.title_create", locale_str).await,
@@ -1610,6 +1830,8 @@ pub async fn create_role_form(
1610 1830 initials: base_context.initials,
1611 1831 avatar_color: base_context.avatar_color,
1612 1832 roles_text: base_context.roles.join(", "),
1833 + nav_items,
1834 + has_pending_password_change: false,
1613 1835 mode: "create".to_string(),
1614 1836 role_id: String::new(),
1615 1837 form_name: String::new(),
@@ -1721,6 +1943,7 @@ pub async fn edit_role_form(
1721 1943 let selected_ids: Vec<uuid::Uuid> = role_perms.iter().map(|p| p.id).collect();
1722 1944 let permission_groups = build_permission_groups(&db, &selected_ids).await?;
1723 1945 let is_protected = role.name == "admin" || role.name == "user";
1946 + let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
1724 1947
1725 1948 let template = RoleFormTemplate {
1726 1949 page_title: format!(
@@ -1738,6 +1961,8 @@ pub async fn edit_role_form(
1738 1961 initials: base_context.initials,
1739 1962 avatar_color: base_context.avatar_color,
1740 1963 roles_text: base_context.roles.join(", "),
1964 + nav_items,
1965 + has_pending_password_change: false,
1741 1966 mode: "edit".to_string(),
1742 1967 role_id: role_id.to_string(),
1743 1968 form_name: role.name,
@@ -1848,6 +2073,8 @@ struct AdminErrorsTemplate {
1848 2073 initials: String,
1849 2074 avatar_color: String,
1850 2075 roles_text: String,
2076 + nav_items: Vec<NavItem>,
2077 + has_pending_password_change: bool,
1851 2078 error_codes: Vec<ErrorCodeInfo>,
1852 2079 // User menu
1853 2080 user_menu_profile: String,
@@ -1927,6 +2154,8 @@ pub async fn admin_errors(
1927 2154 });
1928 2155 }
1929 2156
2157 + let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
2158 +
1930 2159 let template = AdminErrorsTemplate {
1931 2160 page_title: i18n_service.translate("admin.errors.title", locale_str).await,
1932 2161 csrf_token,
@@ -1939,6 +2168,8 @@ pub async fn admin_errors(
1939 2168 initials: base_context.initials,
1940 2169 avatar_color: base_context.avatar_color,
1941 2170 roles_text: base_context.roles.join(", "),
2171 + nav_items,
2172 + has_pending_password_change: false,
1942 2173 error_codes,
1943 2174 user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await,
1944 2175 user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
M crates/rustwarden-ui/src/handlers/settings_handler.rs
+2 -0
@@ -570,6 +570,7 @@ pub async fn update_preferences(
570 570 mod filters {
571 571 use askama::Result;
572 572
573 + #[askama::filter_fn]
573 574 #[allow(dead_code)]
574 575 pub fn default<T: std::fmt::Display>(
575 576 value: T,
@@ -584,6 +585,7 @@ mod filters {
584 585 }
585 586 }
586 587
588 + #[askama::filter_fn]
587 589 pub fn length<I: IntoIterator>(value: I, _values: &dyn askama::Values) -> Result<usize> {
588 590 Ok(value.into_iter().count())
589 591 }
M crates/rustwarden-ui/src/handlers/user_handler.rs
+15 -5
@@ -52,12 +52,17 @@ pub struct UserListQuery {
52 52 pub per_page: Option<u64>,
53 53 }
54 54
55 -/// Handler for POST /api/v1/users - Create a new user (PROTECTED)
55 +/// Handler for POST /api/v1/users - Create a new user (ADMIN ONLY)
56 +/// SEC-H3: Requires admin role
56 57 pub async fn create_user(
57 58 _auth_user: AuthUser,
58 59 State(db): State<DatabaseConnection>,
59 60 Json(payload): Json<CreateUserRequest>,
60 61 ) -> Result<(StatusCode, Json<UserResponse>), AppError> {
62 + if !_auth_user.has_role("admin") {
63 + return Err(AppError::Forbidden);
64 + }
65 +
61 66 payload.validate().map_err(|e| {
62 67 tracing::warn!("Validation failed for create_user: {}", e);
63 68 AppError::Validation(format!("Validation error: {}", e))
@@ -90,10 +95,10 @@ pub async fn create_user(
90 95 }
91 96 None => {
92 97 let default_pwd = PasswordService::generate_default_password();
98 + // SEC-H2: Never log passwords -- only log that a default was generated
93 99 tracing::info!(
94 - "Generated default password for user '{}': {}",
95 - payload.username,
96 - default_pwd
100 + "Generated default password for user '{}' (must change on first login)",
101 + payload.username
97 102 );
98 103 let hash = PasswordService::hash_password(&default_pwd)?;
99 104 (hash, true)
@@ -226,12 +231,17 @@ pub async fn create_user(
226 231 Ok((StatusCode::CREATED, Json(response)))
227 232 }
228 233
229 -/// Handler for GET /api/v1/users - List all users (PROTECTED)
234 +/// Handler for GET /api/v1/users - List all users (ADMIN ONLY)
235 +/// SEC-H3: Requires admin role
230 236 pub async fn list_users(
231 237 _auth_user: AuthUser,
232 238 State(db): State<DatabaseConnection>,
233 239 Query(query): Query<UserListQuery>,
234 240 ) -> Result<Json<UserListResponse>, AppError> {
241 + if !_auth_user.has_role("admin") {
242 + return Err(AppError::Forbidden);
243 + }
244 +
235 245 let users = if let Some(search_term) = query.search {
236 246 use rustwarden_core::models::user;
237 247 use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
M crates/rustwarden-ui/templates/admin/dashboard.html
+4 -0
@@ -2,6 +2,10 @@
2 2
3 3 {% block title %}{{ page_title }}{% endblock %}
4 4
5 +{% block navbar %}
6 +{% include "components/navbar.html" %}
7 +{% endblock %}
8 +
5 9 {% block content %}
6 10 <div class="container mx-auto px-4 py-4">
7 11 <!-- Breadcrumb -->
M crates/rustwarden-ui/templates/admin/errors.html
+4 -0
@@ -2,6 +2,10 @@
2 2
3 3 {% block title %}{{ page_title }}{% endblock %}
4 4
5 +{% block navbar %}
6 +{% include "components/navbar.html" %}
7 +{% endblock %}
8 +
5 9 {% block content %}
6 10 <div class="container mx-auto px-4 py-4">
7 11 <!-- Breadcrumb -->
M crates/rustwarden-ui/templates/admin/role_form.html
+4 -0
@@ -2,6 +2,10 @@
2 2
3 3 {% block title %}{{ page_title }}{% endblock %}
4 4
5 +{% block navbar %}
6 +{% include "components/navbar.html" %}
7 +{% endblock %}
8 +
5 9 {% block content %}
6 10 <div class="container mx-auto px-4 py-8">
7 11 <!-- Breadcrumb -->
M crates/rustwarden-ui/templates/admin/roles_list.html
+4 -0
@@ -2,6 +2,10 @@
2 2
3 3 {% block title %}{{ page_title }}{% endblock %}
4 4
5 +{% block navbar %}
6 +{% include "components/navbar.html" %}
7 +{% endblock %}
8 +
5 9 {% block content %}
6 10 <div class="container mx-auto px-4 py-8">
7 11 <!-- Breadcrumb -->
M crates/rustwarden-ui/templates/admin/settings.html
+300 -0
@@ -2,6 +2,10 @@
2 2
3 3 {% block title %}{{ page_title }}{% endblock %}
4 4
5 +{% block navbar %}
6 +{% include "components/navbar.html" %}
7 +{% endblock %}
8 +
5 9 {% block content %}
6 10 <div class="container mx-auto px-4 py-8">
7 11 <!-- Breadcrumb -->
@@ -324,6 +328,271 @@
324 328 </div>
325 329 </div>
326 330
331 + <!-- OAuth / SSO Card -->
332 + <div class="card bg-base-100 shadow-xl">
333 + <div class="card-body">
334 + <h2 class="card-title text-xl mb-4">
335 + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
336 + {{ oauth_sso_title }}
337 + </h2>
338 +
339 + <div class="space-y-4">
340 + <!-- Global OAuth Toggle -->
341 + <div class="form-control">
342 + <label class="label cursor-pointer justify-start gap-4">
343 + <input type="checkbox" name="oauth_enabled" value="true" class="toggle toggle-primary" id="oauthEnabled"
344 + {% if settings.oauth_enabled == "true" %}checked{% endif %}>
345 + <div>
346 + <span class="label-text font-semibold">{{ oauth_enabled_label }}</span>
347 + <span class="label-text-alt block">{{ oauth_enabled_desc }}</span>
348 + </div>
349 + </label>
350 + </div>
351 +
352 + <!-- OAuth global options (shown when OAuth is enabled) -->
353 + <div id="oauthGlobalOptions" class="space-y-4">
354 + <!-- Base redirect URL -->
355 + <div class="form-control">
356 + <label class="label"><span class="label-text font-semibold">{{ oauth_redirect_url_label }}</span></label>
357 + <input type="text" name="oauth_redirect_base_url" value="{{ settings.oauth_redirect_base_url }}"
358 + placeholder="https://myapp.example.com" class="input input-bordered w-full">
359 + <label class="label"><span class="label-text-alt">{{ oauth_redirect_url_desc }}</span></label>
360 + </div>
361 +
362 + <!-- Auto-register & Link existing toggles -->
363 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
364 + <div class="form-control">
365 + <label class="label cursor-pointer justify-start gap-4">
366 + <input type="checkbox" name="oauth_auto_register" value="true" class="toggle toggle-secondary"
367 + {% if settings.oauth_auto_register == "true" %}checked{% endif %}>
368 + <div>
369 + <span class="label-text font-semibold">{{ oauth_auto_register_label }}</span>
370 + <span class="label-text-alt block">{{ oauth_auto_register_desc }}</span>
371 + </div>
372 + </label>
373 + </div>
374 + <div class="form-control">
375 + <label class="label cursor-pointer justify-start gap-4">
376 + <input type="checkbox" name="oauth_link_existing_account" value="true" class="toggle toggle-secondary"
377 + {% if settings.oauth_link_existing_account == "true" %}checked{% endif %}>
378 + <div>
379 + <span class="label-text font-semibold">{{ oauth_link_existing_label }}</span>
380 + <span class="label-text-alt block">{{ oauth_link_existing_desc }}</span>
381 + </div>
382 + </label>
383 + </div>
384 + </div>
385 +
386 + <div class="divider my-2"></div>
387 +
388 + <!-- ===== Google ===== -->
389 + <div class="collapse collapse-arrow bg-base-200 rounded-box" id="collapseGoogle">
390 + <input type="checkbox" class="peer oauth-provider-collapse" data-provider="google"
391 + {% if settings.oauth_google_enabled == "true" %}checked{% endif %} />
392 + <div class="collapse-title flex items-center gap-3">
393 + <svg class="h-5 w-5" viewBox="0 0 24 24"><path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
394 + <span class="font-semibold">{{ oauth_google_label }}</span>
395 + <span class="badge badge-sm badge-outline ml-1">{{ oauth_google_desc }}</span>
396 + <input type="checkbox" name="oauth_google_enabled" value="true" class="toggle toggle-primary toggle-sm ml-auto oauth-provider-toggle"
397 + data-provider="google"
398 + {% if settings.oauth_google_enabled == "true" %}checked{% endif %}
399 + onclick="event.stopPropagation()">
400 + </div>
401 + <div class="collapse-content space-y-3">
402 + <div class="form-control">
403 + <label class="label"><span class="label-text">{{ oauth_client_id }}</span></label>
404 + <input type="text" name="oauth_google_client_id" value="{{ settings.oauth_google_client_id }}"
405 + placeholder="xxxx.apps.googleusercontent.com" class="input input-bordered input-sm w-full">
406 + </div>
407 + <div class="form-control">
408 + <label class="label">
409 + <span class="label-text">{{ oauth_client_secret }}</span>
410 + {% if settings.oauth_google_has_secret %}
411 + <span class="badge badge-success badge-sm gap-1">
412 + <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
413 + {{ oauth_secret_configured }}
414 + </span>
415 + {% else %}
416 + <span class="badge badge-warning badge-sm">{{ oauth_secret_not_configured }}</span>
417 + {% endif %}
418 + </label>
419 + <input type="password" name="oauth_google_client_secret"
420 + placeholder="{% if settings.oauth_google_has_secret %}{{ oauth_secret_change }}{% else %}GOCSPX-...{% endif %}"
421 + class="input input-bordered input-sm w-full" autocomplete="off">
422 + </div>
423 + <div class="form-control">
424 + <label class="label"><span class="label-text">{{ oauth_redirect_uri }}</span></label>
425 + <div class="join w-full">
426 + <input type="text" readonly
427 + value="{{ settings.oauth_redirect_base_url }}/api/v1/auth/oauth/google/callback"
428 + class="input input-bordered input-sm join-item w-full bg-base-200 font-mono text-xs" id="googleRedirectUri">
429 + <button type="button" class="btn btn-sm join-item btn-outline copy-uri-btn" data-target="googleRedirectUri">{{ oauth_copy_uri }}</button>
430 + </div>
431 + </div>
432 + </div>
433 + </div>
434 +
435 + <!-- ===== GitHub ===== -->
436 + <div class="collapse collapse-arrow bg-base-200 rounded-box" id="collapseGithub">
437 + <input type="checkbox" class="peer oauth-provider-collapse" data-provider="github"
438 + {% if settings.oauth_github_enabled == "true" %}checked{% endif %} />
439 + <div class="collapse-title flex items-center gap-3">
440 + <svg class="h-5 w-5" viewBox="0 0 24 24"><path fill="currentColor" d="M12 .3a12 12 0 0 0-3.8 23.38c.6.12.83-.26.83-.57L9 21.07c-3.34.72-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.08-.74.09-.73.09-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.81 1.3 3.5 1 .1-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.14-.3-.54-1.52.1-3.18 0 0 1-.32 3.3 1.23a11.5 11.5 0 0 1 6.02 0c2.28-1.55 3.29-1.23 3.29-1.23.64 1.66.24 2.88.12 3.18a4.65 4.65 0 0 1 1.23 3.22c0 4.61-2.8 5.63-5.48 5.92.42.36.81 1.1.81 2.22l-.01 3.29c0 .31.2.69.82.57A12 12 0 0 0 12 .3z"/></svg>
441 + <span class="font-semibold">{{ oauth_github_label }}</span>
442 + <span class="badge badge-sm badge-outline ml-1">{{ oauth_github_desc }}</span>
443 + <input type="checkbox" name="oauth_github_enabled" value="true" class="toggle toggle-primary toggle-sm ml-auto oauth-provider-toggle"
444 + data-provider="github"
445 + {% if settings.oauth_github_enabled == "true" %}checked{% endif %}
446 + onclick="event.stopPropagation()">
447 + </div>
448 + <div class="collapse-content space-y-3">
449 + <div class="form-control">
450 + <label class="label"><span class="label-text">{{ oauth_client_id }}</span></label>
451 + <input type="text" name="oauth_github_client_id" value="{{ settings.oauth_github_client_id }}"
452 + placeholder="Iv1.xxxxxx" class="input input-bordered input-sm w-full">
453 + </div>
454 + <div class="form-control">
455 + <label class="label">
456 + <span class="label-text">{{ oauth_client_secret }}</span>
457 + {% if settings.oauth_github_has_secret %}
458 + <span class="badge badge-success badge-sm gap-1">
459 + <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
460 + {{ oauth_secret_configured }}
461 + </span>
462 + {% else %}
463 + <span class="badge badge-warning badge-sm">{{ oauth_secret_not_configured }}</span>
464 + {% endif %}
465 + </label>
466 + <input type="password" name="oauth_github_client_secret"
467 + placeholder="{% if settings.oauth_github_has_secret %}{{ oauth_secret_change }}{% else %}ghp_...{% endif %}"
468 + class="input input-bordered input-sm w-full" autocomplete="off">
469 + </div>
470 + <div class="form-control">
471 + <label class="label"><span class="label-text">{{ oauth_redirect_uri }}</span></label>
472 + <div class="join w-full">
473 + <input type="text" readonly
474 + value="{{ settings.oauth_redirect_base_url }}/api/v1/auth/oauth/github/callback"
475 + class="input input-bordered input-sm join-item w-full bg-base-200 font-mono text-xs" id="githubRedirectUri">
476 + <button type="button" class="btn btn-sm join-item btn-outline copy-uri-btn" data-target="githubRedirectUri">{{ oauth_copy_uri }}</button>
477 + </div>
478 + </div>
479 + </div>
480 + </div>
481 +
482 + <!-- ===== Discord ===== -->
483 + <div class="collapse collapse-arrow bg-base-200 rounded-box" id="collapseDiscord">
484 + <input type="checkbox" class="peer oauth-provider-collapse" data-provider="discord"
485 + {% if settings.oauth_discord_enabled == "true" %}checked{% endif %} />
486 + <div class="collapse-title flex items-center gap-3">
487 + <svg class="h-5 w-5" viewBox="0 0 24 24"><path fill="currentColor" d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
488 + <span class="font-semibold">{{ oauth_discord_label }}</span>
489 + <span class="badge badge-sm badge-outline ml-1">{{ oauth_discord_desc }}</span>
490 + <input type="checkbox" name="oauth_discord_enabled" value="true" class="toggle toggle-primary toggle-sm ml-auto oauth-provider-toggle"
491 + data-provider="discord"
492 + {% if settings.oauth_discord_enabled == "true" %}checked{% endif %}
493 + onclick="event.stopPropagation()">
494 + </div>
495 + <div class="collapse-content space-y-3">
496 + <div class="form-control">
497 + <label class="label"><span class="label-text">{{ oauth_client_id }}</span></label>
498 + <input type="text" name="oauth_discord_client_id" value="{{ settings.oauth_discord_client_id }}"
499 + placeholder="123456789012345678" class="input input-bordered input-sm w-full">
500 + </div>
501 + <div class="form-control">
502 + <label class="label">
503 + <span class="label-text">{{ oauth_client_secret }}</span>
504 + {% if settings.oauth_discord_has_secret %}
505 + <span class="badge badge-success badge-sm gap-1">
506 + <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
507 + {{ oauth_secret_configured }}
508 + </span>
509 + {% else %}
510 + <span class="badge badge-warning badge-sm">{{ oauth_secret_not_configured }}</span>
511 + {% endif %}
512 + </label>
513 + <input type="password" name="oauth_discord_client_secret"
514 + placeholder="{% if settings.oauth_discord_has_secret %}{{ oauth_secret_change }}{% endif %}"
515 + class="input input-bordered input-sm w-full" autocomplete="off">
516 + </div>
517 + <div class="form-control">
518 + <label class="label"><span class="label-text">{{ oauth_redirect_uri }}</span></label>
519 + <div class="join w-full">
520 + <input type="text" readonly
521 + value="{{ settings.oauth_redirect_base_url }}/api/v1/auth/oauth/discord/callback"
522 + class="input input-bordered input-sm join-item w-full bg-base-200 font-mono text-xs" id="discordRedirectUri">
523 + <button type="button" class="btn btn-sm join-item btn-outline copy-uri-btn" data-target="discordRedirectUri">{{ oauth_copy_uri }}</button>
524 + </div>
525 + </div>
526 + </div>
527 + </div>
528 +
529 + <!-- ===== Microsoft ===== -->
530 + <div class="collapse collapse-arrow bg-base-200 rounded-box" id="collapseMicrosoft">
531 + <input type="checkbox" class="peer oauth-provider-collapse" data-provider="microsoft"
532 + {% if settings.oauth_microsoft_enabled == "true" %}checked{% endif %} />
533 + <div class="collapse-title flex items-center gap-3">
534 + <svg class="h-5 w-5" viewBox="0 0 24 24"><path fill="#f25022" d="M1 1h10v10H1z"/><path fill="#00a4ef" d="M1 13h10v10H1z"/><path fill="#7fba00" d="M13 1h10v10H13z"/><path fill="#ffb900" d="M13 13h10v10H13z"/></svg>
535 + <span class="font-semibold">{{ oauth_microsoft_label }}</span>
536 + <span class="badge badge-sm badge-outline ml-1">{{ oauth_microsoft_desc }}</span>
537 + <input type="checkbox" name="oauth_microsoft_enabled" value="true" class="toggle toggle-primary toggle-sm ml-auto oauth-provider-toggle"
538 + data-provider="microsoft"
539 + {% if settings.oauth_microsoft_enabled == "true" %}checked{% endif %}
540 + onclick="event.stopPropagation()">
541 + </div>
542 + <div class="collapse-content space-y-3">
543 + <div class="form-control">
544 + <label class="label"><span class="label-text">{{ oauth_client_id }}</span></label>
545 + <input type="text" name="oauth_microsoft_client_id" value="{{ settings.oauth_microsoft_client_id }}"
546 + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" class="input input-bordered input-sm w-full">
547 + </div>
548 + <div class="form-control">
549 + <label class="label">
550 + <span class="label-text">{{ oauth_client_secret }}</span>
551 + {% if settings.oauth_microsoft_has_secret %}
552 + <span class="badge badge-success badge-sm gap-1">
553 + <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
554 + {{ oauth_secret_configured }}
555 + </span>
556 + {% else %}
557 + <span class="badge badge-warning badge-sm">{{ oauth_secret_not_configured }}</span>
558 + {% endif %}
559 + </label>
560 + <input type="password" name="oauth_microsoft_client_secret"
561 + placeholder="{% if settings.oauth_microsoft_has_secret %}{{ oauth_secret_change }}{% endif %}"
562 + class="input input-bordered input-sm w-full" autocomplete="off">
563 + </div>
564 + <div class="form-control">
565 + <label class="label"><span class="label-text">{{ oauth_tenant }}</span></label>
566 + <input type="text" name="oauth_microsoft_tenant" value="{{ settings.oauth_microsoft_tenant }}"
567 + placeholder="common" class="input input-bordered input-sm w-full">
568 + </div>
569 + <div class="form-control">
570 + <label class="label"><span class="label-text">{{ oauth_redirect_uri }}</span></label>
571 + <div class="join w-full">
572 + <input type="text" readonly
573 + value="{{ settings.oauth_redirect_base_url }}/api/v1/auth/oauth/microsoft/callback"
574 + class="input input-bordered input-sm join-item w-full bg-base-200 font-mono text-xs" id="microsoftRedirectUri">
575 + <button type="button" class="btn btn-sm join-item btn-outline copy-uri-btn" data-target="microsoftRedirectUri">{{ oauth_copy_uri }}</button>
576 + </div>
577 + </div>
578 + </div>
579 + </div>
580 + </div>
581 +
582 + <!-- Restart warning -->
583 + <div class="alert alert-warning mt-4">
584 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
585 + <span class="text-sm">{{ oauth_restart_warning }}</span>
586 + </div>
587 +
588 + <div class="alert alert-info mt-2">
589 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
590 + <span class="text-sm">{{ oauth_env_info }}</span>
591 + </div>
592 + </div>
593 + </div>
594 + </div>
595 +
327 596 <!-- Action Buttons -->
328 597 <div class="card bg-base-200 shadow-xl">
329 598 <div class="card-body">
@@ -362,6 +631,37 @@ if (expirationCheckbox) {
362 631 toggleExpirationSettings();
363 632 }
364 633
634 +const oauthCheckbox = document.getElementById('oauthEnabled');
635 +const oauthGlobalOptions = document.getElementById('oauthGlobalOptions');
636 +
637 +function toggleOauthSection() {
638 + if (oauthCheckbox && oauthGlobalOptions) {
639 + if (oauthCheckbox.checked) {
640 + oauthGlobalOptions.classList.remove('hidden', 'opacity-50');
641 + oauthGlobalOptions.style.pointerEvents = '';
642 + } else {
643 + oauthGlobalOptions.classList.add('hidden');
644 + }
645 + }
646 +}
647 +
648 +if (oauthCheckbox) {
649 + oauthCheckbox.addEventListener('change', toggleOauthSection);
650 + toggleOauthSection();
651 +}
652 +
653 +document.querySelectorAll('.copy-uri-btn').forEach(function(btn) {
654 + btn.addEventListener('click', function() {
655 + var target = document.getElementById(this.dataset.target);
656 + if (target) {
657 + navigator.clipboard.writeText(target.value).then(function() {
658 + btn.textContent = '✓';
659 + setTimeout(function() { btn.textContent = '{{ oauth_copy_uri }}'; }, 1500);
660 + });
661 + }
662 + });
663 +});
664 +
365 665 document.getElementById('settingsForm').addEventListener('submit', async function(e) {
366 666 e.preventDefault();
367 667
M crates/rustwarden-ui/templates/admin/user_form.html
+4 -0
@@ -2,6 +2,10 @@
2 2
3 3 {% block title %}{{ page_title }}{% endblock %}
4 4
5 +{% block navbar %}
6 +{% include "components/navbar.html" %}
7 +{% endblock %}
8 +
5 9 {% block content %}
6 10 <div class="container mx-auto px-4 py-8">
7 11 <!-- Breadcrumb -->
M crates/rustwarden-ui/templates/admin/users_list.html
+4 -0
@@ -2,6 +2,10 @@
2 2
3 3 {% block title %}{{ page_title }}{% endblock %}
4 4
5 +{% block navbar %}
6 +{% include "components/navbar.html" %}
7 +{% endblock %}
8 +
5 9 {% block content %}
6 10 <div class="container mx-auto px-4 py-8">
7 11 <!-- Breadcrumb -->
M docs/configuration-flows.md
+84 -2
@@ -17,7 +17,8 @@ Ce document décrit l'ensemble des variables d'environnement de RustWarden et le
17 17 9. [Nettoyage périodique (Background Tasks)](#9-nettoyage-périodique)
18 18 10. [Administration — Liste & Pagination](#10-administration--pagination)
19 19 11. [Préférences utilisateur (Thème & Langue)](#11-préférences-utilisateur)
20 -12. [Référence complète des variables](#12-référence-complète)
20 +12. [Authentification OAuth / SSO](#12-authentification-oauth--sso)
21 +13. [Référence complète des variables](#13-référence-complète)
21 22
22 23 ---
23 24
@@ -479,7 +480,88 @@ sequenceDiagram
479 480
480 481 ---
481 482
482 -## 12. Référence complète
483 +## 12. Authentification OAuth / SSO
484 +
485 +*(Feature flag `oauth` -- compile conditionnellement)*
486 +
487 +### Flux OAuth complet
488 +
489 +```mermaid
490 +sequenceDiagram
491 + participant U as Utilisateur
492 + participant L as Page Login
493 + participant R as oauth_redirect
494 + participant S as Session
495 + participant P as Provider (Google/GitHub/...)
496 + participant C as oauth_callback
497 + participant OS as OAuthService
498 + participant DB as Base de données
499 + participant JWT as JwtService
500 +
501 + U->>L: Clique "Se connecter avec Google"
502 + L->>R: GET /api/v1/auth/oauth/google/redirect
503 + R->>R: Lit OAUTH_GOOGLE_CLIENT_ID, OAUTH_GOOGLE_CLIENT_SECRET
504 + R->>R: Génère state CSRF + nonce OIDC + PKCE verifier
505 + R->>S: Stocke state, nonce, pkce_verifier, provider
506 + R->>U: 302 Redirect vers Google
507 +
508 + U->>P: Authentification chez le provider
509 + P->>C: GET /api/v1/auth/oauth/google/callback?code=XXX&state=YYY
510 + C->>C: check_auth_rate_limit (RATE_LIMIT_AUTH_PER_MINUTE)
511 + C->>S: Récupère state, nonce, pkce_verifier
512 + C->>C: Valide state (constant-time via subtle)
513 + C->>P: Échange code + PKCE verifier contre tokens
514 + P-->>C: ID Token + Access Token
515 + C->>C: Vérifie nonce dans l'ID Token
516 + C->>C: Extrait ProviderUserInfo (email, name, avatar)
517 +
518 + C->>OS: find_or_create_user(provider, user_info)
519 + OS->>DB: Cherche oauth_account par (provider, provider_user_id)
520 + alt Compte OAuth lié existant
521 + OS->>DB: Récupère l'utilisateur associé
522 + else Email vérifié + utilisateur existant
523 + OS->>DB: Lie le compte OAuth à l'utilisateur
524 + else Nouvel utilisateur
525 + OS->>DB: Crée user (sans password) + role "user"
526 + OS->>DB: Crée oauth_account lié
527 + end
528 + OS-->>C: (user, is_new_user)
529 +
530 + C->>JWT: Génère JWT (JWT_SECRET, JWT_EXPIRATION_MINUTES)
531 + C->>DB: Crée refresh_token (hash SHA-256)
532 + C->>S: session.cycle_id() (anti-fixation)
533 + C->>U: Set-Cookie: jwt_token (HttpOnly, Secure)
534 + C->>U: 302 Redirect vers /home
535 +```
536 +
537 +### Variables d'environnement OAuth
538 +
539 +| Variable | Défaut | Description |
540 +|----------|--------|-------------|
541 +| `OAUTH_ENCRYPTION_KEY` | — | Clé AES-256-GCM (hex 64 chars) pour chiffrer les secrets OAuth en DB. Générer avec `openssl rand -hex 32` |
542 +| `OAUTH_ENABLED` | `false` | Active globalement OAuth |
543 +| `OAUTH_REDIRECT_BASE_URL` | `http://localhost:3000` | Base URL pour les callbacks |
544 +| `OAUTH_GOOGLE_CLIENT_ID` | — | Google Client ID |
545 +| `OAUTH_GOOGLE_CLIENT_SECRET` | — | Google Client Secret |
546 +| `OAUTH_GOOGLE_ENABLED` | `false` | Active Google |
547 +| `OAUTH_GITHUB_CLIENT_ID` | — | GitHub Client ID |
548 +| `OAUTH_GITHUB_CLIENT_SECRET` | — | GitHub Client Secret |
549 +| `OAUTH_GITHUB_ENABLED` | `false` | Active GitHub |
550 +| `OAUTH_DISCORD_CLIENT_ID` | — | Discord Client ID |
551 +| `OAUTH_DISCORD_CLIENT_SECRET` | — | Discord Client Secret |
552 +| `OAUTH_DISCORD_ENABLED` | `false` | Active Discord |
553 +| `OAUTH_MICROSOFT_CLIENT_ID` | — | Microsoft Client ID |
554 +| `OAUTH_MICROSOFT_CLIENT_SECRET` | — | Microsoft Client Secret |
555 +| `OAUTH_MICROSOFT_ENABLED` | `false` | Active Microsoft |
556 +| `OAUTH_MICROSOFT_TENANT` | `common` | Azure AD tenant |
557 +
558 +> **Note** : Les variables d'environnement ci-dessus servent de fallback. La configuration
559 +> principale peut se faire via **Admin > Settings > OAuth / SSO** dans l'interface web.
560 +> Les secrets sont chiffrés (AES-256-GCM) en base de données.
561 +
562 +---
563 +
564 +## 13. Référence complète
483 565
484 566 ### Serveur & Base de données
485 567
M locales/de.yml
+42 -0
@@ -199,6 +199,13 @@ profile:
199 199 additional_info: "Zusätzliche Informationen"
200 200 account_created: "Konto erstellt am"
201 201 roles: "Rollen"
202 + linked_accounts_title: "Verknüpfte Konten"
203 + no_linked_accounts: "Keine externen Konten verknüpft."
204 + linked_since: "Verknüpft seit"
205 + unlink_button: "Trennen"
206 + unlink_confirm: "Möchten Sie dieses Konto wirklich trennen?"
207 + unlink_error: "Dieses Konto konnte nicht getrennt werden."
208 + cannot_unlink_last: "Ihre letzte Authentifizierungsmethode kann nicht entfernt werden."
202 209
203 210 # Breadcrumb
204 211 breadcrumb:
@@ -407,6 +414,34 @@ admin:
407 414 saving: "Speichern..."
408 415 save_success: "Einstellungen erfolgreich gespeichert!"
409 416 save_error_prefix: "Fehler"
417 + # OAuth / SSO admin settings
418 + oauth_sso_title: "OAuth / SSO"
419 + oauth_enabled_label: "OAuth aktivieren"
420 + oauth_enabled_desc: "Anmeldung über externe Anbieter erlauben (Google, GitHub, etc.)"
421 + oauth_redirect_url_label: "Basis-Weiterleitungs-URL"
422 + oauth_redirect_url_desc: "Basis-URL für OAuth-Callbacks (z.B. https://meineapp.example.com)"
423 + oauth_auto_register_label: "Automatische Registrierung"
424 + oauth_auto_register_desc: "Automatisch ein Konto erstellen, wenn die E-Mail nicht existiert"
425 + oauth_link_existing_label: "Bestehende Konten verknüpfen"
426 + oauth_link_existing_desc: "Automatisch ein OAuth-Konto verknüpfen, wenn die verifizierte E-Mail übereinstimmt"
427 + oauth_client_id: "Client-ID"
428 + oauth_client_secret: "Client-Secret"
429 + oauth_tenant: "Mandanten-ID"
430 + oauth_redirect_uri: "Weiterleitungs-URI"
431 + oauth_copy_uri: "Kopieren"
432 + oauth_secret_configured: "Konfiguriert"
433 + oauth_secret_not_configured: "Nicht konfiguriert"
434 + oauth_secret_change: "Ändern"
435 + oauth_restart_warning: "Änderungen an Anmeldedaten erfordern einen Neustart der Anwendung."
436 + oauth_env_info: "Anmeldedaten können auch über Umgebungsvariablen gesetzt werden (OAUTH_*_CLIENT_ID, etc.)."
437 + oauth_google_label: "Google"
438 + oauth_google_desc: "OpenID Connect"
439 + oauth_github_label: "GitHub"
440 + oauth_github_desc: "OAuth2"
441 + oauth_discord_label: "Discord"
442 + oauth_discord_desc: "OAuth2"
443 + oauth_microsoft_label: "Microsoft"
444 + oauth_microsoft_desc: "OpenID Connect"
410 445 users_list:
411 446 page_title: "Benutzerverwaltung - Admin - RustCloud"
412 447 title: "Benutzerverwaltung"
@@ -566,6 +601,13 @@ rate_limit:
566 601 exceeded: "Zu viele Anfragen. Bitte versuchen Sie es später erneut."
567 602 password_change: "Zu viele Passwortänderungsanfragen. Bitte warten Sie, bevor Sie es erneut versuchen."
568 603
604 +# OAuth
605 +oauth:
606 + divider: "oder"
607 + login_with: "Anmelden mit {provider}"
608 + error_denied: "Anmeldung vom Anbieter verweigert."
609 + error_generic: "OAuth-Anmeldefehler."
610 +
569 611 # Sessions
570 612 session:
571 613 ip_unavailable: "Nicht verfügbar"
M locales/en.yml
+42 -0
@@ -204,6 +204,13 @@ profile:
204 204 additional_info: "Additional Information"
205 205 account_created: "Account created on"
206 206 roles: "Roles"
207 + linked_accounts_title: "Linked Accounts"
208 + no_linked_accounts: "No external accounts linked."
209 + linked_since: "Linked since"
210 + unlink_button: "Unlink"
211 + unlink_confirm: "Are you sure you want to unlink this account?"
212 + unlink_error: "Unable to unlink this account."
213 + cannot_unlink_last: "Cannot remove your last authentication method."
207 214
208 215 # Breadcrumb
209 216 breadcrumb:
@@ -412,6 +419,34 @@ admin:
412 419 saving: "Saving..."
413 420 save_success: "Settings saved successfully!"
414 421 save_error_prefix: "Error"
422 + # OAuth / SSO admin settings
423 + oauth_sso_title: "OAuth / SSO"
424 + oauth_enabled_label: "Enable OAuth"
425 + oauth_enabled_desc: "Allow login via external providers (Google, GitHub, etc.)"
426 + oauth_redirect_url_label: "Base redirect URL"
427 + oauth_redirect_url_desc: "Base URL for OAuth callbacks (e.g. https://myapp.example.com)"
428 + oauth_auto_register_label: "Auto-registration"
429 + oauth_auto_register_desc: "Automatically create an account if the email does not exist"
430 + oauth_link_existing_label: "Link existing accounts"
431 + oauth_link_existing_desc: "Automatically link an OAuth account if the verified email matches"
432 + oauth_client_id: "Client ID"
433 + oauth_client_secret: "Client Secret"
434 + oauth_tenant: "Tenant ID"
435 + oauth_redirect_uri: "Redirect URI"
436 + oauth_copy_uri: "Copy"
437 + oauth_secret_configured: "Configured"
438 + oauth_secret_not_configured: "Not configured"
439 + oauth_secret_change: "Change"
440 + oauth_restart_warning: "Credential changes require an application restart."
441 + oauth_env_info: "Credentials can also be set via environment variables (OAUTH_*_CLIENT_ID, etc.)."
442 + oauth_google_label: "Google"
443 + oauth_google_desc: "OpenID Connect"
444 + oauth_github_label: "GitHub"
445 + oauth_github_desc: "OAuth2"
446 + oauth_discord_label: "Discord"
447 + oauth_discord_desc: "OAuth2"
448 + oauth_microsoft_label: "Microsoft"
449 + oauth_microsoft_desc: "OpenID Connect"
415 450 users_list:
416 451 page_title: "User Management - Admin - RustCloud"
417 452 title: "User Management"
@@ -587,6 +622,13 @@ rate_limit:
587 622 exceeded: "Too many requests. Please try again later."
588 623 password_change: "Too many password change requests. Please wait before trying again."
589 624
625 +# OAuth
626 +oauth:
627 + divider: "or"
628 + login_with: "Sign in with {provider}"
629 + error_denied: "Login denied by provider."
630 + error_generic: "OAuth login error."
631 +
590 632 # Sessions
591 633 session:
592 634 ip_unavailable: "Unavailable"
M locales/es.yml
+42 -0
@@ -199,6 +199,13 @@ profile:
199 199 additional_info: "Información adicional"
200 200 account_created: "Cuenta creada el"
201 201 roles: "Roles"
202 + linked_accounts_title: "Cuentas vinculadas"
203 + no_linked_accounts: "No hay cuentas externas vinculadas."
204 + linked_since: "Vinculada desde"
205 + unlink_button: "Desvincular"
206 + unlink_confirm: "¿Está seguro de que desea desvincular esta cuenta?"
207 + unlink_error: "No se pudo desvincular esta cuenta."
208 + cannot_unlink_last: "No se puede eliminar su último método de autenticación."
202 209
203 210 # Breadcrumb
204 211 breadcrumb:
@@ -407,6 +414,34 @@ admin:
407 414 saving: "Guardando..."
408 415 save_success: "¡Configuración guardada con éxito!"
409 416 save_error_prefix: "Error"
417 + # OAuth / SSO admin settings
418 + oauth_sso_title: "OAuth / SSO"
419 + oauth_enabled_label: "Activar OAuth"
420 + oauth_enabled_desc: "Permitir inicio de sesión a través de proveedores externos (Google, GitHub, etc.)"
421 + oauth_redirect_url_label: "URL base de redirección"
422 + oauth_redirect_url_desc: "URL base para callbacks OAuth (ej: https://miapp.example.com)"
423 + oauth_auto_register_label: "Auto-registro"
424 + oauth_auto_register_desc: "Crear automáticamente una cuenta si el correo no existe"
425 + oauth_link_existing_label: "Vincular cuentas existentes"
426 + oauth_link_existing_desc: "Vincular automáticamente una cuenta OAuth si el correo verificado coincide"
427 + oauth_client_id: "Client ID"
428 + oauth_client_secret: "Client Secret"
429 + oauth_tenant: "Tenant ID"
430 + oauth_redirect_uri: "URI de redirección"
431 + oauth_copy_uri: "Copiar"
432 + oauth_secret_configured: "Configurado"
433 + oauth_secret_not_configured: "No configurado"
434 + oauth_secret_change: "Cambiar"
435 + oauth_restart_warning: "Los cambios de credenciales requieren un reinicio de la aplicación."
436 + oauth_env_info: "Las credenciales también se pueden configurar mediante variables de entorno (OAUTH_*_CLIENT_ID, etc.)."
437 + oauth_google_label: "Google"
438 + oauth_google_desc: "OpenID Connect"
439 + oauth_github_label: "GitHub"
440 + oauth_github_desc: "OAuth2"
441 + oauth_discord_label: "Discord"
442 + oauth_discord_desc: "OAuth2"
443 + oauth_microsoft_label: "Microsoft"
444 + oauth_microsoft_desc: "OpenID Connect"
410 445 users_list:
411 446 page_title: "Gestión de Usuarios - Admin - RustCloud"
412 447 title: "Gestión de Usuarios"
@@ -566,6 +601,13 @@ rate_limit:
566 601 exceeded: "Demasiadas solicitudes. Por favor, inténtelo de nuevo más tarde."
567 602 password_change: "Demasiadas solicitudes de cambio de contraseña. Por favor, espere antes de intentarlo de nuevo."
568 603
604 +# OAuth
605 +oauth:
606 + divider: "o"
607 + login_with: "Iniciar sesión con {provider}"
608 + error_denied: "Inicio de sesión denegado por el proveedor."
609 + error_generic: "Error de inicio de sesión OAuth."
610 +
569 611 # Sessions
570 612 session:
571 613 ip_unavailable: "No disponible"
M locales/fr.yml
+42 -0
@@ -205,6 +205,13 @@ profile:
205 205 additional_info: "Informations supplémentaires"
206 206 account_created: "Compte créé le"
207 207 roles: "Rôles"
208 + linked_accounts_title: "Comptes liés"
209 + no_linked_accounts: "Aucun compte externe lié."
210 + linked_since: "Lié depuis"
211 + unlink_button: "Délier"
212 + unlink_confirm: "Êtes-vous sûr de vouloir délier ce compte ?"
213 + unlink_error: "Impossible de délier ce compte."
214 + cannot_unlink_last: "Impossible de supprimer votre dernier moyen d'authentification."
208 215
209 216 # Breadcrumb
210 217 breadcrumb:
@@ -414,6 +421,34 @@ admin:
414 421 saving: "Enregistrement..."
415 422 save_success: "Paramètres enregistrés avec succès !"
416 423 save_error_prefix: "Erreur"
424 + # OAuth / SSO admin settings
425 + oauth_sso_title: "OAuth / SSO"
426 + oauth_enabled_label: "Activer OAuth"
427 + oauth_enabled_desc: "Autorise la connexion via des fournisseurs externes (Google, GitHub, etc.)"
428 + oauth_redirect_url_label: "URL de redirection de base"
429 + oauth_redirect_url_desc: "URL de base pour les callbacks OAuth (ex: https://monapp.example.com)"
430 + oauth_auto_register_label: "Auto-inscription"
431 + oauth_auto_register_desc: "Créer automatiquement un compte si l'email n'existe pas"
432 + oauth_link_existing_label: "Lier les comptes existants"
433 + oauth_link_existing_desc: "Lier automatiquement un compte OAuth si l'email vérifié correspond"
434 + oauth_client_id: "Client ID"
435 + oauth_client_secret: "Client Secret"
436 + oauth_tenant: "Tenant ID"
437 + oauth_redirect_uri: "URI de redirection"
438 + oauth_copy_uri: "Copier"
439 + oauth_secret_configured: "Configuré"
440 + oauth_secret_not_configured: "Non configuré"
441 + oauth_secret_change: "Modifier"
442 + oauth_restart_warning: "Les modifications de credentials nécessitent un redémarrage de l'application."
443 + oauth_env_info: "Les credentials peuvent aussi être configurées via variables d'environnement (OAUTH_*_CLIENT_ID, etc.)."
444 + oauth_google_label: "Google"
445 + oauth_google_desc: "OpenID Connect"
446 + oauth_github_label: "GitHub"
447 + oauth_github_desc: "OAuth2"
448 + oauth_discord_label: "Discord"
449 + oauth_discord_desc: "OAuth2"
450 + oauth_microsoft_label: "Microsoft"
451 + oauth_microsoft_desc: "OpenID Connect"
417 452 users_list:
418 453 page_title: "Gestion des Utilisateurs - Admin - RustCloud"
419 454 title: "Gestion des Utilisateurs"
@@ -689,6 +724,13 @@ rate_limit:
689 724 exceeded: "Trop de requêtes. Veuillez réessayer plus tard."
690 725 password_change: "Trop de demandes de changement de mot de passe. Veuillez patienter avant de réessayer."
691 726
727 +# OAuth
728 +oauth:
729 + divider: "ou"
730 + login_with: "Se connecter avec {provider}"
731 + error_denied: "Connexion refusée par le fournisseur."
732 + error_generic: "Erreur lors de la connexion OAuth."
733 +
692 734 # Sessions
693 735 session:
694 736 ip_unavailable: "Non disponible"
D migrations/src/lib.rs
+0 -18
@@ -1,18 +0,0 @@
1 -pub use sea_orm_migration::prelude::*;
2 -
3 -pub mod m20260305_000001_initial_schema;
4 -pub mod m20260306_000002_create_app_settings_table;
5 -pub mod m20260309_000003_create_permissions_tables;
6 -
7 -pub struct Migrator;
8 -
9 -#[async_trait::async_trait]
10 -impl MigratorTrait for Migrator {
11 - fn migrations() -> Vec<Box<dyn MigrationTrait>> {
12 - vec![
13 - Box::new(m20260305_000001_initial_schema::Migration),
14 - Box::new(m20260306_000002_create_app_settings_table::Migration),
15 - Box::new(m20260309_000003_create_permissions_tables::Migration),
16 - ]
17 - }
18 -}
D migrations/src/m20260305_000001_initial_schema.rs
+0 -1288
@@ -1,1288 +0,0 @@
1 -use sea_orm_migration::prelude::*;
2 -
3 -#[derive(DeriveMigrationName)]
4 -pub struct Migration;
5 -
6 -#[async_trait::async_trait]
7 -impl MigrationTrait for Migration {
8 - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
9 - // --- 1. users ---
10 - manager
11 - .create_table(
12 - Table::create()
13 - .table(Users::Table)
14 - .if_not_exists()
15 - .col(ColumnDef::new(Users::Id).uuid().not_null().primary_key())
16 - .col(
17 - ColumnDef::new(Users::Username)
18 - .string()
19 - .string_len(64)
20 - .not_null()
21 - .unique_key(),
22 - )
23 - .col(
24 - ColumnDef::new(Users::Email)
25 - .string()
26 - .string_len(255)
27 - .not_null()
28 - .unique_key(),
29 - )
30 - .col(
31 - ColumnDef::new(Users::PasswordHash)
32 - .string()
33 - .string_len(255)
34 - .not_null(),
35 - )
36 - .col(
37 - ColumnDef::new(Users::MustChangePassword)
38 - .boolean()
39 - .not_null()
40 - .default(false),
41 - )
42 - .col(
43 - ColumnDef::new(Users::EmailVerified)
44 - .boolean()
45 - .not_null()
46 - .default(false),
47 - )
48 - .col(
49 - ColumnDef::new(Users::EmailVerifiedAt)
50 - .timestamp_with_time_zone()
51 - .null(),
52 - )
53 - .col(
54 - ColumnDef::new(Users::LastLoginAt)
55 - .timestamp_with_time_zone()
56 - .null(),
57 - )
58 - .col(
59 - ColumnDef::new(Users::LastLoginIp)
60 - .string()
61 - .string_len(45)
62 - .null(),
63 - )
64 - .col(
65 - ColumnDef::new(Users::PasswordChangedAt)
66 - .timestamp_with_time_zone()
67 - .null(),
68 - )
69 - .col(
70 - ColumnDef::new(Users::CreatedAt)
71 - .timestamp_with_time_zone()
72 - .not_null(),
73 - )
74 - .col(
75 - ColumnDef::new(Users::UpdatedAt)
76 - .timestamp_with_time_zone()
77 - .not_null(),
78 - )
79 - .to_owned(),
80 - )
81 - .await?;
82 -
83 - manager
84 - .create_index(
85 - Index::create()
86 - .if_not_exists()
87 - .name("idx_users_email_verified")
88 - .table(Users::Table)
89 - .col(Users::EmailVerified)
90 - .to_owned(),
91 - )
92 - .await?;
93 -
94 - manager
95 - .create_index(
96 - Index::create()
97 - .if_not_exists()
98 - .name("idx_users_last_login_at")
99 - .table(Users::Table)
100 - .col(Users::LastLoginAt)
101 - .to_owned(),
102 - )
103 - .await?;
104 -
105 - manager
106 - .create_index(
107 - Index::create()
108 - .if_not_exists()
109 - .name("idx_users_password_changed_at")
110 - .table(Users::Table)
111 - .col(Users::PasswordChangedAt)
112 - .to_owned(),
113 - )
114 - .await?;
115 -
116 - // --- 2. roles ---
117 - manager
118 - .create_table(
119 - Table::create()
120 - .table(Roles::Table)
121 - .if_not_exists()
122 - .col(ColumnDef::new(Roles::Id).uuid().not_null().primary_key())
123 - .col(
124 - ColumnDef::new(Roles::Name)
125 - .string()
126 - .string_len(64)
127 - .not_null()
128 - .unique_key(),
129 - )
130 - .col(ColumnDef::new(Roles::Description).text().null())
131 - .col(
132 - ColumnDef::new(Roles::CreatedAt)
133 - .timestamp_with_time_zone()
134 - .not_null(),
135 - )
136 - .col(
137 - ColumnDef::new(Roles::UpdatedAt)
138 - .timestamp_with_time_zone()
139 - .not_null(),
140 - )
141 - .to_owned(),
142 - )
143 - .await?;
144 -
145 - // --- 3. user_roles ---
146 - manager
147 - .create_table(
148 - Table::create()
149 - .table(UserRoles::Table)
150 - .if_not_exists()
151 - .col(ColumnDef::new(UserRoles::UserId).uuid().not_null())
152 - .col(ColumnDef::new(UserRoles::RoleId).uuid().not_null())
153 - .col(
154 - ColumnDef::new(UserRoles::CreatedAt)
155 - .timestamp_with_time_zone()
156 - .not_null(),
157 - )
158 - .primary_key(
159 - Index::create()
160 - .col(UserRoles::UserId)
161 - .col(UserRoles::RoleId),
162 - )
163 - .foreign_key(
164 - ForeignKey::create()
165 - .name("fk_user_roles_user_id")
166 - .from(UserRoles::Table, UserRoles::UserId)
167 - .to(Users::Table, Users::Id)
168 - .on_delete(ForeignKeyAction::Cascade),
169 - )
170 - .foreign_key(
171 - ForeignKey::create()
172 - .name("fk_user_roles_role_id")
173 - .from(UserRoles::Table, UserRoles::RoleId)
174 - .to(Roles::Table, Roles::Id)
175 - .on_delete(ForeignKeyAction::Cascade),
176 - )
177 - .to_owned(),
178 - )
179 - .await?;
180 -
181 - // --- 4. refresh_token ---
182 - manager
183 - .create_table(
184 - Table::create()
185 - .table(RefreshToken::Table)
186 - .if_not_exists()
187 - .col(
188 - ColumnDef::new(RefreshToken::Id)
189 - .uuid()
190 - .not_null()
191 - .primary_key(),
192 - )
193 - .col(ColumnDef::new(RefreshToken::UserId).uuid().not_null())
194 - .col(
195 - ColumnDef::new(RefreshToken::Token)
196 - .string()
197 - .not_null()
198 - .unique_key(),
199 - )
200 - .col(
201 - ColumnDef::new(RefreshToken::ExpiresAt)
202 - .timestamp_with_time_zone()
203 - .not_null(),
204 - )
205 - .col(
206 - ColumnDef::new(RefreshToken::CreatedAt)
207 - .timestamp_with_time_zone()
208 - .not_null(),
209 - )
210 - .col(
211 - ColumnDef::new(RefreshToken::Revoked)
212 - .boolean()
213 - .not_null()
214 - .default(false),
215 - )
216 - .col(
217 - ColumnDef::new(RefreshToken::RevokedAt)
218 - .timestamp_with_time_zone()
219 - .null(),
220 - )
221 - .col(ColumnDef::new(RefreshToken::ReplacedBy).uuid().null())
222 - .foreign_key(
223 - ForeignKey::create()
224 - .name("fk_refresh_token_user_id")
225 - .from(RefreshToken::Table, RefreshToken::UserId)
226 - .to(Users::Table, Users::Id)
227 - .on_delete(ForeignKeyAction::Cascade),
228 - )
229 - .foreign_key(
230 - ForeignKey::create()
231 - .name("fk_refresh_token_replaced_by")
232 - .from(RefreshToken::Table, RefreshToken::ReplacedBy)
233 - .to(RefreshToken::Table, RefreshToken::Id)
234 - .on_delete(ForeignKeyAction::SetNull),
235 - )
236 - .to_owned(),
237 - )
238 - .await?;
239 -
240 - manager
241 - .create_index(
242 - Index::create()
243 - .if_not_exists()
244 - .name("idx_refresh_token_user_id")
245 - .table(RefreshToken::Table)
246 - .col(RefreshToken::UserId)
247 - .to_owned(),
248 - )
249 - .await?;
250 -
251 - manager
252 - .create_index(
253 - Index::create()
254 - .if_not_exists()
255 - .name("idx_refresh_token_token")
256 - .table(RefreshToken::Table)
257 - .col(RefreshToken::Token)
258 - .to_owned(),
259 - )
260 - .await?;
261 -
262 - manager
263 - .create_index(
264 - Index::create()
265 - .if_not_exists()
266 - .name("idx_refresh_token_expires_at")
267 - .table(RefreshToken::Table)
268 - .col(RefreshToken::ExpiresAt)
269 - .to_owned(),
270 - )
271 - .await?;
272 -
273 - // --- 5. jwt_blacklist ---
274 - manager
275 - .create_table(
276 - Table::create()
277 - .table(JwtBlacklist::Table)
278 - .if_not_exists()
279 - .col(
280 - ColumnDef::new(JwtBlacklist::Id)
281 - .uuid()
282 - .not_null()
283 - .primary_key(),
284 - )
285 - .col(
286 - ColumnDef::new(JwtBlacklist::TokenHash)
287 - .string()
288 - .not_null()
289 - .unique_key(),
290 - )
291 - .col(ColumnDef::new(JwtBlacklist::UserId).uuid().not_null())
292 - .col(
293 - ColumnDef::new(JwtBlacklist::ExpiresAt)
294 - .timestamp_with_time_zone()
295 - .not_null(),
296 - )
297 - .col(
298 - ColumnDef::new(JwtBlacklist::RevokedAt)
299 - .timestamp_with_time_zone()
300 - .not_null(),
301 - )
302 - .col(
303 - ColumnDef::new(JwtBlacklist::CreatedAt)
304 - .timestamp_with_time_zone()
305 - .not_null(),
306 - )
307 - .foreign_key(
308 - ForeignKey::create()
309 - .name("fk_jwt_blacklist_user_id")
310 - .from(JwtBlacklist::Table, JwtBlacklist::UserId)
311 - .to(Users::Table, Users::Id)
312 - .on_delete(ForeignKeyAction::Cascade),
313 - )
314 - .to_owned(),
315 - )
316 - .await?;
317 -
318 - manager
319 - .create_index(
320 - Index::create()
321 - .if_not_exists()
322 - .name("idx_jwt_blacklist_token_hash")
323 - .table(JwtBlacklist::Table)
324 - .col(JwtBlacklist::TokenHash)
325 - .unique()
326 - .to_owned(),
327 - )
328 - .await?;
329 -
330 - manager
331 - .create_index(
332 - Index::create()
333 - .if_not_exists()
334 - .name("idx_jwt_blacklist_expires_at")
335 - .table(JwtBlacklist::Table)
336 - .col(JwtBlacklist::ExpiresAt)
337 - .to_owned(),
338 - )
339 - .await?;
340 -
341 - manager
342 - .create_index(
343 - Index::create()
344 - .if_not_exists()
345 - .name("idx_jwt_blacklist_user_id")
346 - .table(JwtBlacklist::Table)
347 - .col(JwtBlacklist::UserId)
348 - .to_owned(),
349 - )
350 - .await?;
351 -
352 - // --- 6. user_preferences ---
353 - manager
354 - .create_table(
355 - Table::create()
356 - .table(UserPreferences::Table)
357 - .if_not_exists()
358 - .col(
359 - ColumnDef::new(UserPreferences::Id)
360 - .uuid()
361 - .not_null()
362 - .primary_key()
363 - .default(Expr::cust("gen_random_uuid()")),
364 - )
365 - .col(
366 - ColumnDef::new(UserPreferences::UserId)
367 - .uuid()
368 - .not_null()
369 - .unique_key(),
370 - )
371 - .col(
372 - ColumnDef::new(UserPreferences::Theme)
373 - .string()
374 - .string_len(10)
375 - .not_null()
376 - .default("light"),
377 - )
378 - .col(
379 - ColumnDef::new(UserPreferences::Language)
380 - .string()
381 - .string_len(5)
382 - .not_null()
383 - .default("fr"),
384 - )
385 - .col(
386 - ColumnDef::new(UserPreferences::CreatedAt)
387 - .timestamp_with_time_zone()
388 - .not_null()
389 - .default(Expr::cust("NOW()")),
390 - )
391 - .col(
392 - ColumnDef::new(UserPreferences::UpdatedAt)
393 - .timestamp_with_time_zone()
394 - .not_null()
395 - .default(Expr::cust("NOW()")),
396 - )
397 - .foreign_key(
398 - ForeignKey::create()
399 - .name("fk_user_preferences_user_id")
400 - .from(UserPreferences::Table, UserPreferences::UserId)
401 - .to(Users::Table, Users::Id)
402 - .on_delete(ForeignKeyAction::Cascade),
403 - )
404 - .to_owned(),
405 - )
406 - .await?;
407 -
408 - manager
409 - .get_connection()
410 - .execute_unprepared(
411 - "ALTER TABLE user_preferences ADD CONSTRAINT check_theme CHECK (theme IN ('light', 'dark', 'auto'))",
412 - )
413 - .await?;
414 -
415 - manager
416 - .get_connection()
417 - .execute_unprepared(
418 - "ALTER TABLE user_preferences ADD CONSTRAINT check_language CHECK (language IN ('fr', 'en'))",
419 - )
420 - .await?;
421 -
422 - manager
423 - .create_index(
424 - Index::create()
425 - .if_not_exists()
426 - .name("idx_user_preferences_user_id")
427 - .table(UserPreferences::Table)
428 - .col(UserPreferences::UserId)
429 - .to_owned(),
430 - )
431 - .await?;
432 -
433 - // --- 7. password_reset_tokens ---
434 - manager
435 - .create_table(
436 - Table::create()
437 - .table(PasswordResetTokens::Table)
438 - .if_not_exists()
439 - .col(
440 - ColumnDef::new(PasswordResetTokens::Id)
441 - .uuid()
442 - .not_null()
443 - .primary_key(),
444 - )
445 - .col(
446 - ColumnDef::new(PasswordResetTokens::UserId)
447 - .uuid()
448 - .not_null(),
449 - )
450 - .col(
451 - ColumnDef::new(PasswordResetTokens::TokenHash)
452 - .string_len(64)
453 - .not_null()
454 - .unique_key(),
455 - )
456 - .col(
457 - ColumnDef::new(PasswordResetTokens::ExpiresAt)
458 - .timestamp_with_time_zone()
459 - .not_null(),
460 - )
461 - .col(
462 - ColumnDef::new(PasswordResetTokens::UsedAt)
463 - .timestamp_with_time_zone()
464 - .null(),
465 - )
466 - .col(
467 - ColumnDef::new(PasswordResetTokens::CreatedAt)
468 - .timestamp_with_time_zone()
469 - .not_null(),
470 - )
471 - .foreign_key(
472 - ForeignKey::create()
473 - .name("fk_password_reset_tokens_user_id")
474 - .from(PasswordResetTokens::Table, PasswordResetTokens::UserId)
475 - .to(Users::Table, Users::Id)
476 - .on_delete(ForeignKeyAction::Cascade),
477 - )
478 - .to_owned(),
479 - )
480 - .await?;
481 -
482 - manager
483 - .create_index(
484 - Index::create()
485 - .if_not_exists()
486 - .name("idx_password_reset_tokens_user_id")
487 - .table(PasswordResetTokens::Table)
488 - .col(PasswordResetTokens::UserId)
489 - .to_owned(),
490 - )
491 - .await?;
492 -
493 - manager
494 - .create_index(
495 - Index::create()
496 - .if_not_exists()
497 - .name("idx_password_reset_tokens_token_hash")
498 - .table(PasswordResetTokens::Table)
499 - .col(PasswordResetTokens::TokenHash)
500 - .unique()
501 - .to_owned(),
502 - )
503 - .await?;
504 -
505 - manager
506 - .create_index(
507 - Index::create()
508 - .if_not_exists()
509 - .name("idx_password_reset_tokens_expires_at")
510 - .table(PasswordResetTokens::Table)
511 - .col(PasswordResetTokens::ExpiresAt)
512 - .to_owned(),
513 - )
514 - .await?;
515 -
516 - // --- 8. email_validation_tokens ---
517 - manager
518 - .create_table(
519 - Table::create()
520 - .table(EmailValidationTokens::Table)
521 - .if_not_exists()
522 - .col(
523 - ColumnDef::new(EmailValidationTokens::Id)
524 - .uuid()
525 - .not_null()
526 - .primary_key(),
527 - )
528 - .col(
529 - ColumnDef::new(EmailValidationTokens::UserId)
530 - .uuid()
531 - .not_null(),
532 - )
533 - .col(
534 - ColumnDef::new(EmailValidationTokens::TokenHash)
535 - .string_len(64)
536 - .not_null()
537 - .unique_key(),
538 - )
539 - .col(
540 - ColumnDef::new(EmailValidationTokens::ExpiresAt)
541 - .timestamp_with_time_zone()
542 - .not_null(),
543 - )
544 - .col(
545 - ColumnDef::new(EmailValidationTokens::UsedAt)
546 - .timestamp_with_time_zone()
547 - .null(),
548 - )
549 - .col(
550 - ColumnDef::new(EmailValidationTokens::CreatedAt)
551 - .timestamp_with_time_zone()
552 - .not_null(),
553 - )
554 - .foreign_key(
555 - ForeignKey::create()
556 - .name("fk_email_validation_tokens_user_id")
557 - .from(
558 - EmailValidationTokens::Table,
559 - EmailValidationTokens::UserId,
560 - )
561 - .to(Users::Table, Users::Id)
562 - .on_delete(ForeignKeyAction::Cascade),
563 - )
564 - .to_owned(),
565 - )
566 - .await?;
567 -
568 - manager
569 - .create_index(
570 - Index::create()
571 - .if_not_exists()
572 - .name("idx_email_validation_tokens_user_id")
573 - .table(EmailValidationTokens::Table)
574 - .col(EmailValidationTokens::UserId)
575 - .to_owned(),
576 - )
577 - .await?;
578 -
579 - manager
580 - .create_index(
581 - Index::create()
582 - .if_not_exists()
583 - .name("idx_email_validation_tokens_token_hash")
584 - .table(EmailValidationTokens::Table)
585 - .col(EmailValidationTokens::TokenHash)
586 - .unique()
587 - .to_owned(),
588 - )
589 - .await?;
590 -
591 - manager
592 - .create_index(
593 - Index::create()
594 - .if_not_exists()
595 - .name("idx_email_validation_tokens_expires_at")
596 - .table(EmailValidationTokens::Table)
597 - .col(EmailValidationTokens::ExpiresAt)
598 - .to_owned(),
599 - )
600 - .await?;
601 -
602 - // --- 9. password_change_requests ---
603 - manager
604 - .create_table(
605 - Table::create()
606 - .table(PasswordChangeRequests::Table)
607 - .if_not_exists()
608 - .col(
609 - ColumnDef::new(PasswordChangeRequests::Id)
610 - .uuid()
611 - .not_null()
612 - .primary_key(),
613 - )
614 - .col(
615 - ColumnDef::new(PasswordChangeRequests::UserId)
616 - .uuid()
617 - .not_null(),
618 - )
619 - .col(
620 - ColumnDef::new(PasswordChangeRequests::NewPasswordHash)
621 - .text()
622 - .not_null(),
623 - )
624 - .col(
625 - ColumnDef::new(PasswordChangeRequests::ConfirmationToken)
626 - .string_len(64)
627 - .not_null()
628 - .unique_key(),
629 - )
630 - .col(
631 - ColumnDef::new(PasswordChangeRequests::ExpiresAt)
632 - .timestamp_with_time_zone()
633 - .not_null(),
634 - )
635 - .col(
636 - ColumnDef::new(PasswordChangeRequests::CreatedAt)
637 - .timestamp_with_time_zone()
638 - .not_null(),
639 - )
640 - .col(
641 - ColumnDef::new(PasswordChangeRequests::ConfirmedAt)
642 - .timestamp_with_time_zone()
643 - .null(),
644 - )
645 - .col(
646 - ColumnDef::new(PasswordChangeRequests::EmailSentAt)
647 - .timestamp_with_time_zone()
648 - .null(),
649 - )
650 - .col(
651 - ColumnDef::new(PasswordChangeRequests::RetryCount)
652 - .integer()
653 - .not_null()
654 - .default(0),
655 - )
656 - .foreign_key(
657 - ForeignKey::create()
658 - .name("fk_password_change_requests_user_id")
659 - .from(
660 - PasswordChangeRequests::Table,
661 - PasswordChangeRequests::UserId,
662 - )
663 - .to(Users::Table, Users::Id)
664 - .on_delete(ForeignKeyAction::Cascade),
665 - )
666 - .to_owned(),
667 - )
668 - .await?;
669 -
670 - manager
671 - .create_index(
672 - Index::create()
673 - .if_not_exists()
674 - .name("idx_password_change_requests_user_id")
675 - .table(PasswordChangeRequests::Table)
676 - .col(PasswordChangeRequests::UserId)
677 - .to_owned(),
678 - )
679 - .await?;
680 -
681 - manager
682 - .create_index(
683 - Index::create()
684 - .if_not_exists()
685 - .name("idx_password_change_requests_confirmation_token")
686 - .table(PasswordChangeRequests::Table)
687 - .col(PasswordChangeRequests::ConfirmationToken)
688 - .unique()
689 - .to_owned(),
690 - )
691 - .await?;
692 -
693 - manager
694 - .create_index(
695 - Index::create()
696 - .if_not_exists()
697 - .name("idx_password_change_requests_expires_at")
698 - .table(PasswordChangeRequests::Table)
699 - .col(PasswordChangeRequests::ExpiresAt)
700 - .to_owned(),
701 - )
702 - .await?;
703 -
704 - manager
705 - .get_connection()
706 - .execute_unprepared(
707 - r#"
708 - CREATE UNIQUE INDEX IF NOT EXISTS idx_password_change_requests_user_id_pending
709 - ON password_change_requests (user_id)
710 - WHERE confirmed_at IS NULL
711 - "#,
712 - )
713 - .await?;
714 -
715 - // --- 10. email_queue ---
716 - manager
717 - .create_table(
718 - Table::create()
719 - .table(EmailQueue::Table)
720 - .if_not_exists()
721 - .col(
722 - ColumnDef::new(EmailQueue::Id)
723 - .uuid()
724 - .not_null()
725 - .primary_key(),
726 - )
727 - .col(
728 - ColumnDef::new(EmailQueue::ToEmail)
729 - .string_len(255)
730 - .not_null(),
731 - )
732 - .col(ColumnDef::new(EmailQueue::ToName).string_len(255).null())
733 - .col(
734 - ColumnDef::new(EmailQueue::FromEmail)
735 - .string_len(255)
736 - .not_null(),
737 - )
738 - .col(ColumnDef::new(EmailQueue::FromName).string_len(255).null())
739 - .col(
740 - ColumnDef::new(EmailQueue::Subject)
741 - .string_len(500)
742 - .not_null(),
743 - )
744 - .col(ColumnDef::new(EmailQueue::BodyHtml).text().not_null())
745 - .col(ColumnDef::new(EmailQueue::BodyText).text().not_null())
746 - .col(
747 - ColumnDef::new(EmailQueue::EmailType)
748 - .string_len(50)
749 - .not_null(),
750 - )
751 - .col(
752 - ColumnDef::new(EmailQueue::Status)
753 - .string_len(20)
754 - .not_null()
755 - .default("pending"),
756 - )
757 - .col(
758 - ColumnDef::new(EmailQueue::Attempts)
759 - .integer()
760 - .not_null()
761 - .default(0),
762 - )
763 - .col(
764 - ColumnDef::new(EmailQueue::LastAttemptAt)
765 - .timestamp_with_time_zone()
766 - .null(),
767 - )
768 - .col(
769 - ColumnDef::new(EmailQueue::SentAt)
770 - .timestamp_with_time_zone()
771 - .null(),
772 - )
773 - .col(ColumnDef::new(EmailQueue::ErrorMessage).text().null())
774 - .col(
775 - ColumnDef::new(EmailQueue::SmtpCode)
776 - .string_len(10)
777 - .null(),
778 - )
779 - .col(
780 - ColumnDef::new(EmailQueue::SmtpResponse)
781 - .text()
782 - .null(),
783 - )
784 - .col(
785 - ColumnDef::new(EmailQueue::CreatedAt)
786 - .timestamp_with_time_zone()
787 - .not_null(),
788 - )
789 - .col(
790 - ColumnDef::new(EmailQueue::UpdatedAt)
791 - .timestamp_with_time_zone()
792 - .not_null(),
793 - )
794 - .to_owned(),
795 - )
796 - .await?;
797 -
798 - manager
799 - .create_index(
800 - Index::create()
801 - .if_not_exists()
802 - .name("idx_email_queue_status")
803 - .table(EmailQueue::Table)
804 - .col(EmailQueue::Status)
805 - .to_owned(),
806 - )
807 - .await?;
808 -
809 - manager
810 - .create_index(
811 - Index::create()
812 - .if_not_exists()
813 - .name("idx_email_queue_created_at")
814 - .table(EmailQueue::Table)
815 - .col(EmailQueue::CreatedAt)
816 - .to_owned(),
817 - )
818 - .await?;
819 -
820 - manager
821 - .create_index(
822 - Index::create()
823 - .if_not_exists()
824 - .name("idx_email_queue_email_type")
825 - .table(EmailQueue::Table)
826 - .col(EmailQueue::EmailType)
827 - .to_owned(),
828 - )
829 - .await?;
830 -
831 - manager
832 - .get_connection()
833 - .execute_unprepared(
834 - r#"
835 - ALTER TABLE email_queue
836 - ADD CONSTRAINT chk_email_queue_email_type
837 - CHECK (email_type IN (
838 - 'password_reset',
839 - 'email_validation',
840 - 'password_changed',
841 - 'account_modified',
842 - 'welcome',
843 - 'admin_new_user',
844 - 'notification'
845 - ))
846 - "#,
847 - )
848 - .await?;
849 -
850 - manager
851 - .get_connection()
852 - .execute_unprepared(
853 - r#"
854 - ALTER TABLE email_queue
855 - ADD CONSTRAINT chk_email_queue_status
856 - CHECK (status IN ('pending', 'sent', 'failed'))
857 - "#,
858 - )
859 - .await?;
860 -
861 - // --- 11. email_delivery_status ---
862 - manager
863 - .create_table(
864 - Table::create()
865 - .table(EmailDeliveryStatus::Table)
866 - .if_not_exists()
867 - .col(
868 - ColumnDef::new(EmailDeliveryStatus::Id)
869 - .uuid()
870 - .not_null()
871 - .primary_key(),
872 - )
873 - .col(
874 - ColumnDef::new(EmailDeliveryStatus::EmailType)
875 - .string_len(50)
876 - .not_null(),
877 - )
878 - .col(
879 - ColumnDef::new(EmailDeliveryStatus::Recipient)
880 - .string_len(255)
881 - .not_null(),
882 - )
883 - .col(
884 - ColumnDef::new(EmailDeliveryStatus::Template)
885 - .string_len(255)
886 - .not_null(),
887 - )
888 - .col(
889 - ColumnDef::new(EmailDeliveryStatus::State)
890 - .string_len(20)
891 - .not_null()
892 - .default("queued"),
893 - )
894 - .col(
895 - ColumnDef::new(EmailDeliveryStatus::SmtpCode)
896 - .string_len(10)
897 - .null(),
898 - )
899 - .col(
900 - ColumnDef::new(EmailDeliveryStatus::SmtpResponse)
901 - .text()
902 - .null(),
903 - )
904 - .col(
905 - ColumnDef::new(EmailDeliveryStatus::AttemptCount)
906 - .integer()
907 - .not_null()
908 - .default(0),
909 - )
910 - .col(
911 - ColumnDef::new(EmailDeliveryStatus::LastAttemptAt)
912 - .timestamp_with_time_zone()
913 - .null(),
914 - )
915 - .col(
916 - ColumnDef::new(EmailDeliveryStatus::LastStateAt)
917 - .timestamp_with_time_zone()
918 - .not_null(),
919 - )
920 - .col(
921 - ColumnDef::new(EmailDeliveryStatus::CorrelationId)
922 - .uuid()
923 - .null(),
924 - )
925 - .col(
926 - ColumnDef::new(EmailDeliveryStatus::CreatedAt)
927 - .timestamp_with_time_zone()
928 - .not_null(),
929 - )
930 - .col(
931 - ColumnDef::new(EmailDeliveryStatus::UpdatedAt)
932 - .timestamp_with_time_zone()
933 - .not_null(),
934 - )
935 - .to_owned(),
936 - )
937 - .await?;
938 -
939 - manager
940 - .create_index(
941 - Index::create()
942 - .if_not_exists()
943 - .name("idx_email_delivery_status_type")
944 - .table(EmailDeliveryStatus::Table)
945 - .col(EmailDeliveryStatus::EmailType)
946 - .to_owned(),
947 - )
948 - .await?;
949 -
950 - manager
951 - .create_index(
952 - Index::create()
953 - .if_not_exists()
954 - .name("idx_email_delivery_status_state")
955 - .table(EmailDeliveryStatus::Table)
956 - .col(EmailDeliveryStatus::State)
957 - .to_owned(),
958 - )
959 - .await?;
960 -
961 - manager
962 - .create_index(
963 - Index::create()
964 - .if_not_exists()
965 - .name("idx_email_delivery_status_recipient")
966 - .table(EmailDeliveryStatus::Table)
967 - .col(EmailDeliveryStatus::Recipient)
968 - .to_owned(),
969 - )
970 - .await?;
971 -
972 - manager
973 - .create_index(
974 - Index::create()
975 - .if_not_exists()
976 - .name("idx_email_delivery_status_last_state_at")
977 - .table(EmailDeliveryStatus::Table)
978 - .col(EmailDeliveryStatus::LastStateAt)
979 - .to_owned(),
980 - )
981 - .await?;
982 -
983 - // --- Section 12: audit_logs table ---
984 - manager
985 - .create_table(
986 - Table::create()
987 - .table(AuditLogs::Table)
988 - .if_not_exists()
989 - .col(ColumnDef::new(AuditLogs::Id).uuid().not_null().primary_key())
990 - .col(ColumnDef::new(AuditLogs::UserId).uuid())
991 - .col(ColumnDef::new(AuditLogs::Action).string_len(100).not_null())
992 - .col(ColumnDef::new(AuditLogs::TargetUserId).uuid())
993 - .col(ColumnDef::new(AuditLogs::Level).string_len(10).not_null().default("INFO"))
994 - .col(ColumnDef::new(AuditLogs::Message).text().not_null())
995 - .col(ColumnDef::new(AuditLogs::CreatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
996 - .foreign_key(
997 - ForeignKey::create()
998 - .name("fk_audit_logs_user")
999 - .from(AuditLogs::Table, AuditLogs::UserId)
1000 - .to(Users::Table, Users::Id)
1001 - .on_delete(ForeignKeyAction::SetNull),
1002 - )
1003 - .foreign_key(
1004 - ForeignKey::create()
1005 - .name("fk_audit_logs_target_user")
1006 - .from(AuditLogs::Table, AuditLogs::TargetUserId)
1007 - .to(Users::Table, Users::Id)
1008 - .on_delete(ForeignKeyAction::SetNull),
1009 - )
1010 - .to_owned(),
1011 - )
1012 - .await?;
1013 -
1014 - manager
1015 - .create_index(
1016 - Index::create()
1017 - .if_not_exists()
1018 - .name("idx_audit_logs_user_id")
1019 - .table(AuditLogs::Table)
1020 - .col(AuditLogs::UserId)
1021 - .to_owned(),
1022 - )
1023 - .await?;
1024 -
1025 - manager
1026 - .create_index(
1027 - Index::create()
1028 - .if_not_exists()
1029 - .name("idx_audit_logs_action")
1030 - .table(AuditLogs::Table)
1031 - .col(AuditLogs::Action)
1032 - .to_owned(),
1033 - )
1034 - .await?;
1035 -
1036 - manager
1037 - .create_index(
1038 - Index::create()
1039 - .if_not_exists()
1040 - .name("idx_audit_logs_created_at")
1041 - .table(AuditLogs::Table)
1042 - .col(AuditLogs::CreatedAt)
1043 - .to_owned(),
1044 - )
1045 - .await?;
1046 -
1047 - // --- Seed default roles ---
1048 - manager
1049 - .get_connection()
1050 - .execute_unprepared(
1051 - r#"
1052 - INSERT INTO roles (id, name, description, created_at, updated_at)
1053 - VALUES
1054 - (gen_random_uuid(), 'admin', 'Administrator role', NOW(), NOW()),
1055 - (gen_random_uuid(), 'user', 'Standard user role', NOW(), NOW())
1056 - ON CONFLICT (name) DO NOTHING
1057 - "#,
1058 - )
1059 - .await?;
1060 -
1061 - Ok(())
1062 - }
1063 -
1064 - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
1065 - manager
1066 - .drop_table(Table::drop().table(AuditLogs::Table).to_owned())
1067 - .await?;
1068 -
1069 - manager
1070 - .drop_table(
1071 - Table::drop()
1072 - .table(EmailDeliveryStatus::Table)
1073 - .to_owned(),
1074 - )
1075 - .await?;
1076 -
1077 - manager
1078 - .drop_table(Table::drop().table(EmailQueue::Table).to_owned())
1079 - .await?;
1080 -
1081 - manager
1082 - .drop_table(
1083 - Table::drop()
1084 - .table(PasswordChangeRequests::Table)
1085 - .to_owned(),
1086 - )
1087 - .await?;
1088 -
1089 - manager
1090 - .drop_table(
1091 - Table::drop()
1092 - .table(EmailValidationTokens::Table)
1093 - .to_owned(),
1094 - )
1095 - .await?;
1096 -
1097 - manager
1098 - .drop_table(
1099 - Table::drop()
1100 - .table(PasswordResetTokens::Table)
1101 - .to_owned(),
1102 - )
1103 - .await?;
1104 -
1105 - manager
1106 - .drop_table(Table::drop().table(UserPreferences::Table).to_owned())
1107 - .await?;
1108 -
1109 - manager
1110 - .drop_table(Table::drop().table(JwtBlacklist::Table).to_owned())
1111 - .await?;
1112 -
1113 - manager
1114 - .drop_table(Table::drop().table(RefreshToken::Table).to_owned())
1115 - .await?;
1116 -
1117 - manager
1118 - .drop_table(Table::drop().table(UserRoles::Table).to_owned())
1119 - .await?;
1120 -
1121 - manager
1122 - .drop_table(Table::drop().table(Roles::Table).to_owned())
1123 - .await?;
1124 -
1125 - manager
1126 - .drop_table(Table::drop().table(Users::Table).to_owned())
1127 - .await
1128 - }
1129 -}
1130 -
1131 -#[derive(DeriveIden)]
1132 -enum Users {
1133 - Table,
1134 - Id,
1135 - Username,
1136 - Email,
1137 - PasswordHash,
1138 - MustChangePassword,
1139 - EmailVerified,
1140 - EmailVerifiedAt,
1141 - LastLoginAt,
1142 - LastLoginIp,
1143 - PasswordChangedAt,
1144 - CreatedAt,
1145 - UpdatedAt,
1146 -}
1147 -
1148 -#[derive(DeriveIden)]
1149 -enum Roles {
1150 - Table,
1151 - Id,
1152 - Name,
1153 - Description,
1154 - CreatedAt,
1155 - UpdatedAt,
1156 -}
1157 -
1158 -#[derive(DeriveIden)]
1159 -enum UserRoles {
1160 - Table,
1161 - UserId,
1162 - RoleId,
1163 - CreatedAt,
1164 -}
1165 -
1166 -#[derive(DeriveIden)]
1167 -enum RefreshToken {
1168 - Table,
1169 - Id,
1170 - UserId,
1171 - Token,
1172 - ExpiresAt,
1173 - CreatedAt,
1174 - Revoked,
1175 - RevokedAt,
1176 - ReplacedBy,
1177 -}
1178 -
1179 -#[derive(DeriveIden)]
1180 -enum JwtBlacklist {
1181 - Table,
1182 - Id,
1183 - TokenHash,
1184 - UserId,
1185 - ExpiresAt,
1186 - RevokedAt,
1187 - CreatedAt,
1188 -}
1189 -
1190 -#[derive(DeriveIden)]
1191 -enum UserPreferences {
1192 - Table,
1193 - Id,
1194 - UserId,
1195 - Theme,
1196 - Language,
1197 - CreatedAt,
1198 - UpdatedAt,
1199 -}
1200 -
1201 -#[derive(DeriveIden)]
1202 -enum PasswordResetTokens {
1203 - Table,
1204 - Id,
1205 - UserId,
1206 - TokenHash,
1207 - ExpiresAt,
1208 - UsedAt,
1209 - CreatedAt,
1210 -}
1211 -
1212 -#[derive(DeriveIden)]
1213 -enum EmailValidationTokens {
1214 - Table,
1215 - Id,
1216 - UserId,
1217 - TokenHash,
1218 - ExpiresAt,
1219 - UsedAt,
1220 - CreatedAt,
1221 -}
1222 -
1223 -#[derive(DeriveIden)]
1224 -enum PasswordChangeRequests {
1225 - Table,
1226 - Id,
1227 - UserId,
1228 - NewPasswordHash,
1229 - ConfirmationToken,
1230 - ExpiresAt,
1231 - CreatedAt,
1232 - ConfirmedAt,
1233 - EmailSentAt,
1234 - RetryCount,
1235 -}
1236 -
1237 -#[derive(DeriveIden)]
1238 -enum EmailQueue {
1239 - Table,
1240 - Id,
1241 - ToEmail,
1242 - ToName,
1243 - FromEmail,
1244 - FromName,
1245 - Subject,
1246 - BodyHtml,
1247 - BodyText,
1248 - EmailType,
1249 - Status,
1250 - Attempts,
1251 - LastAttemptAt,
1252 - SentAt,
1253 - ErrorMessage,
1254 - SmtpCode,
1255 - SmtpResponse,
1256 - CreatedAt,
1257 - UpdatedAt,
1258 -}
1259 -
1260 -#[derive(DeriveIden)]
1261 -enum EmailDeliveryStatus {
1262 - Table,
1263 - Id,
1264 - EmailType,
1265 - Recipient,
1266 - Template,
1267 - State,
1268 - SmtpCode,
1269 - SmtpResponse,
1270 - AttemptCount,
1271 - LastAttemptAt,
1272 - LastStateAt,
1273 - CorrelationId,
1274 - CreatedAt,
1275 - UpdatedAt,
1276 -}
1277 -
1278 -#[derive(DeriveIden)]
1279 -enum AuditLogs {
1280 - Table,
1281 - Id,
1282 - UserId,
1283 - Action,
1284 - TargetUserId,
1285 - Level,
1286 - Message,
1287 - CreatedAt,
1288 -}
D migrations/src/m20260306_000002_create_app_settings_table.rs
+0 -82
@@ -1,82 +0,0 @@
1 -use sea_orm_migration::prelude::*;
2 -
3 -#[derive(DeriveMigrationName)]
4 -pub struct Migration;
5 -
6 -#[async_trait::async_trait]
7 -impl MigrationTrait for Migration {
8 - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
9 - manager
10 - .create_table(
11 - Table::create()
12 - .table(AppSettings::Table)
13 - .if_not_exists()
14 - .col(
15 - ColumnDef::new(AppSettings::Id)
16 - .uuid()
17 - .not_null()
18 - .primary_key(),
19 - )
20 - .col(
21 - ColumnDef::new(AppSettings::Key)
22 - .string()
23 - .string_len(100)
24 - .not_null()
25 - .unique_key(),
26 - )
27 - .col(ColumnDef::new(AppSettings::Value).text().not_null())
28 - .col(ColumnDef::new(AppSettings::Description).text().null())
29 - .col(ColumnDef::new(AppSettings::UpdatedBy).uuid().null())
30 - .col(
31 - ColumnDef::new(AppSettings::UpdatedAt)
32 - .timestamp_with_time_zone()
33 - .not_null(),
34 - )
35 - .foreign_key(
36 - ForeignKey::create()
37 - .name("fk_app_settings_updated_by")
38 - .from(AppSettings::Table, AppSettings::UpdatedBy)
39 - .to(Users::Table, Users::Id)
40 - .on_delete(ForeignKeyAction::SetNull),
41 - )
42 - .to_owned(),
43 - )
44 - .await?;
45 -
46 - manager
47 - .create_index(
48 - Index::create()
49 - .if_not_exists()
50 - .name("idx_app_settings_key")
51 - .table(AppSettings::Table)
52 - .col(AppSettings::Key)
53 - .to_owned(),
54 - )
55 - .await?;
56 -
57 - Ok(())
58 - }
59 -
60 - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
61 - manager
62 - .drop_table(Table::drop().table(AppSettings::Table).to_owned())
63 - .await
64 - }
65 -}
66 -
67 -#[derive(DeriveIden)]
68 -enum AppSettings {
69 - Table,
70 - Id,
71 - Key,
72 - Value,
73 - Description,
74 - UpdatedBy,
75 - UpdatedAt,
76 -}
77 -
78 -#[derive(DeriveIden)]
79 -enum Users {
80 - Table,
81 - Id,
82 -}
D migrations/src/m20260309_000003_create_permissions_tables.rs
+0 -179
@@ -1,179 +0,0 @@
1 -use sea_orm_migration::prelude::*;
2 -
3 -#[derive(DeriveMigrationName)]
4 -pub struct Migration;
5 -
6 -#[async_trait::async_trait]
7 -impl MigrationTrait for Migration {
8 - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
9 - // --- 1. permissions table ---
10 - manager
11 - .create_table(
12 - Table::create()
13 - .table(Permissions::Table)
14 - .if_not_exists()
15 - .col(
16 - ColumnDef::new(Permissions::Id)
17 - .uuid()
18 - .not_null()
19 - .primary_key(),
20 - )
21 - .col(
22 - ColumnDef::new(Permissions::Name)
23 - .string()
24 - .string_len(100)
25 - .not_null()
26 - .unique_key(),
27 - )
28 - .col(
29 - ColumnDef::new(Permissions::Description)
30 - .text()
31 - .null(),
32 - )
33 - .col(
34 - ColumnDef::new(Permissions::Category)
35 - .string()
36 - .string_len(50)
37 - .not_null(),
38 - )
39 - .col(
40 - ColumnDef::new(Permissions::CreatedAt)
41 - .timestamp_with_time_zone()
42 - .not_null(),
43 - )
44 - .to_owned(),
45 - )
46 - .await?;
47 -
48 - // --- 2. role_permissions junction table ---
49 - manager
50 - .create_table(
51 - Table::create()
52 - .table(RolePermissions::Table)
53 - .if_not_exists()
54 - .col(ColumnDef::new(RolePermissions::RoleId).uuid().not_null())
55 - .col(
56 - ColumnDef::new(RolePermissions::PermissionId)
57 - .uuid()
58 - .not_null(),
59 - )
60 - .primary_key(
61 - Index::create()
62 - .col(RolePermissions::RoleId)
63 - .col(RolePermissions::PermissionId),
64 - )
65 - .foreign_key(
66 - ForeignKey::create()
67 - .name("fk_role_permissions_role_id")
68 - .from(RolePermissions::Table, RolePermissions::RoleId)
69 - .to(Roles::Table, Roles::Id)
70 - .on_delete(ForeignKeyAction::Cascade),
71 - )
72 - .foreign_key(
73 - ForeignKey::create()
74 - .name("fk_role_permissions_permission_id")
75 - .from(RolePermissions::Table, RolePermissions::PermissionId)
76 - .to(Permissions::Table, Permissions::Id)
77 - .on_delete(ForeignKeyAction::Cascade),
78 - )
79 - .to_owned(),
80 - )
81 - .await?;
82 -
83 - // --- 3. Indexes ---
84 - manager
85 - .create_index(
86 - Index::create()
87 - .if_not_exists()
88 - .name("idx_permissions_category")
89 - .table(Permissions::Table)
90 - .col(Permissions::Category)
91 - .to_owned(),
92 - )
93 - .await?;
94 -
95 - manager
96 - .create_index(
97 - Index::create()
98 - .if_not_exists()
99 - .name("idx_role_permissions_role_id")
100 - .table(RolePermissions::Table)
101 - .col(RolePermissions::RoleId)
102 - .to_owned(),
103 - )
104 - .await?;
105 -
106 - // --- 4. Seed default permissions ---
107 - manager
108 - .get_connection()
109 - .execute_unprepared(
110 - r#"
111 - INSERT INTO permissions (id, name, description, category, created_at) VALUES
112 - (gen_random_uuid(), 'users.list', 'View the list of users', 'users', NOW()),
113 - (gen_random_uuid(), 'users.create', 'Create new users', 'users', NOW()),
114 - (gen_random_uuid(), 'users.edit', 'Edit existing users', 'users', NOW()),
115 - (gen_random_uuid(), 'users.delete', 'Delete users', 'users', NOW()),
116 - (gen_random_uuid(), 'users.reset_password', 'Reset user passwords', 'users', NOW()),
117 - (gen_random_uuid(), 'roles.list', 'View the list of roles', 'roles', NOW()),
118 - (gen_random_uuid(), 'roles.create', 'Create new roles', 'roles', NOW()),
119 - (gen_random_uuid(), 'roles.edit', 'Edit existing roles', 'roles', NOW()),
120 - (gen_random_uuid(), 'roles.delete', 'Delete roles', 'roles', NOW()),
121 - (gen_random_uuid(), 'settings.view', 'View application settings', 'settings', NOW()),
122 - (gen_random_uuid(), 'settings.edit', 'Edit application settings', 'settings', NOW()),
123 - (gen_random_uuid(), 'audit.view', 'View audit logs', 'audit', NOW())
124 - ON CONFLICT (name) DO NOTHING
125 - "#,
126 - )
127 - .await?;
128 -
129 - // --- 5. Assign all permissions to the admin role ---
130 - manager
131 - .get_connection()
132 - .execute_unprepared(
133 - r#"
134 - INSERT INTO role_permissions (role_id, permission_id)
135 - SELECT r.id, p.id
136 - FROM roles r
137 - CROSS JOIN permissions p
138 - WHERE r.name = 'admin'
139 - ON CONFLICT DO NOTHING
140 - "#,
141 - )
142 - .await?;
143 -
144 - Ok(())
145 - }
146 -
147 - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
148 - manager
149 - .drop_table(Table::drop().table(RolePermissions::Table).to_owned())
150 - .await?;
151 - manager
152 - .drop_table(Table::drop().table(Permissions::Table).to_owned())
153 - .await?;
154 - Ok(())
155 - }
156 -}
157 -
158 -#[derive(DeriveIden)]
159 -enum Permissions {
160 - Table,
161 - Id,
162 - Name,
163 - Description,
164 - Category,
165 - CreatedAt,
166 -}
167 -
168 -#[derive(DeriveIden)]
169 -enum RolePermissions {
170 - Table,
171 - RoleId,
172 - PermissionId,
173 -}
174 -
175 -#[derive(DeriveIden)]
176 -enum Roles {
177 - Table,
178 - Id,
179 -}
D migrations/src/main.rs
+0 -9
@@ -1,9 +0,0 @@
1 -use sea_orm_migration::prelude::*;
2 -
3 -#[path = "lib.rs"]
4 -mod migrations;
5 -
6 -#[tokio::main]
7 -async fn main() {
8 - cli::run_cli(migrations::Migrator).await;
9 -}
A migrations_old/src/lib.rs
+18 -0
@@ -0,0 +1,18 @@
1 +pub use sea_orm_migration::prelude::*;
2 +
3 +pub mod m20260305_000001_initial_schema;
4 +pub mod m20260306_000002_create_app_settings_table;
5 +pub mod m20260309_000003_create_permissions_tables;
6 +
7 +pub struct Migrator;
8 +
9 +#[async_trait::async_trait]
10 +impl MigratorTrait for Migrator {
11 + fn migrations() -> Vec<Box<dyn MigrationTrait>> {
12 + vec![
13 + Box::new(m20260305_000001_initial_schema::Migration),
14 + Box::new(m20260306_000002_create_app_settings_table::Migration),
15 + Box::new(m20260309_000003_create_permissions_tables::Migration),
16 + ]
17 + }
18 +}
A migrations_old/src/m20260305_000001_initial_schema.rs
+1288 -0
@@ -0,0 +1,1288 @@
1 +use sea_orm_migration::prelude::*;
2 +
3 +#[derive(DeriveMigrationName)]
4 +pub struct Migration;
5 +
6 +#[async_trait::async_trait]
7 +impl MigrationTrait for Migration {
8 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
9 + // --- 1. users ---
10 + manager
11 + .create_table(
12 + Table::create()
13 + .table(Users::Table)
14 + .if_not_exists()
15 + .col(ColumnDef::new(Users::Id).uuid().not_null().primary_key())
16 + .col(
17 + ColumnDef::new(Users::Username)
18 + .string()
19 + .string_len(64)
20 + .not_null()
21 + .unique_key(),
22 + )
23 + .col(
24 + ColumnDef::new(Users::Email)
25 + .string()
26 + .string_len(255)
27 + .not_null()
28 + .unique_key(),
29 + )
30 + .col(
31 + ColumnDef::new(Users::PasswordHash)
32 + .string()
33 + .string_len(255)
34 + .not_null(),
35 + )
36 + .col(
37 + ColumnDef::new(Users::MustChangePassword)
38 + .boolean()
39 + .not_null()
40 + .default(false),
41 + )
42 + .col(
43 + ColumnDef::new(Users::EmailVerified)
44 + .boolean()
45 + .not_null()
46 + .default(false),
47 + )
48 + .col(
49 + ColumnDef::new(Users::EmailVerifiedAt)
50 + .timestamp_with_time_zone()
51 + .null(),
52 + )
53 + .col(
54 + ColumnDef::new(Users::LastLoginAt)
55 + .timestamp_with_time_zone()
56 + .null(),
57 + )
58 + .col(
59 + ColumnDef::new(Users::LastLoginIp)
60 + .string()
61 + .string_len(45)
62 + .null(),
63 + )
64 + .col(
65 + ColumnDef::new(Users::PasswordChangedAt)
66 + .timestamp_with_time_zone()
67 + .null(),
68 + )
69 + .col(
70 + ColumnDef::new(Users::CreatedAt)
71 + .timestamp_with_time_zone()
72 + .not_null(),
73 + )
74 + .col(
75 + ColumnDef::new(Users::UpdatedAt)
76 + .timestamp_with_time_zone()
77 + .not_null(),
78 + )
79 + .to_owned(),
80 + )
81 + .await?;
82 +
83 + manager
84 + .create_index(
85 + Index::create()
86 + .if_not_exists()
87 + .name("idx_users_email_verified")
88 + .table(Users::Table)
89 + .col(Users::EmailVerified)
90 + .to_owned(),
91 + )
92 + .await?;
93 +
94 + manager
95 + .create_index(
96 + Index::create()
97 + .if_not_exists()
98 + .name("idx_users_last_login_at")
99 + .table(Users::Table)
100 + .col(Users::LastLoginAt)
101 + .to_owned(),
102 + )
103 + .await?;
104 +
105 + manager
106 + .create_index(
107 + Index::create()
108 + .if_not_exists()
109 + .name("idx_users_password_changed_at")
110 + .table(Users::Table)
111 + .col(Users::PasswordChangedAt)
112 + .to_owned(),
113 + )
114 + .await?;
115 +
116 + // --- 2. roles ---
117 + manager
118 + .create_table(
119 + Table::create()
120 + .table(Roles::Table)
121 + .if_not_exists()
122 + .col(ColumnDef::new(Roles::Id).uuid().not_null().primary_key())
123 + .col(
124 + ColumnDef::new(Roles::Name)
125 + .string()
126 + .string_len(64)
127 + .not_null()
128 + .unique_key(),
129 + )
130 + .col(ColumnDef::new(Roles::Description).text().null())
131 + .col(
132 + ColumnDef::new(Roles::CreatedAt)
133 + .timestamp_with_time_zone()
134 + .not_null(),
135 + )
136 + .col(
137 + ColumnDef::new(Roles::UpdatedAt)
138 + .timestamp_with_time_zone()
139 + .not_null(),
140 + )
141 + .to_owned(),
142 + )
143 + .await?;
144 +
145 + // --- 3. user_roles ---
146 + manager
147 + .create_table(
148 + Table::create()
149 + .table(UserRoles::Table)
150 + .if_not_exists()
151 + .col(ColumnDef::new(UserRoles::UserId).uuid().not_null())
152 + .col(ColumnDef::new(UserRoles::RoleId).uuid().not_null())
153 + .col(
154 + ColumnDef::new(UserRoles::CreatedAt)
155 + .timestamp_with_time_zone()
156 + .not_null(),
157 + )
158 + .primary_key(
159 + Index::create()
160 + .col(UserRoles::UserId)
161 + .col(UserRoles::RoleId),
162 + )
163 + .foreign_key(
164 + ForeignKey::create()
165 + .name("fk_user_roles_user_id")
166 + .from(UserRoles::Table, UserRoles::UserId)
167 + .to(Users::Table, Users::Id)
168 + .on_delete(ForeignKeyAction::Cascade),
169 + )
170 + .foreign_key(
171 + ForeignKey::create()
172 + .name("fk_user_roles_role_id")
173 + .from(UserRoles::Table, UserRoles::RoleId)
174 + .to(Roles::Table, Roles::Id)
175 + .on_delete(ForeignKeyAction::Cascade),
176 + )
177 + .to_owned(),
178 + )
179 + .await?;
180 +
181 + // --- 4. refresh_token ---
182 + manager
183 + .create_table(
184 + Table::create()
185 + .table(RefreshToken::Table)
186 + .if_not_exists()
187 + .col(
188 + ColumnDef::new(RefreshToken::Id)
189 + .uuid()
190 + .not_null()
191 + .primary_key(),
192 + )
193 + .col(ColumnDef::new(RefreshToken::UserId).uuid().not_null())
194 + .col(
195 + ColumnDef::new(RefreshToken::Token)
196 + .string()
197 + .not_null()
198 + .unique_key(),
199 + )
200 + .col(
201 + ColumnDef::new(RefreshToken::ExpiresAt)
202 + .timestamp_with_time_zone()
203 + .not_null(),
204 + )
205 + .col(
206 + ColumnDef::new(RefreshToken::CreatedAt)
207 + .timestamp_with_time_zone()
208 + .not_null(),
209 + )
210 + .col(
211 + ColumnDef::new(RefreshToken::Revoked)
212 + .boolean()
213 + .not_null()
214 + .default(false),
215 + )
216 + .col(
217 + ColumnDef::new(RefreshToken::RevokedAt)
218 + .timestamp_with_time_zone()
219 + .null(),
220 + )
221 + .col(ColumnDef::new(RefreshToken::ReplacedBy).uuid().null())
222 + .foreign_key(
223 + ForeignKey::create()
224 + .name("fk_refresh_token_user_id")
225 + .from(RefreshToken::Table, RefreshToken::UserId)
226 + .to(Users::Table, Users::Id)
227 + .on_delete(ForeignKeyAction::Cascade),
228 + )
229 + .foreign_key(
230 + ForeignKey::create()
231 + .name("fk_refresh_token_replaced_by")
232 + .from(RefreshToken::Table, RefreshToken::ReplacedBy)
233 + .to(RefreshToken::Table, RefreshToken::Id)
234 + .on_delete(ForeignKeyAction::SetNull),
235 + )
236 + .to_owned(),
237 + )
238 + .await?;
239 +
240 + manager
241 + .create_index(
242 + Index::create()
243 + .if_not_exists()
244 + .name("idx_refresh_token_user_id")
245 + .table(RefreshToken::Table)
246 + .col(RefreshToken::UserId)
247 + .to_owned(),
248 + )
249 + .await?;
250 +
251 + manager
252 + .create_index(
253 + Index::create()
254 + .if_not_exists()
255 + .name("idx_refresh_token_token")
256 + .table(RefreshToken::Table)
257 + .col(RefreshToken::Token)
258 + .to_owned(),
259 + )
260 + .await?;
261 +
262 + manager
263 + .create_index(
264 + Index::create()
265 + .if_not_exists()
266 + .name("idx_refresh_token_expires_at")
267 + .table(RefreshToken::Table)
268 + .col(RefreshToken::ExpiresAt)
269 + .to_owned(),
270 + )
271 + .await?;
272 +
273 + // --- 5. jwt_blacklist ---
274 + manager
275 + .create_table(
276 + Table::create()
277 + .table(JwtBlacklist::Table)
278 + .if_not_exists()
279 + .col(
280 + ColumnDef::new(JwtBlacklist::Id)
281 + .uuid()
282 + .not_null()
283 + .primary_key(),
284 + )
285 + .col(
286 + ColumnDef::new(JwtBlacklist::TokenHash)
287 + .string()
288 + .not_null()
289 + .unique_key(),
290 + )
291 + .col(ColumnDef::new(JwtBlacklist::UserId).uuid().not_null())
292 + .col(
293 + ColumnDef::new(JwtBlacklist::ExpiresAt)
294 + .timestamp_with_time_zone()
295 + .not_null(),
296 + )
297 + .col(
298 + ColumnDef::new(JwtBlacklist::RevokedAt)
299 + .timestamp_with_time_zone()
300 + .not_null(),
301 + )
302 + .col(
303 + ColumnDef::new(JwtBlacklist::CreatedAt)
304 + .timestamp_with_time_zone()
305 + .not_null(),
306 + )
307 + .foreign_key(
308 + ForeignKey::create()
309 + .name("fk_jwt_blacklist_user_id")
310 + .from(JwtBlacklist::Table, JwtBlacklist::UserId)
311 + .to(Users::Table, Users::Id)
312 + .on_delete(ForeignKeyAction::Cascade),
313 + )
314 + .to_owned(),
315 + )
316 + .await?;
317 +
318 + manager
319 + .create_index(
320 + Index::create()
321 + .if_not_exists()
322 + .name("idx_jwt_blacklist_token_hash")
323 + .table(JwtBlacklist::Table)
324 + .col(JwtBlacklist::TokenHash)
325 + .unique()
326 + .to_owned(),
327 + )
328 + .await?;
329 +
330 + manager
331 + .create_index(
332 + Index::create()
333 + .if_not_exists()
334 + .name("idx_jwt_blacklist_expires_at")
335 + .table(JwtBlacklist::Table)
336 + .col(JwtBlacklist::ExpiresAt)
337 + .to_owned(),
338 + )
339 + .await?;
340 +
341 + manager
342 + .create_index(
343 + Index::create()
344 + .if_not_exists()
345 + .name("idx_jwt_blacklist_user_id")
346 + .table(JwtBlacklist::Table)
347 + .col(JwtBlacklist::UserId)
348 + .to_owned(),
349 + )
350 + .await?;
351 +
352 + // --- 6. user_preferences ---
353 + manager
354 + .create_table(
355 + Table::create()
356 + .table(UserPreferences::Table)
357 + .if_not_exists()
358 + .col(
359 + ColumnDef::new(UserPreferences::Id)
360 + .uuid()
361 + .not_null()
362 + .primary_key()
363 + .default(Expr::cust("gen_random_uuid()")),
364 + )
365 + .col(
366 + ColumnDef::new(UserPreferences::UserId)
367 + .uuid()
368 + .not_null()
369 + .unique_key(),
370 + )
371 + .col(
372 + ColumnDef::new(UserPreferences::Theme)
373 + .string()
374 + .string_len(10)
375 + .not_null()
376 + .default("light"),
377 + )
378 + .col(
379 + ColumnDef::new(UserPreferences::Language)
380 + .string()
381 + .string_len(5)
382 + .not_null()
383 + .default("fr"),
384 + )
385 + .col(
386 + ColumnDef::new(UserPreferences::CreatedAt)
387 + .timestamp_with_time_zone()
388 + .not_null()
389 + .default(Expr::cust("NOW()")),
390 + )
391 + .col(
392 + ColumnDef::new(UserPreferences::UpdatedAt)
393 + .timestamp_with_time_zone()
394 + .not_null()
395 + .default(Expr::cust("NOW()")),
396 + )
397 + .foreign_key(
398 + ForeignKey::create()
399 + .name("fk_user_preferences_user_id")
400 + .from(UserPreferences::Table, UserPreferences::UserId)
401 + .to(Users::Table, Users::Id)
402 + .on_delete(ForeignKeyAction::Cascade),
403 + )
404 + .to_owned(),
405 + )
406 + .await?;
407 +
408 + manager
409 + .get_connection()
410 + .execute_unprepared(
411 + "ALTER TABLE user_preferences ADD CONSTRAINT check_theme CHECK (theme IN ('light', 'dark', 'auto'))",
412 + )
413 + .await?;
414 +
415 + manager
416 + .get_connection()
417 + .execute_unprepared(
418 + "ALTER TABLE user_preferences ADD CONSTRAINT check_language CHECK (language IN ('fr', 'en'))",
419 + )
420 + .await?;
421 +
422 + manager
423 + .create_index(
424 + Index::create()
425 + .if_not_exists()
426 + .name("idx_user_preferences_user_id")
427 + .table(UserPreferences::Table)
428 + .col(UserPreferences::UserId)
429 + .to_owned(),
430 + )
431 + .await?;
432 +
433 + // --- 7. password_reset_tokens ---
434 + manager
435 + .create_table(
436 + Table::create()
437 + .table(PasswordResetTokens::Table)
438 + .if_not_exists()
439 + .col(
440 + ColumnDef::new(PasswordResetTokens::Id)
441 + .uuid()
442 + .not_null()
443 + .primary_key(),
444 + )
445 + .col(
446 + ColumnDef::new(PasswordResetTokens::UserId)
447 + .uuid()
448 + .not_null(),
449 + )
450 + .col(
451 + ColumnDef::new(PasswordResetTokens::TokenHash)
452 + .string_len(64)
453 + .not_null()
454 + .unique_key(),
455 + )
456 + .col(
457 + ColumnDef::new(PasswordResetTokens::ExpiresAt)
458 + .timestamp_with_time_zone()
459 + .not_null(),
460 + )
461 + .col(
462 + ColumnDef::new(PasswordResetTokens::UsedAt)
463 + .timestamp_with_time_zone()
464 + .null(),
465 + )
466 + .col(
467 + ColumnDef::new(PasswordResetTokens::CreatedAt)
468 + .timestamp_with_time_zone()
469 + .not_null(),
470 + )
471 + .foreign_key(
472 + ForeignKey::create()
473 + .name("fk_password_reset_tokens_user_id")
474 + .from(PasswordResetTokens::Table, PasswordResetTokens::UserId)
475 + .to(Users::Table, Users::Id)
476 + .on_delete(ForeignKeyAction::Cascade),
477 + )
478 + .to_owned(),
479 + )
480 + .await?;
481 +
482 + manager
483 + .create_index(
484 + Index::create()
485 + .if_not_exists()
486 + .name("idx_password_reset_tokens_user_id")
487 + .table(PasswordResetTokens::Table)
488 + .col(PasswordResetTokens::UserId)
489 + .to_owned(),
490 + )
491 + .await?;
492 +
493 + manager
494 + .create_index(
495 + Index::create()
496 + .if_not_exists()
497 + .name("idx_password_reset_tokens_token_hash")
498 + .table(PasswordResetTokens::Table)
499 + .col(PasswordResetTokens::TokenHash)
500 + .unique()
501 + .to_owned(),
502 + )
503 + .await?;
504 +
505 + manager
506 + .create_index(
507 + Index::create()
508 + .if_not_exists()
509 + .name("idx_password_reset_tokens_expires_at")
510 + .table(PasswordResetTokens::Table)
511 + .col(PasswordResetTokens::ExpiresAt)
512 + .to_owned(),
513 + )
514 + .await?;
515 +
516 + // --- 8. email_validation_tokens ---
517 + manager
518 + .create_table(
519 + Table::create()
520 + .table(EmailValidationTokens::Table)
521 + .if_not_exists()
522 + .col(
523 + ColumnDef::new(EmailValidationTokens::Id)
524 + .uuid()
525 + .not_null()
526 + .primary_key(),
527 + )
528 + .col(
529 + ColumnDef::new(EmailValidationTokens::UserId)
530 + .uuid()
531 + .not_null(),
532 + )
533 + .col(
534 + ColumnDef::new(EmailValidationTokens::TokenHash)
535 + .string_len(64)
536 + .not_null()
537 + .unique_key(),
538 + )
539 + .col(
540 + ColumnDef::new(EmailValidationTokens::ExpiresAt)
541 + .timestamp_with_time_zone()
542 + .not_null(),
543 + )
544 + .col(
545 + ColumnDef::new(EmailValidationTokens::UsedAt)
546 + .timestamp_with_time_zone()
547 + .null(),
548 + )
549 + .col(
550 + ColumnDef::new(EmailValidationTokens::CreatedAt)
551 + .timestamp_with_time_zone()
552 + .not_null(),
553 + )
554 + .foreign_key(
555 + ForeignKey::create()
556 + .name("fk_email_validation_tokens_user_id")
557 + .from(
558 + EmailValidationTokens::Table,
559 + EmailValidationTokens::UserId,
560 + )
561 + .to(Users::Table, Users::Id)
562 + .on_delete(ForeignKeyAction::Cascade),
563 + )
564 + .to_owned(),
565 + )
566 + .await?;
567 +
568 + manager
569 + .create_index(
570 + Index::create()
571 + .if_not_exists()
572 + .name("idx_email_validation_tokens_user_id")
573 + .table(EmailValidationTokens::Table)
574 + .col(EmailValidationTokens::UserId)
575 + .to_owned(),
576 + )
577 + .await?;
578 +
579 + manager
580 + .create_index(
581 + Index::create()
582 + .if_not_exists()
583 + .name("idx_email_validation_tokens_token_hash")
584 + .table(EmailValidationTokens::Table)
585 + .col(EmailValidationTokens::TokenHash)
586 + .unique()
587 + .to_owned(),
588 + )
589 + .await?;
590 +
591 + manager
592 + .create_index(
593 + Index::create()
594 + .if_not_exists()
595 + .name("idx_email_validation_tokens_expires_at")
596 + .table(EmailValidationTokens::Table)
597 + .col(EmailValidationTokens::ExpiresAt)
598 + .to_owned(),
599 + )
600 + .await?;
601 +
602 + // --- 9. password_change_requests ---
603 + manager
604 + .create_table(
605 + Table::create()
606 + .table(PasswordChangeRequests::Table)
607 + .if_not_exists()
608 + .col(
609 + ColumnDef::new(PasswordChangeRequests::Id)
610 + .uuid()
611 + .not_null()
612 + .primary_key(),
613 + )
614 + .col(
615 + ColumnDef::new(PasswordChangeRequests::UserId)
616 + .uuid()
617 + .not_null(),
618 + )
619 + .col(
620 + ColumnDef::new(PasswordChangeRequests::NewPasswordHash)
621 + .text()
622 + .not_null(),
623 + )
624 + .col(
625 + ColumnDef::new(PasswordChangeRequests::ConfirmationToken)
626 + .string_len(64)
627 + .not_null()
628 + .unique_key(),
629 + )
630 + .col(
631 + ColumnDef::new(PasswordChangeRequests::ExpiresAt)
632 + .timestamp_with_time_zone()
633 + .not_null(),
634 + )
635 + .col(
636 + ColumnDef::new(PasswordChangeRequests::CreatedAt)
637 + .timestamp_with_time_zone()
638 + .not_null(),
639 + )
640 + .col(
641 + ColumnDef::new(PasswordChangeRequests::ConfirmedAt)
642 + .timestamp_with_time_zone()
643 + .null(),
644 + )
645 + .col(
646 + ColumnDef::new(PasswordChangeRequests::EmailSentAt)
647 + .timestamp_with_time_zone()
648 + .null(),
649 + )
650 + .col(
651 + ColumnDef::new(PasswordChangeRequests::RetryCount)
652 + .integer()
653 + .not_null()
654 + .default(0),
655 + )
656 + .foreign_key(
657 + ForeignKey::create()
658 + .name("fk_password_change_requests_user_id")
659 + .from(
660 + PasswordChangeRequests::Table,
661 + PasswordChangeRequests::UserId,
662 + )
663 + .to(Users::Table, Users::Id)
664 + .on_delete(ForeignKeyAction::Cascade),
665 + )
666 + .to_owned(),
667 + )
668 + .await?;
669 +
670 + manager
671 + .create_index(
672 + Index::create()
673 + .if_not_exists()
674 + .name("idx_password_change_requests_user_id")
675 + .table(PasswordChangeRequests::Table)
676 + .col(PasswordChangeRequests::UserId)
677 + .to_owned(),
678 + )
679 + .await?;
680 +
681 + manager
682 + .create_index(
683 + Index::create()
684 + .if_not_exists()
685 + .name("idx_password_change_requests_confirmation_token")
686 + .table(PasswordChangeRequests::Table)
687 + .col(PasswordChangeRequests::ConfirmationToken)
688 + .unique()
689 + .to_owned(),
690 + )
691 + .await?;
692 +
693 + manager
694 + .create_index(
695 + Index::create()
696 + .if_not_exists()
697 + .name("idx_password_change_requests_expires_at")
698 + .table(PasswordChangeRequests::Table)
699 + .col(PasswordChangeRequests::ExpiresAt)
700 + .to_owned(),
701 + )
702 + .await?;
703 +
704 + manager
705 + .get_connection()
706 + .execute_unprepared(
707 + r#"
708 + CREATE UNIQUE INDEX IF NOT EXISTS idx_password_change_requests_user_id_pending
709 + ON password_change_requests (user_id)
710 + WHERE confirmed_at IS NULL
711 + "#,
712 + )
713 + .await?;
714 +
715 + // --- 10. email_queue ---
716 + manager
717 + .create_table(
718 + Table::create()
719 + .table(EmailQueue::Table)
720 + .if_not_exists()
721 + .col(
722 + ColumnDef::new(EmailQueue::Id)
723 + .uuid()
724 + .not_null()
725 + .primary_key(),
726 + )
727 + .col(
728 + ColumnDef::new(EmailQueue::ToEmail)
729 + .string_len(255)
730 + .not_null(),
731 + )
732 + .col(ColumnDef::new(EmailQueue::ToName).string_len(255).null())
733 + .col(
734 + ColumnDef::new(EmailQueue::FromEmail)
735 + .string_len(255)
736 + .not_null(),
737 + )
738 + .col(ColumnDef::new(EmailQueue::FromName).string_len(255).null())
739 + .col(
740 + ColumnDef::new(EmailQueue::Subject)
741 + .string_len(500)
742 + .not_null(),
743 + )
744 + .col(ColumnDef::new(EmailQueue::BodyHtml).text().not_null())
745 + .col(ColumnDef::new(EmailQueue::BodyText).text().not_null())
746 + .col(
747 + ColumnDef::new(EmailQueue::EmailType)
748 + .string_len(50)
749 + .not_null(),
750 + )
751 + .col(
752 + ColumnDef::new(EmailQueue::Status)
753 + .string_len(20)
754 + .not_null()
755 + .default("pending"),
756 + )
757 + .col(
758 + ColumnDef::new(EmailQueue::Attempts)
759 + .integer()
760 + .not_null()
761 + .default(0),
762 + )
763 + .col(
764 + ColumnDef::new(EmailQueue::LastAttemptAt)
765 + .timestamp_with_time_zone()
766 + .null(),
767 + )
768 + .col(
769 + ColumnDef::new(EmailQueue::SentAt)
770 + .timestamp_with_time_zone()
771 + .null(),
772 + )
773 + .col(ColumnDef::new(EmailQueue::ErrorMessage).text().null())
774 + .col(
775 + ColumnDef::new(EmailQueue::SmtpCode)
776 + .string_len(10)
777 + .null(),
778 + )
779 + .col(
780 + ColumnDef::new(EmailQueue::SmtpResponse)
781 + .text()
782 + .null(),
783 + )
784 + .col(
785 + ColumnDef::new(EmailQueue::CreatedAt)
786 + .timestamp_with_time_zone()
787 + .not_null(),
788 + )
789 + .col(
790 + ColumnDef::new(EmailQueue::UpdatedAt)
791 + .timestamp_with_time_zone()
792 + .not_null(),
793 + )
794 + .to_owned(),
795 + )
796 + .await?;
797 +
798 + manager
799 + .create_index(
800 + Index::create()
801 + .if_not_exists()
802 + .name("idx_email_queue_status")
803 + .table(EmailQueue::Table)
804 + .col(EmailQueue::Status)
805 + .to_owned(),
806 + )
807 + .await?;
808 +
809 + manager
810 + .create_index(
811 + Index::create()
812 + .if_not_exists()
813 + .name("idx_email_queue_created_at")
814 + .table(EmailQueue::Table)
815 + .col(EmailQueue::CreatedAt)
816 + .to_owned(),
817 + )
818 + .await?;
819 +
820 + manager
821 + .create_index(
822 + Index::create()
823 + .if_not_exists()
824 + .name("idx_email_queue_email_type")
825 + .table(EmailQueue::Table)
826 + .col(EmailQueue::EmailType)
827 + .to_owned(),
828 + )
829 + .await?;
830 +
831 + manager
832 + .get_connection()
833 + .execute_unprepared(
834 + r#"
835 + ALTER TABLE email_queue
836 + ADD CONSTRAINT chk_email_queue_email_type
837 + CHECK (email_type IN (
838 + 'password_reset',
839 + 'email_validation',
840 + 'password_changed',
841 + 'account_modified',
842 + 'welcome',
843 + 'admin_new_user',
844 + 'notification'
845 + ))
846 + "#,
847 + )
848 + .await?;
849 +
850 + manager
851 + .get_connection()
852 + .execute_unprepared(
853 + r#"
854 + ALTER TABLE email_queue
855 + ADD CONSTRAINT chk_email_queue_status
856 + CHECK (status IN ('pending', 'sent', 'failed'))
857 + "#,
858 + )
859 + .await?;
860 +
861 + // --- 11. email_delivery_status ---
862 + manager
863 + .create_table(
864 + Table::create()
865 + .table(EmailDeliveryStatus::Table)
866 + .if_not_exists()
867 + .col(
868 + ColumnDef::new(EmailDeliveryStatus::Id)
869 + .uuid()
870 + .not_null()
871 + .primary_key(),
872 + )
873 + .col(
874 + ColumnDef::new(EmailDeliveryStatus::EmailType)
875 + .string_len(50)
876 + .not_null(),
877 + )
878 + .col(
879 + ColumnDef::new(EmailDeliveryStatus::Recipient)
880 + .string_len(255)
881 + .not_null(),
882 + )
883 + .col(
884 + ColumnDef::new(EmailDeliveryStatus::Template)
885 + .string_len(255)
886 + .not_null(),
887 + )
888 + .col(
889 + ColumnDef::new(EmailDeliveryStatus::State)
890 + .string_len(20)
891 + .not_null()
892 + .default("queued"),
893 + )
894 + .col(
895 + ColumnDef::new(EmailDeliveryStatus::SmtpCode)
896 + .string_len(10)
897 + .null(),
898 + )
899 + .col(
900 + ColumnDef::new(EmailDeliveryStatus::SmtpResponse)
901 + .text()
902 + .null(),
903 + )
904 + .col(
905 + ColumnDef::new(EmailDeliveryStatus::AttemptCount)
906 + .integer()
907 + .not_null()
908 + .default(0),
909 + )
910 + .col(
911 + ColumnDef::new(EmailDeliveryStatus::LastAttemptAt)
912 + .timestamp_with_time_zone()
913 + .null(),
914 + )
915 + .col(
916 + ColumnDef::new(EmailDeliveryStatus::LastStateAt)
917 + .timestamp_with_time_zone()
918 + .not_null(),
919 + )
920 + .col(
921 + ColumnDef::new(EmailDeliveryStatus::CorrelationId)
922 + .uuid()
923 + .null(),
924 + )
925 + .col(
926 + ColumnDef::new(EmailDeliveryStatus::CreatedAt)
927 + .timestamp_with_time_zone()
928 + .not_null(),
929 + )
930 + .col(
931 + ColumnDef::new(EmailDeliveryStatus::UpdatedAt)
932 + .timestamp_with_time_zone()
933 + .not_null(),
934 + )
935 + .to_owned(),
936 + )
937 + .await?;
938 +
939 + manager
940 + .create_index(
941 + Index::create()
942 + .if_not_exists()
943 + .name("idx_email_delivery_status_type")
944 + .table(EmailDeliveryStatus::Table)
945 + .col(EmailDeliveryStatus::EmailType)
946 + .to_owned(),
947 + )
948 + .await?;
949 +
950 + manager
951 + .create_index(
952 + Index::create()
953 + .if_not_exists()
954 + .name("idx_email_delivery_status_state")
955 + .table(EmailDeliveryStatus::Table)
956 + .col(EmailDeliveryStatus::State)
957 + .to_owned(),
958 + )
959 + .await?;
960 +
961 + manager
962 + .create_index(
963 + Index::create()
964 + .if_not_exists()
965 + .name("idx_email_delivery_status_recipient")
966 + .table(EmailDeliveryStatus::Table)
967 + .col(EmailDeliveryStatus::Recipient)
968 + .to_owned(),
969 + )
970 + .await?;
971 +
972 + manager
973 + .create_index(
974 + Index::create()
975 + .if_not_exists()
976 + .name("idx_email_delivery_status_last_state_at")
977 + .table(EmailDeliveryStatus::Table)
978 + .col(EmailDeliveryStatus::LastStateAt)
979 + .to_owned(),
980 + )
981 + .await?;
982 +
983 + // --- Section 12: audit_logs table ---
984 + manager
985 + .create_table(
986 + Table::create()
987 + .table(AuditLogs::Table)
988 + .if_not_exists()
989 + .col(ColumnDef::new(AuditLogs::Id).uuid().not_null().primary_key())
990 + .col(ColumnDef::new(AuditLogs::UserId).uuid())
991 + .col(ColumnDef::new(AuditLogs::Action).string_len(100).not_null())
992 + .col(ColumnDef::new(AuditLogs::TargetUserId).uuid())
993 + .col(ColumnDef::new(AuditLogs::Level).string_len(10).not_null().default("INFO"))
994 + .col(ColumnDef::new(AuditLogs::Message).text().not_null())
995 + .col(ColumnDef::new(AuditLogs::CreatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
996 + .foreign_key(
997 + ForeignKey::create()
998 + .name("fk_audit_logs_user")
999 + .from(AuditLogs::Table, AuditLogs::UserId)
1000 + .to(Users::Table, Users::Id)
1001 + .on_delete(ForeignKeyAction::SetNull),
1002 + )
1003 + .foreign_key(
1004 + ForeignKey::create()
1005 + .name("fk_audit_logs_target_user")
1006 + .from(AuditLogs::Table, AuditLogs::TargetUserId)
1007 + .to(Users::Table, Users::Id)
1008 + .on_delete(ForeignKeyAction::SetNull),
1009 + )
1010 + .to_owned(),
1011 + )
1012 + .await?;
1013 +
1014 + manager
1015 + .create_index(
1016 + Index::create()
1017 + .if_not_exists()
1018 + .name("idx_audit_logs_user_id")
1019 + .table(AuditLogs::Table)
1020 + .col(AuditLogs::UserId)
1021 + .to_owned(),
1022 + )
1023 + .await?;
1024 +
1025 + manager
1026 + .create_index(
1027 + Index::create()
1028 + .if_not_exists()
1029 + .name("idx_audit_logs_action")
1030 + .table(AuditLogs::Table)
1031 + .col(AuditLogs::Action)
1032 + .to_owned(),
1033 + )
1034 + .await?;
1035 +
1036 + manager
1037 + .create_index(
1038 + Index::create()
1039 + .if_not_exists()
1040 + .name("idx_audit_logs_created_at")
1041 + .table(AuditLogs::Table)
1042 + .col(AuditLogs::CreatedAt)
1043 + .to_owned(),
1044 + )
1045 + .await?;
1046 +
1047 + // --- Seed default roles ---
1048 + manager
1049 + .get_connection()
1050 + .execute_unprepared(
1051 + r#"
1052 + INSERT INTO roles (id, name, description, created_at, updated_at)
1053 + VALUES
1054 + (gen_random_uuid(), 'admin', 'Administrator role', NOW(), NOW()),
1055 + (gen_random_uuid(), 'user', 'Standard user role', NOW(), NOW())
1056 + ON CONFLICT (name) DO NOTHING
1057 + "#,
1058 + )
1059 + .await?;
1060 +
1061 + Ok(())
1062 + }
1063 +
1064 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
1065 + manager
1066 + .drop_table(Table::drop().table(AuditLogs::Table).to_owned())
1067 + .await?;
1068 +
1069 + manager
1070 + .drop_table(
1071 + Table::drop()
1072 + .table(EmailDeliveryStatus::Table)
1073 + .to_owned(),
1074 + )
1075 + .await?;
1076 +
1077 + manager
1078 + .drop_table(Table::drop().table(EmailQueue::Table).to_owned())
1079 + .await?;
1080 +
1081 + manager
1082 + .drop_table(
1083 + Table::drop()
1084 + .table(PasswordChangeRequests::Table)
1085 + .to_owned(),
1086 + )
1087 + .await?;
1088 +
1089 + manager
1090 + .drop_table(
1091 + Table::drop()
1092 + .table(EmailValidationTokens::Table)
1093 + .to_owned(),
1094 + )
1095 + .await?;
1096 +
1097 + manager
1098 + .drop_table(
1099 + Table::drop()
1100 + .table(PasswordResetTokens::Table)
1101 + .to_owned(),
1102 + )
1103 + .await?;
1104 +
1105 + manager
1106 + .drop_table(Table::drop().table(UserPreferences::Table).to_owned())
1107 + .await?;
1108 +
1109 + manager
1110 + .drop_table(Table::drop().table(JwtBlacklist::Table).to_owned())
1111 + .await?;
1112 +
1113 + manager
1114 + .drop_table(Table::drop().table(RefreshToken::Table).to_owned())
1115 + .await?;
1116 +
1117 + manager
1118 + .drop_table(Table::drop().table(UserRoles::Table).to_owned())
1119 + .await?;
1120 +
1121 + manager
1122 + .drop_table(Table::drop().table(Roles::Table).to_owned())
1123 + .await?;
1124 +
1125 + manager
1126 + .drop_table(Table::drop().table(Users::Table).to_owned())
1127 + .await
1128 + }
1129 +}
1130 +
1131 +#[derive(DeriveIden)]
1132 +enum Users {
1133 + Table,
1134 + Id,
1135 + Username,
1136 + Email,
1137 + PasswordHash,
1138 + MustChangePassword,
1139 + EmailVerified,
1140 + EmailVerifiedAt,
1141 + LastLoginAt,
1142 + LastLoginIp,
1143 + PasswordChangedAt,
1144 + CreatedAt,
1145 + UpdatedAt,
1146 +}
1147 +
1148 +#[derive(DeriveIden)]
1149 +enum Roles {
1150 + Table,
1151 + Id,
1152 + Name,
1153 + Description,
1154 + CreatedAt,
1155 + UpdatedAt,
1156 +}
1157 +
1158 +#[derive(DeriveIden)]
1159 +enum UserRoles {
1160 + Table,
1161 + UserId,
1162 + RoleId,
1163 + CreatedAt,
1164 +}
1165 +
1166 +#[derive(DeriveIden)]
1167 +enum RefreshToken {
1168 + Table,
1169 + Id,
1170 + UserId,
1171 + Token,
1172 + ExpiresAt,
1173 + CreatedAt,
1174 + Revoked,
1175 + RevokedAt,
1176 + ReplacedBy,
1177 +}
1178 +
1179 +#[derive(DeriveIden)]
1180 +enum JwtBlacklist {
1181 + Table,
1182 + Id,
1183 + TokenHash,
1184 + UserId,
1185 + ExpiresAt,
1186 + RevokedAt,
1187 + CreatedAt,
1188 +}
1189 +
1190 +#[derive(DeriveIden)]
1191 +enum UserPreferences {
1192 + Table,
1193 + Id,
1194 + UserId,
1195 + Theme,
1196 + Language,
1197 + CreatedAt,
1198 + UpdatedAt,
1199 +}
1200 +
1201 +#[derive(DeriveIden)]
1202 +enum PasswordResetTokens {
1203 + Table,
1204 + Id,
1205 + UserId,
1206 + TokenHash,
1207 + ExpiresAt,
1208 + UsedAt,
1209 + CreatedAt,
1210 +}
1211 +
1212 +#[derive(DeriveIden)]
1213 +enum EmailValidationTokens {
1214 + Table,
1215 + Id,
1216 + UserId,
1217 + TokenHash,
1218 + ExpiresAt,
1219 + UsedAt,
1220 + CreatedAt,
1221 +}
1222 +
1223 +#[derive(DeriveIden)]
1224 +enum PasswordChangeRequests {
1225 + Table,
1226 + Id,
1227 + UserId,
1228 + NewPasswordHash,
1229 + ConfirmationToken,
1230 + ExpiresAt,
1231 + CreatedAt,
1232 + ConfirmedAt,
1233 + EmailSentAt,
1234 + RetryCount,
1235 +}
1236 +
1237 +#[derive(DeriveIden)]
1238 +enum EmailQueue {
1239 + Table,
1240 + Id,
1241 + ToEmail,
1242 + ToName,
1243 + FromEmail,
1244 + FromName,
1245 + Subject,
1246 + BodyHtml,
1247 + BodyText,
1248 + EmailType,
1249 + Status,
1250 + Attempts,
1251 + LastAttemptAt,
1252 + SentAt,
1253 + ErrorMessage,
1254 + SmtpCode,
1255 + SmtpResponse,
1256 + CreatedAt,
1257 + UpdatedAt,
1258 +}
1259 +
1260 +#[derive(DeriveIden)]
1261 +enum EmailDeliveryStatus {
1262 + Table,
1263 + Id,
1264 + EmailType,
1265 + Recipient,
1266 + Template,
1267 + State,
1268 + SmtpCode,
1269 + SmtpResponse,
1270 + AttemptCount,
1271 + LastAttemptAt,
1272 + LastStateAt,
1273 + CorrelationId,
1274 + CreatedAt,
1275 + UpdatedAt,
1276 +}
1277 +
1278 +#[derive(DeriveIden)]
1279 +enum AuditLogs {
1280 + Table,
1281 + Id,
1282 + UserId,
1283 + Action,
1284 + TargetUserId,
1285 + Level,
1286 + Message,
1287 + CreatedAt,
1288 +}
A migrations_old/src/m20260306_000002_create_app_settings_table.rs
+82 -0
@@ -0,0 +1,82 @@
1 +use sea_orm_migration::prelude::*;
2 +
3 +#[derive(DeriveMigrationName)]
4 +pub struct Migration;
5 +
6 +#[async_trait::async_trait]
7 +impl MigrationTrait for Migration {
8 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
9 + manager
10 + .create_table(
11 + Table::create()
12 + .table(AppSettings::Table)
13 + .if_not_exists()
14 + .col(
15 + ColumnDef::new(AppSettings::Id)
16 + .uuid()
17 + .not_null()
18 + .primary_key(),
19 + )
20 + .col(
21 + ColumnDef::new(AppSettings::Key)
22 + .string()
23 + .string_len(100)
24 + .not_null()
25 + .unique_key(),
26 + )
27 + .col(ColumnDef::new(AppSettings::Value).text().not_null())
28 + .col(ColumnDef::new(AppSettings::Description).text().null())
29 + .col(ColumnDef::new(AppSettings::UpdatedBy).uuid().null())
30 + .col(
31 + ColumnDef::new(AppSettings::UpdatedAt)
32 + .timestamp_with_time_zone()
33 + .not_null(),
34 + )
35 + .foreign_key(
36 + ForeignKey::create()
37 + .name("fk_app_settings_updated_by")
38 + .from(AppSettings::Table, AppSettings::UpdatedBy)
39 + .to(Users::Table, Users::Id)
40 + .on_delete(ForeignKeyAction::SetNull),
41 + )
42 + .to_owned(),
43 + )
44 + .await?;
45 +
46 + manager
47 + .create_index(
48 + Index::create()
49 + .if_not_exists()
50 + .name("idx_app_settings_key")
51 + .table(AppSettings::Table)
52 + .col(AppSettings::Key)
53 + .to_owned(),
54 + )
55 + .await?;
56 +
57 + Ok(())
58 + }
59 +
60 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
61 + manager
62 + .drop_table(Table::drop().table(AppSettings::Table).to_owned())
63 + .await
64 + }
65 +}
66 +
67 +#[derive(DeriveIden)]
68 +enum AppSettings {
69 + Table,
70 + Id,
71 + Key,
72 + Value,
73 + Description,
74 + UpdatedBy,
75 + UpdatedAt,
76 +}
77 +
78 +#[derive(DeriveIden)]
79 +enum Users {
80 + Table,
81 + Id,
82 +}
A migrations_old/src/m20260309_000003_create_permissions_tables.rs
+179 -0
@@ -0,0 +1,179 @@
1 +use sea_orm_migration::prelude::*;
2 +
3 +#[derive(DeriveMigrationName)]
4 +pub struct Migration;
5 +
6 +#[async_trait::async_trait]
7 +impl MigrationTrait for Migration {
8 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
9 + // --- 1. permissions table ---
10 + manager
11 + .create_table(
12 + Table::create()
13 + .table(Permissions::Table)
14 + .if_not_exists()
15 + .col(
16 + ColumnDef::new(Permissions::Id)
17 + .uuid()
18 + .not_null()
19 + .primary_key(),
20 + )
21 + .col(
22 + ColumnDef::new(Permissions::Name)
23 + .string()
24 + .string_len(100)
25 + .not_null()
26 + .unique_key(),
27 + )
28 + .col(
29 + ColumnDef::new(Permissions::Description)
30 + .text()
31 + .null(),
32 + )
33 + .col(
34 + ColumnDef::new(Permissions::Category)
35 + .string()
36 + .string_len(50)
37 + .not_null(),
38 + )
39 + .col(
40 + ColumnDef::new(Permissions::CreatedAt)
41 + .timestamp_with_time_zone()
42 + .not_null(),
43 + )
44 + .to_owned(),
45 + )
46 + .await?;
47 +
48 + // --- 2. role_permissions junction table ---
49 + manager
50 + .create_table(
51 + Table::create()
52 + .table(RolePermissions::Table)
53 + .if_not_exists()
54 + .col(ColumnDef::new(RolePermissions::RoleId).uuid().not_null())
55 + .col(
56 + ColumnDef::new(RolePermissions::PermissionId)
57 + .uuid()
58 + .not_null(),
59 + )
60 + .primary_key(
61 + Index::create()
62 + .col(RolePermissions::RoleId)
63 + .col(RolePermissions::PermissionId),
64 + )
65 + .foreign_key(
66 + ForeignKey::create()
67 + .name("fk_role_permissions_role_id")
68 + .from(RolePermissions::Table, RolePermissions::RoleId)
69 + .to(Roles::Table, Roles::Id)
70 + .on_delete(ForeignKeyAction::Cascade),
71 + )
72 + .foreign_key(
73 + ForeignKey::create()
74 + .name("fk_role_permissions_permission_id")
75 + .from(RolePermissions::Table, RolePermissions::PermissionId)
76 + .to(Permissions::Table, Permissions::Id)
77 + .on_delete(ForeignKeyAction::Cascade),
78 + )
79 + .to_owned(),
80 + )
81 + .await?;
82 +
83 + // --- 3. Indexes ---
84 + manager
85 + .create_index(
86 + Index::create()
87 + .if_not_exists()
88 + .name("idx_permissions_category")
89 + .table(Permissions::Table)
90 + .col(Permissions::Category)
91 + .to_owned(),
92 + )
93 + .await?;
94 +
95 + manager
96 + .create_index(
97 + Index::create()
98 + .if_not_exists()
99 + .name("idx_role_permissions_role_id")
100 + .table(RolePermissions::Table)
101 + .col(RolePermissions::RoleId)
102 + .to_owned(),
103 + )
104 + .await?;
105 +
106 + // --- 4. Seed default permissions ---
107 + manager
108 + .get_connection()
109 + .execute_unprepared(
110 + r#"
111 + INSERT INTO permissions (id, name, description, category, created_at) VALUES
112 + (gen_random_uuid(), 'users.list', 'View the list of users', 'users', NOW()),
113 + (gen_random_uuid(), 'users.create', 'Create new users', 'users', NOW()),
114 + (gen_random_uuid(), 'users.edit', 'Edit existing users', 'users', NOW()),
115 + (gen_random_uuid(), 'users.delete', 'Delete users', 'users', NOW()),
116 + (gen_random_uuid(), 'users.reset_password', 'Reset user passwords', 'users', NOW()),
117 + (gen_random_uuid(), 'roles.list', 'View the list of roles', 'roles', NOW()),
118 + (gen_random_uuid(), 'roles.create', 'Create new roles', 'roles', NOW()),
119 + (gen_random_uuid(), 'roles.edit', 'Edit existing roles', 'roles', NOW()),
120 + (gen_random_uuid(), 'roles.delete', 'Delete roles', 'roles', NOW()),
121 + (gen_random_uuid(), 'settings.view', 'View application settings', 'settings', NOW()),
122 + (gen_random_uuid(), 'settings.edit', 'Edit application settings', 'settings', NOW()),
123 + (gen_random_uuid(), 'audit.view', 'View audit logs', 'audit', NOW())
124 + ON CONFLICT (name) DO NOTHING
125 + "#,
126 + )
127 + .await?;
128 +
129 + // --- 5. Assign all permissions to the admin role ---
130 + manager
131 + .get_connection()
132 + .execute_unprepared(
133 + r#"
134 + INSERT INTO role_permissions (role_id, permission_id)
135 + SELECT r.id, p.id
136 + FROM roles r
137 + CROSS JOIN permissions p
138 + WHERE r.name = 'admin'
139 + ON CONFLICT DO NOTHING
140 + "#,
141 + )
142 + .await?;
143 +
144 + Ok(())
145 + }
146 +
147 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
148 + manager
149 + .drop_table(Table::drop().table(RolePermissions::Table).to_owned())
150 + .await?;
151 + manager
152 + .drop_table(Table::drop().table(Permissions::Table).to_owned())
153 + .await?;
154 + Ok(())
155 + }
156 +}
157 +
158 +#[derive(DeriveIden)]
159 +enum Permissions {
160 + Table,
161 + Id,
162 + Name,
163 + Description,
164 + Category,
165 + CreatedAt,
166 +}
167 +
168 +#[derive(DeriveIden)]
169 +enum RolePermissions {
170 + Table,
171 + RoleId,
172 + PermissionId,
173 +}
174 +
175 +#[derive(DeriveIden)]
176 +enum Roles {
177 + Table,
178 + Id,
179 +}
A migrations_old/src/main.rs
+9 -0
@@ -0,0 +1,9 @@
1 +use sea_orm_migration::prelude::*;
2 +
3 +#[path = "lib.rs"]
4 +mod migrations;
5 +
6 +#[tokio::main]
7 +async fn main() {
8 + cli::run_cli(migrations::Migrator).await;
9 +}
D templates/admin/dashboard.html
+0 -191
@@ -1,191 +0,0 @@
1 -{% extends "base.html" %}
2 -
3 -{% block title %}{{ page_title }}{% endblock %}
4 -
5 -{% block content %}
6 -<div class="container mx-auto px-4 py-4">
7 - <!-- Breadcrumb -->
8 - <div class="text-sm breadcrumbs mb-3">
9 - <ul>
10 - <li><a href="/">{{ breadcrumb_home }}</a></li>
11 - <li>{{ breadcrumb_admin }}</li>
12 - </ul>
13 - </div>
14 -
15 - <h1 class="text-2xl font-bold mb-4">{{ heading }}</h1>
16 -
17 - <!-- Statistics Cards -->
18 - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
19 - <!-- Total Users Card -->
20 - <div class="card bg-base-100 shadow-md">
21 - <div class="card-body p-4">
22 - <h2 class="card-title text-primary text-base">👥 {{ users_card_title }}</h2>
23 - <p class="text-xs text-base-content/70 mb-1">{{ users_card_subtitle }}</p>
24 - <p class="text-2xl font-bold">{{ total_users }}</p>
25 - <div class="card-actions justify-end mt-2">
26 - <a href="/admin/users" class="btn btn-primary btn-sm">{{ access_button }}</a>
27 - </div>
28 - </div>
29 - </div>
30 -
31 - <!-- Active Sessions Card -->
32 - <div class="card bg-base-100 shadow-md">
33 - <div class="card-body p-4">
34 - <h2 class="card-title text-secondary text-base">🔐 {{ sessions_card_title }}</h2>
35 - <p class="text-xs text-base-content/70 mb-1">{{ sessions_card_subtitle }}</p>
36 - <p class="text-2xl font-bold">{{ active_sessions }}</p>
37 - </div>
38 - </div>
39 -
40 - <!-- Admins Card -->
41 - <div class="card bg-base-100 shadow-md">
42 - <div class="card-body p-4">
43 - <h2 class="card-title text-accent text-base">👑 {{ admins_card_title }}</h2>
44 - <p class="text-xs text-base-content/70 mb-1">{{ admins_card_subtitle }}</p>
45 - <p class="text-2xl font-bold">{{ total_admins }}</p>
46 - </div>
47 - </div>
48 -
49 - <!-- Last Activity Card -->
50 - <div class="card bg-base-100 shadow-md">
51 - <div class="card-body p-4">
52 - <h2 class="card-title text-info text-base">⏰ {{ last_activity_card_title }}</h2>
53 - <p class="text-xs text-base-content/70 mb-1">{{ last_activity_card_subtitle }}</p>
54 - <p class="text-base font-semibold">{{ last_activity }}</p>
55 - </div>
56 - </div>
57 - </div>
58 -
59 - <!-- Audit Log Statistics (if available) -->
60 - {% if has_audit_logs %}
61 - <div class="card bg-base-100 shadow-md mb-4">
62 - <div class="card-body p-4">
63 - <h2 class="card-title text-lg mb-3">📊 {{ audit_section_title }}</h2>
64 - <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
65 - <!-- Histogram Chart -->
66 - <div>
67 - <canvas id="audit-logs-chart" width="400" height="200"></canvas>
68 - </div>
69 - <!-- Statistics and Legends -->
70 - <div class="space-y-3">
71 - <div class="grid grid-cols-2 gap-3">
72 - <div>
73 - <p class="text-xs text-base-content/70">{{ audit_total_logs_label }}</p>
74 - <p class="text-xl font-bold">{{ audit_total_logs }}</p>
75 - </div>
76 - <div>
77 - <p class="text-xs text-base-content/70">{{ audit_last_24h }}</p>
78 - <p class="text-xl font-bold">{{ audit_logs_last_24h }}</p>
79 - </div>
80 - </div>
81 - <div>
82 - <p class="text-xs text-base-content/70 mb-2">{{ audit_by_level }}</p>
83 - <div class="flex flex-wrap gap-2">
84 - <span class="badge badge-info badge-sm">DEBUG: {{ audit_debug_count }}</span>
85 - <span class="badge badge-success badge-sm">INFO: {{ audit_info_count }}</span>
86 - <span class="badge badge-warning badge-sm">WARN: {{ audit_warn_count }}</span>
87 - <span class="badge badge-error badge-sm">ERROR: {{ audit_error_count }}</span>
88 - </div>
89 - </div>
90 - </div>
91 - </div>
92 - </div>
93 - </div>
94 - {% endif %}
95 -
96 - <!-- Navigation Links -->
97 - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
98 - <a href="/admin/roles" class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
99 - <div class="card-body p-4">
100 - <h2 class="card-title text-warning text-base">🛡️ {{ roles_card_title }}</h2>
101 - <p class="text-sm">{{ roles_card_desc }}</p>
102 - <div class="stats shadow-sm mt-2">
103 - <div class="stat py-1 px-2">
104 - <div class="stat-title text-xs">{{ roles_stat_title }}</div>
105 - <div class="stat-value text-base">{{ total_roles }}</div>
106 - </div>
107 - </div>
108 - <div class="card-actions justify-end mt-2">
109 - <button class="btn btn-warning btn-sm">{{ access_button }}</button>
110 - </div>
111 - </div>
112 - </a>
113 -
114 - <a href="/admin/settings" class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
115 - <div class="card-body p-4">
116 - <h2 class="card-title text-secondary text-base">⚙️ {{ settings_card_title }}</h2>
117 - <p class="text-sm">{{ settings_card_desc }}</p>
118 - <div class="card-actions justify-end mt-2">
119 - <button class="btn btn-secondary btn-sm">{{ access_button }}</button>
120 - </div>
121 - </div>
122 - </a>
123 -
124 - <a href="/admin/apps" class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
125 - <div class="card-body p-4">
126 - <h2 class="card-title text-accent text-base">📦 {{ apps_card_title }}</h2>
127 - <p class="text-sm">{{ apps_card_desc }}</p>
128 - <div class="card-actions justify-end mt-2">
129 - <button class="btn btn-accent btn-sm">{{ access_button }}</button>
130 - </div>
131 - </div>
132 - </a>
133 -
134 - </div>
135 -</div>
136 -{% endblock %}
137 -
138 -{% block scripts %}
139 -{% if has_audit_logs %}
140 -<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
141 -<script>
142 -document.addEventListener('DOMContentLoaded', function() {
143 - const ctx = document.getElementById('audit-logs-chart');
144 - if (ctx) {
145 - new Chart(ctx, {
146 - type: 'bar',
147 - data: {
148 - labels: ['DEBUG', 'INFO', 'WARN', 'ERROR'],
149 - datasets: [{
150 - label: '{{ chart_label }}',
151 - data: [
152 - {{ audit_debug_count }},
153 - {{ audit_info_count }},
154 - {{ audit_warn_count }},
155 - {{ audit_error_count }}
156 - ],
157 - backgroundColor: [
158 - 'rgba(59, 130, 246, 0.5)', // info
159 - 'rgba(34, 197, 94, 0.5)', // success
160 - 'rgba(251, 191, 36, 0.5)', // warning
161 - 'rgba(239, 68, 68, 0.5)' // error
162 - ],
163 - borderColor: [
164 - 'rgba(59, 130, 246, 1)',
165 - 'rgba(34, 197, 94, 1)',
166 - 'rgba(251, 191, 36, 1)',
167 - 'rgba(239, 68, 68, 1)'
168 - ],
169 - borderWidth: 1
170 - }]
171 - },
172 - options: {
173 - responsive: true,
174 - maintainAspectRatio: true,
175 - scales: {
176 - y: {
177 - beginAtZero: true
178 - }
179 - },
180 - plugins: {
181 - legend: {
182 - display: false
183 - }
184 - }
185 - }
186 - });
187 - }
188 -});
189 -</script>
190 -{% endif %}
191 -{% endblock %}
D templates/admin/errors.html
+0 -62
@@ -1,62 +0,0 @@
1 -{% extends "base.html" %}
2 -
3 -{% block title %}{{ page_title }}{% endblock %}
4 -
5 -{% block content %}
6 -<div class="container mx-auto px-4 py-4">
7 - <!-- Breadcrumb -->
8 - <div class="text-sm breadcrumbs mb-3">
9 - <ul>
10 - <li><a href="/">{{ breadcrumb_home }}</a></li>
11 - <li><a href="/admin">{{ breadcrumb_admin }}</a></li>
12 - <li>{{ breadcrumb_errors }}</li>
13 - </ul>
14 - </div>
15 -
16 - <h1 class="text-2xl font-bold mb-4">{{ title }}</h1>
17 - <p class="text-base-content/70 mb-6">{{ description }}</p>
18 -
19 - <!-- Error Codes Table -->
20 - <div class="overflow-x-auto">
21 - <table class="table table-zebra w-full">
22 - <thead>
23 - <tr>
24 - <th>{{ table_code }}</th>
25 - <th>{{ table_description }}</th>
26 - <th>{{ table_category }}</th>
27 - <th>{{ table_middleware }}</th>
28 - <th>{{ table_context }}</th>
29 - <th>{{ table_example }}</th>
30 - </tr>
31 - </thead>
32 - <tbody>
33 - {% for error in error_codes %}
34 - <tr>
35 - <td><code class="badge badge-error">{{ error.code }}</code></td>
36 - <td>{{ error.description }}</td>
37 - <td><span class="badge badge-outline">{{ error.category }}</span></td>
38 - <td><code class="text-xs">{{ error.middleware }}</code></td>
39 - <td class="text-sm text-base-content/70">{{ error.context }}</td>
40 - <td class="text-sm text-base-content/70">{{ error.example }}</td>
41 - </tr>
42 - {% endfor %}
43 - </tbody>
44 - </table>
45 - </div>
46 -
47 - <!-- Legend -->
48 - <div class="mt-6 card bg-base-200">
49 - <div class="card-body">
50 - <h2 class="card-title text-lg">{{ legend_title }}</h2>
51 - <ul class="list-disc list-inside space-y-2 text-sm">
52 - <li><strong>{{ table_code }}</strong> : {{ legend_code }}</li>
53 - <li><strong>{{ table_description }}</strong> : {{ legend_description }}</li>
54 - <li><strong>{{ table_category }}</strong> : {{ legend_category }}</li>
55 - <li><strong>{{ table_middleware }}</strong> : {{ legend_middleware }}</li>
56 - <li><strong>{{ table_context }}</strong> : {{ legend_context }}</li>
57 - <li><strong>{{ table_example }}</strong> : {{ legend_example }}</li>
58 - </ul>
59 - </div>
60 - </div>
61 -</div>
62 -{% endblock %}
D templates/admin/role_form.html
+0 -135
@@ -1,135 +0,0 @@
1 -{% extends "base.html" %}
2 -
3 -{% block title %}{{ page_title }}{% endblock %}
4 -
5 -{% block content %}
6 -<div class="container mx-auto px-4 py-8">
7 - <!-- Breadcrumb -->
8 - <div class="text-sm breadcrumbs mb-6">
9 - <ul>
10 - <li><a href="/">{{ breadcrumb_home }}</a></li>
11 - <li><a href="/admin">{{ breadcrumb_admin }}</a></li>
12 - <li><a href="/admin/roles">{{ breadcrumb_roles }}</a></li>
13 - <li>{{ breadcrumb_mode }}</li>
14 - </ul>
15 - </div>
16 -
17 - <div class="flex items-center gap-4 mb-6">
18 - <a href="/admin/roles" class="btn btn-ghost btn-sm">
19 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
20 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
21 - </svg>
22 - {{ back_to_list }}
23 - </a>
24 - <h1 class="text-3xl font-bold">{{ form_title }}</h1>
25 - </div>
26 -
27 - <div class="card bg-base-100 shadow-xl max-w-3xl">
28 - <div class="card-body">
29 - <form id="roleForm"
30 - {% if mode == "create" %}
31 - hx-post="/admin/roles"
32 - {% else %}
33 - action="/admin/roles/{{ role_id }}"
34 - method="POST"
35 - {% endif %}
36 - hx-target="#form-message"
37 - hx-swap="innerHTML">
38 - {% if mode == "edit" %}
39 - <input type="hidden" name="_method" value="PUT">
40 - {% endif %}
41 - <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
42 -
43 - <!-- Name -->
44 - <div class="form-control mb-4">
45 - <label class="label">
46 - <span class="label-text font-semibold">{{ name_label }}</span>
47 - </label>
48 - {% if is_protected %}
49 - <input type="text" name="name" value="{{ form_name }}" class="input input-bordered" readonly>
50 - <label class="label">
51 - <span class="label-text-alt text-warning">{{ protected_name_hint }}</span>
52 - </label>
53 - {% else %}
54 - <input type="text" name="name" value="{{ form_name }}" class="input input-bordered"
55 - placeholder="{{ name_placeholder }}" required>
56 - {% endif %}
57 - </div>
58 -
59 - <!-- Description -->
60 - <div class="form-control mb-4">
61 - <label class="label">
62 - <span class="label-text font-semibold">{{ description_label }}</span>
63 - </label>
64 - <textarea name="description" class="textarea textarea-bordered"
65 - placeholder="{{ description_placeholder }}" rows="3">{{ form_description }}</textarea>
66 - </div>
67 -
68 - <!-- Permissions grouped by category -->
69 - <div class="form-control mb-6">
70 - <label class="label">
71 - <span class="label-text font-semibold">{{ permissions_label }}</span>
72 - </label>
73 -
74 - {% for group in permission_groups %}
75 - <div class="collapse collapse-arrow bg-base-200 mb-2">
76 - <input type="checkbox" checked>
77 - <div class="collapse-title font-medium capitalize">
78 - {{ group.category }}
79 - <span class="badge badge-sm badge-ghost ml-2">{{ group.permissions.len() }}</span>
80 - </div>
81 - <div class="collapse-content">
82 - <div class="grid grid-cols-1 md:grid-cols-2 gap-2 pt-2">
83 - {% for perm in group.permissions %}
84 - <label class="flex items-start gap-3 cursor-pointer p-2 rounded hover:bg-base-300 transition-colors">
85 - <input type="checkbox" name="permissions" value="{{ perm.id }}"
86 - class="checkbox checkbox-sm checkbox-primary mt-0.5"
87 - {% if perm.is_selected %}checked{% endif %}>
88 - <div>
89 - <div class="text-sm font-mono">{{ perm.name }}</div>
90 - {% if !perm.description.is_empty() %}
91 - <div class="text-xs text-base-content/60">{{ perm.description }}</div>
92 - {% endif %}
93 - </div>
94 - </label>
95 - {% endfor %}
96 - </div>
97 - </div>
98 - </div>
99 - {% endfor %}
100 - </div>
101 -
102 - <!-- Message area -->
103 - <div id="form-message" class="mb-4"></div>
104 -
105 - <!-- Actions -->
106 - <div class="flex justify-end gap-4">
107 - <a href="/admin/roles" class="btn btn-ghost">{{ cancel }}</a>
108 - <button type="submit" class="btn btn-primary">{{ submit_label }}</button>
109 - </div>
110 - </form>
111 - </div>
112 - </div>
113 -</div>
114 -
115 -{% if mode == "edit" %}
116 -<script>
117 -document.getElementById('roleForm').addEventListener('submit', function(e) {
118 - e.preventDefault();
119 - const form = e.target;
120 - const formData = new FormData(form);
121 - fetch(form.action, {
122 - method: 'PUT',
123 - headers: {
124 - 'X-CSRF-Token': '{{ csrf_token }}',
125 - },
126 - body: new URLSearchParams(formData),
127 - }).then(function(resp) {
128 - if (resp.redirected) {
129 - window.location.href = resp.url;
130 - }
131 - });
132 -});
133 -</script>
134 -{% endif %}
135 -{% endblock %}
D templates/admin/roles_list.html
+0 -132
@@ -1,132 +0,0 @@
1 -{% extends "base.html" %}
2 -
3 -{% block title %}{{ page_title }}{% endblock %}
4 -
5 -{% block content %}
6 -<div class="container mx-auto px-4 py-8">
7 - <!-- Breadcrumb -->
8 - <div class="text-sm breadcrumbs mb-6">
9 - <ul>
10 - <li><a href="/">{{ breadcrumb_home }}</a></li>
11 - <li><a href="/admin">{{ breadcrumb_admin }}</a></li>
12 - <li>{{ breadcrumb_roles }}</li>
13 - </ul>
14 - </div>
15 -
16 - <div class="flex justify-between items-center mb-6">
17 - <h1 class="text-3xl font-bold">{{ title }}</h1>
18 - <a href="/admin/roles/create" class="btn btn-primary">
19 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
20 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
21 - </svg>
22 - {{ create_role }}
23 - </a>
24 - </div>
25 -
26 - <!-- Roles Table -->
27 - <div class="card bg-base-100 shadow-xl">
28 - <div class="card-body p-0">
29 - {% if roles.is_empty() %}
30 - <div class="p-8 text-center text-base-content/60">
31 - <svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-4 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
32 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
33 - </svg>
34 - <p>{{ no_roles }}</p>
35 - </div>
36 - {% else %}
37 - <div class="overflow-x-auto">
38 - <table class="table">
39 - <thead>
40 - <tr>
41 - <th>{{ table_name }}</th>
42 - <th>{{ table_description }}</th>
43 - <th class="text-center">{{ table_users }}</th>
44 - <th class="text-center">{{ table_permissions }}</th>
45 - <th class="text-right">{{ table_actions }}</th>
46 - </tr>
47 - </thead>
48 - <tbody>
49 - {% for role in roles %}
50 - <tr>
51 - <td>
52 - <div class="font-bold">{{ role.name }}</div>
53 - {% if role.is_protected %}
54 - <span class="badge badge-ghost badge-sm">{{ protected_hint }}</span>
55 - {% endif %}
56 - </td>
57 - <td>{{ role.description }}</td>
58 - <td class="text-center">
59 - <span class="badge badge-outline">{{ role.user_count }}</span>
60 - </td>
61 - <td class="text-center">
62 - <span class="badge badge-outline">{{ role.permission_count }}</span>
63 - </td>
64 - <td class="text-right">
65 - <div class="flex justify-end gap-2">
66 - <a href="/admin/roles/{{ role.id }}" class="btn btn-ghost btn-sm">
67 - <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
68 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
69 - </svg>
70 - {{ edit }}
71 - </a>
72 - {% if !role.is_protected %}
73 - <button class="btn btn-ghost btn-sm text-error"
74 - onclick="confirmDeleteRole('{{ role.id }}', '{{ role.name }}')">
75 - <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
76 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
77 - </svg>
78 - {{ delete_label }}
79 - </button>
80 - {% endif %}
81 - </div>
82 - </td>
83 - </tr>
84 - {% endfor %}
85 - </tbody>
86 - </table>
87 - </div>
88 - {% endif %}
89 - </div>
90 - </div>
91 -</div>
92 -
93 -<!-- Delete Confirmation Modal -->
94 -<dialog id="deleteRoleModal" class="modal">
95 - <div class="modal-box">
96 - <h3 class="text-lg font-bold">{{ delete_confirm_title }}</h3>
97 - <p class="py-4">{{ delete_confirm_message }} <strong id="deleteRoleName"></strong>?</p>
98 - <div class="modal-action">
99 - <form method="dialog">
100 - <button class="btn">{{ cancel }}</button>
101 - </form>
102 - <button id="confirmDeleteBtn" class="btn btn-error">{{ delete_button }}</button>
103 - </div>
104 - </div>
105 - <form method="dialog" class="modal-backdrop">
106 - <button>close</button>
107 - </form>
108 -</dialog>
109 -
110 -<script>
111 -let deleteRoleId = null;
112 -
113 -function confirmDeleteRole(id, name) {
114 - deleteRoleId = id;
115 - document.getElementById('deleteRoleName').textContent = name;
116 - document.getElementById('deleteRoleModal').showModal();
117 -}
118 -
119 -document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
120 - if (!deleteRoleId) return;
121 - fetch('/admin/roles/' + deleteRoleId, {
122 - method: 'DELETE',
123 - headers: {
124 - 'X-CSRF-Token': '{{ csrf_token }}',
125 - }
126 - }).then(function() {
127 - window.location.reload();
128 - });
129 - document.getElementById('deleteRoleModal').close();
130 -});
131 -</script>
132 -{% endblock %}
D templates/admin/settings.html
+0 -401
@@ -1,401 +0,0 @@
1 -{% extends "base.html" %}
2 -
3 -{% block title %}{{ page_title }}{% endblock %}
4 -
5 -{% block content %}
6 -<div class="container mx-auto px-4 py-8">
7 - <!-- Breadcrumb -->
8 - <div class="text-sm breadcrumbs mb-6">
9 - <ul>
10 - <li><a href="/">{{ breadcrumb_home }}</a></li>
11 - <li><a href="/admin">{{ breadcrumb_admin }}</a></li>
12 - <li>{{ breadcrumb_settings }}</li>
13 - </ul>
14 - </div>
15 -
16 - <!-- Page Header -->
17 - <div class="mb-8">
18 - <h1 class="text-3xl font-bold mb-2">⚙️ {{ title }}</h1>
19 - <p class="text-base-content/70">{{ subtitle }}</p>
20 - </div>
21 -
22 - <form id="settingsForm" method="POST" action="/admin/settings" class="space-y-6 max-w-6xl">
23 - <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
24 -
25 - <!-- General Settings Card -->
26 - <div class="card bg-base-100 shadow-xl">
27 - <div class="card-body">
28 - <h2 class="card-title text-xl mb-4">
29 - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
30 - {{ general }}
31 - </h2>
32 -
33 - <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
34 - <!-- App Name -->
35 - <div class="form-control">
36 - <label class="label">
37 - <span class="label-text font-semibold">{{ app_name_label }}</span>
38 - </label>
39 - <input
40 - type="text"
41 - name="app_name"
42 - value="{{ settings.app_name }}"
43 - class="input input-bordered w-full"
44 - >
45 - </div>
46 -
47 - <!-- App Domain -->
48 - <div class="form-control">
49 - <label class="label">
50 - <span class="label-text font-semibold">{{ app_domain_label }}</span>
51 - </label>
52 - <input
53 - type="text"
54 - name="app_domain"
55 - value="{{ settings.app_domain }}"
56 - class="input input-bordered w-full"
57 - placeholder="{{ app_domain_placeholder }}"
58 - >
59 - </div>
60 -
61 - <!-- App Description -->
62 - <div class="form-control md:col-span-2">
63 - <label class="label">
64 - <span class="label-text font-semibold">{{ app_description_label }}</span>
65 - </label>
66 - <textarea
67 - name="app_description"
68 - class="textarea textarea-bordered w-full"
69 - rows="2"
70 - >{{ settings.app_description }}</textarea>
71 - </div>
72 -
73 - <!-- Allow Registration -->
74 - <div class="form-control">
75 - <label class="label cursor-pointer justify-start gap-4">
76 - <input
77 - type="checkbox"
78 - name="allow_registration"
79 - value="true"
80 - class="checkbox checkbox-primary"
81 - {% if settings.allow_registration == "true" %}checked{% endif %}
82 - >
83 - <div>
84 - <span class="label-text font-semibold">{{ allow_registration_label }}</span>
85 - <span class="label-text-alt block">{{ allow_registration_desc }}</span>
86 - </div>
87 - </label>
88 - </div>
89 -
90 - <!-- Validation email requise -->
91 - <div class="form-control">
92 - <label class="label cursor-pointer justify-start gap-4">
93 - <input
94 - type="checkbox"
95 - name="validation_email_requise"
96 - value="true"
97 - class="checkbox checkbox-primary"
98 - {% if settings.validation_email_requise == "true" %}checked{% endif %}
99 - >
100 - <div>
101 - <span class="label-text font-semibold">{{ email_validation_required_label }}</span>
102 - <span class="label-text-alt block">{{ email_validation_required_desc }}</span>
103 - </div>
104 - </label>
105 - </div>
106 - </div>
107 - </div>
108 - </div>
109 -
110 - <!-- Password Security Card -->
111 - <div class="card bg-base-100 shadow-xl">
112 - <div class="card-body">
113 - <h2 class="card-title text-xl mb-4">
114 - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
115 - {{ password_security }}
116 - </h2>
117 -
118 - <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
119 - <!-- Min Length -->
120 - <div class="form-control">
121 - <label class="label">
122 - <span class="label-text font-semibold">{{ password_min_length }}</span>
123 - </label>
124 - <input
125 - type="number"
126 - name="password_min_length"
127 - value="{{ settings.password_min_length }}"
128 - class="input input-bordered w-full"
129 - min="6"
130 - max="128"
131 - >
132 - <label class="label">
133 - <span class="label-text-alt">{{ password_min_length_hint }}</span>
134 - </label>
135 - </div>
136 -
137 - <!-- Complexity Requirements -->
138 - <div class="space-y-2">
139 - <label class="label">
140 - <span class="label-text font-semibold">{{ password_complexity }}</span>
141 - </label>
142 - <div class="form-control">
143 - <label class="label cursor-pointer justify-start gap-3 py-1">
144 - <input
145 - type="checkbox"
146 - name="password_require_uppercase"
147 - value="true"
148 - class="checkbox checkbox-sm"
149 - {% if settings.password_require_uppercase == "true" %}checked{% endif %}
150 - >
151 - <span class="label-text">{{ require_uppercase }}</span>
152 - </label>
153 - </div>
154 - <div class="form-control">
155 - <label class="label cursor-pointer justify-start gap-3 py-1">
156 - <input
157 - type="checkbox"
158 - name="password_require_lowercase"
159 - value="true"
160 - class="checkbox checkbox-sm"
161 - {% if settings.password_require_lowercase == "true" %}checked{% endif %}
162 - >
163 - <span class="label-text">{{ require_lowercase }}</span>
164 - </label>
165 - </div>
166 - <div class="form-control">
167 - <label class="label cursor-pointer justify-start gap-3 py-1">
168 - <input
169 - type="checkbox"
170 - name="password_require_digits"
171 - value="true"
172 - class="checkbox checkbox-sm"
173 - {% if settings.password_require_digits == "true" %}checked{% endif %}
174 - >
175 - <span class="label-text">{{ require_digits }}</span>
176 - </label>
177 - </div>
178 - <div class="form-control">
179 - <label class="label cursor-pointer justify-start gap-3 py-1">
180 - <input
181 - type="checkbox"
182 - name="password_require_special"
183 - value="true"
184 - class="checkbox checkbox-sm"
185 - {% if settings.password_require_special == "true" %}checked{% endif %}
186 - >
187 - <span class="label-text">{{ require_special }}</span>
188 - </label>
189 - </div>
190 - </div>
191 -
192 - <!-- Password change email confirmation -->
193 - <div class="form-control mt-4">
194 - <label class="label cursor-pointer justify-start gap-4">
195 - <input
196 - type="checkbox"
197 - name="password_change_require_email"
198 - value="true"
199 - class="toggle toggle-primary"
200 - {% if settings.password_change_require_email == "true" %}checked{% endif %}
201 - >
202 - <div>
203 - <span class="label-text font-medium">{{ password_change_require_email }}</span>
204 - <p class="text-xs text-base-content/60 mt-0.5">{{ password_change_require_email_desc }}</p>
205 - </div>
206 - </label>
207 - </div>
208 -
209 - <!-- Password Expiration -->
210 - <div class="form-control">
211 - <label class="label cursor-pointer justify-start gap-4">
212 - <input
213 - type="checkbox"
214 - name="password_expiration_enabled"
215 - value="true"
216 - class="checkbox checkbox-primary"
217 - id="expirationEnabled"
218 - {% if settings.password_expiration_enabled == "true" %}checked{% endif %}
219 - >
220 - <div>
221 - <span class="label-text font-semibold">{{ password_expiration }}</span>
222 - <span class="label-text-alt block">{{ password_expiration_desc }}</span>
223 - </div>
224 - </label>
225 - </div>
226 -
227 - <div id="expirationSettings" class="space-y-3">
228 - <div class="form-control">
229 - <label class="label">
230 - <span class="label-text">{{ expiration_days }}</span>
231 - </label>
232 - <input
233 - type="number"
234 - name="password_expiration_days"
235 - value="{{ settings.password_expiration_days }}"
236 - class="input input-bordered input-sm w-full"
237 - min="0"
238 - max="365"
239 - >
240 - </div>
241 -
242 - <div class="form-control">
243 - <label class="label cursor-pointer justify-start gap-3">
244 - <input
245 - type="checkbox"
246 - name="password_expiration_alert_enabled"
247 - value="true"
248 - class="checkbox checkbox-sm"
249 - {% if settings.password_expiration_alert_enabled == "true" %}checked{% endif %}
250 - >
251 - <span class="label-text">{{ expiration_alert }}</span>
252 - </label>
253 - </div>
254 -
255 - <div class="form-control">
256 - <label class="label">
257 - <span class="label-text">{{ expiration_alert_days }}</span>
258 - </label>
259 - <input
260 - type="number"
261 - name="password_expiration_alert_days"
262 - value="{{ settings.password_expiration_alert_days_before }}"
263 - class="input input-bordered input-sm w-full"
264 - min="0"
265 - max="30"
266 - >
267 - </div>
268 - </div>
269 - </div>
270 - </div>
271 - </div>
272 -
273 - <!-- Audit Logging Card -->
274 - <div class="card bg-base-100 shadow-xl">
275 - <div class="card-body">
276 - <h2 class="card-title text-xl mb-4">
277 - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
278 - {{ audit_logging }}
279 - </h2>
280 -
281 - <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
282 - <!-- Log Level -->
283 - <div class="form-control">
284 - <label class="label">
285 - <span class="label-text font-semibold">{{ log_level }}</span>
286 - </label>
287 - <select
288 - name="audit_log_level"
289 - class="select select-bordered w-full"
290 - >
291 - <option value="DEBUG" {% if settings.audit_log_level == "DEBUG" %}selected{% endif %}>{{ log_level_debug }}</option>
292 - <option value="INFO" {% if settings.audit_log_level == "INFO" %}selected{% endif %}>{{ log_level_info }}</option>
293 - <option value="WARN" {% if settings.audit_log_level == "WARN" %}selected{% endif %}>{{ log_level_warn }}</option>
294 - <option value="ERROR" {% if settings.audit_log_level == "ERROR" %}selected{% endif %}>{{ log_level_error }}</option>
295 - </select>
296 - <label class="label">
297 - <span class="label-text-alt">{{ log_level_hint }}</span>
298 - </label>
299 - </div>
300 -
301 - <!-- Retention -->
302 - <div class="form-control">
303 - <label class="label">
304 - <span class="label-text font-semibold">{{ log_retention }}</span>
305 - </label>
306 - <input
307 - type="number"
308 - name="audit_log_retention_days"
309 - value="{{ settings.audit_log_retention_days }}"
310 - class="input input-bordered w-full"
311 - min="1"
312 - max="365"
313 - >
314 - <label class="label">
315 - <span class="label-text-alt">{{ log_retention_hint }}</span>
316 - </label>
317 - </div>
318 - </div>
319 -
320 - <div class="alert alert-info mt-4">
321 - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
322 - <span class="text-sm">{{ audit_info_message }}</span>
323 - </div>
324 - </div>
325 - </div>
326 -
327 - <!-- Action Buttons -->
328 - <div class="card bg-base-200 shadow-xl">
329 - <div class="card-body">
330 - <div class="flex justify-between items-center">
331 - <div>
332 - <h3 class="font-semibold">{{ save_changes }}</h3>
333 - <p class="text-sm text-base-content/70">{{ save_changes_desc }}</p>
334 - </div>
335 - <div class="flex gap-2">
336 - <a href="/admin" class="btn btn-ghost">{{ cancel }}</a>
337 - <button type="submit" class="btn btn-primary">
338 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
339 - {{ save_button }}
340 - </button>
341 - </div>
342 - </div>
343 - </div>
344 - </div>
345 - </form>
346 -</div>
347 -
348 -<script>
349 -const expirationCheckbox = document.getElementById('expirationEnabled');
350 -const expirationSettings = document.getElementById('expirationSettings');
351 -
352 -function toggleExpirationSettings() {
353 - if (expirationCheckbox.checked) {
354 - expirationSettings.classList.remove('hidden');
355 - } else {
356 - expirationSettings.classList.add('hidden');
357 - }
358 -}
359 -
360 -if (expirationCheckbox) {
361 - expirationCheckbox.addEventListener('change', toggleExpirationSettings);
362 - toggleExpirationSettings();
363 -}
364 -
365 -document.getElementById('settingsForm').addEventListener('submit', async function(e) {
366 - e.preventDefault();
367 -
368 - const formData = new FormData(this);
369 - const submitBtn = this.querySelector('button[type="submit"]');
370 -
371 - submitBtn.disabled = true;
372 - submitBtn.innerHTML = '<span class="loading loading-spinner"></span> {{ saving }}';
373 -
374 - try {
375 - const response = await fetch('/admin/settings', {
376 - method: 'PUT',
377 - headers: {
378 - 'Content-Type': 'application/x-www-form-urlencoded',
379 - },
380 - body: new URLSearchParams(formData)
381 - });
382 -
383 - const result = await response.json();
384 -
385 - if (response.ok) {
386 - alert('✅ {{ save_success }}');
387 - window.location.reload();
388 - } else {
389 - alert('❌ {{ save_error_prefix }} : ' + (result.error || result.message || ''));
390 - submitBtn.disabled = false;
391 - submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> {{ save_button }}';
392 - }
393 - } catch (error) {
394 - console.error('Error:', error);
395 - alert('❌ {{ network_error }}');
396 - submitBtn.disabled = false;
397 - submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> {{ save_button }}';
398 - }
399 -});
400 -</script>
401 -{% endblock %}
D templates/admin/user_form.html
+0 -183
@@ -1,183 +0,0 @@
1 -{% extends "base.html" %}
2 -
3 -{% block title %}{{ page_title }}{% endblock %}
4 -
5 -{% block content %}
6 -<div class="container mx-auto px-4 py-8">
7 - <!-- Breadcrumb -->
8 - <div class="text-sm breadcrumbs mb-6">
9 - <ul>
10 - <li><a href="/">{{ breadcrumb_home }}</a></li>
11 - <li><a href="/admin">{{ breadcrumb_admin }}</a></li>
12 - <li><a href="/admin/users">{{ breadcrumb_users }}</a></li>
13 - <li>{{ breadcrumb_mode }}</li>
14 - </ul>
15 - </div>
16 -
17 - <div class="flex justify-between items-center mb-6">
18 - <h1 class="text-3xl font-bold">{{ form_title }}</h1>
19 - <a href="/admin/users" class="btn btn-ghost">
20 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
21 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
22 - </svg>
23 - {{ back_to_list }}
24 - </a>
25 - </div>
26 -
27 - <!-- Messages d'erreur/succès -->
28 - <div id="form-messages" class="mb-6"></div>
29 -
30 - <!-- User Form -->
31 - <div class="card bg-base-100 shadow-xl">
32 - <div class="card-body">
33 - {% if mode == "create" %}
34 - <form
35 - method="POST"
36 - action="/admin/users"
37 - class="space-y-6"
38 - hx-post="/admin/users"
39 - hx-target="#form-messages"
40 - hx-swap="innerHTML"
41 - hx-indicator="#form-loading"
42 - >
43 - {% else %}
44 - <form
45 - method="PUT"
46 - action="/admin/users/{{ user_id }}"
47 - class="space-y-6"
48 - hx-put="/admin/users/{{ user_id }}"
49 - hx-target="#form-messages"
50 - hx-swap="innerHTML"
51 - hx-indicator="#form-loading"
52 - >
53 - {% endif %}
54 - <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
55 -
56 - <!-- Username Field -->
57 - <div class="form-control">
58 - <label class="label">
59 - <span class="label-text font-semibold">{{ username_label }}</span>
60 - <span class="label-text-alt text-error">*</span>
61 - </label>
62 - {% if mode == "create" %}
63 - <input
64 - type="text"
65 - name="username"
66 - value="{{ form_username }}"
67 - placeholder="{{ username_placeholder }}"
68 - class="input input-bordered w-full"
69 - required
70 - >
71 - {% else %}
72 - <input
73 - type="text"
74 - value="{{ form_username }}"
75 - class="input input-bordered w-full input-disabled"
76 - readonly
77 - >
78 - <input type="hidden" name="username" value="{{ form_username }}">
79 - {% endif %}
80 - <label class="label">
81 - <span class="label-text-alt">{{ username_readonly_hint }}</span>
82 - </label>
83 - </div>
84 -
85 - <!-- Email Field -->
86 - <div class="form-control">
87 - <label class="label">
88 - <span class="label-text font-semibold">{{ email_label }}</span>
89 - <span class="label-text-alt text-error">*</span>
90 - </label>
91 - <input
92 - type="email"
93 - name="email"
94 - value="{{ form_email }}"
95 - placeholder="{{ email_placeholder }}"
96 - class="input input-bordered w-full"
97 - required
98 - >
99 - </div>
100 -
101 - <!-- Password Field (only for create mode or if resetting) -->
102 - {% if mode == "create" %}
103 - <div class="form-control">
104 - <label class="label">
105 - <span class="label-text font-semibold">{{ password_label }}</span>
106 - <span class="label-text-alt">{{ password_optional }}</span>
107 - </label>
108 - <input
109 - type="password"
110 - name="password"
111 - placeholder="{{ password_placeholder }}"
112 - class="input input-bordered w-full"
113 - >
114 - <label class="label">
115 - <span class="label-text-alt">{{ password_hint }}</span>
116 - </label>
117 - </div>
118 - {% endif %}
119 -
120 - <!-- Roles Field -->
121 - <div class="form-control">
122 - <label class="label">
123 - <span class="label-text font-semibold">{{ roles_label }}</span>
124 - <span class="label-text-alt text-error">*</span>
125 - </label>
126 - <div class="space-y-2">
127 - {% for role in available_roles %}
128 - <label class="label cursor-pointer justify-start gap-3">
129 - <input
130 - type="checkbox"
131 - name="roles"
132 - value="{{ role.name }}"
133 - class="checkbox checkbox-primary"
134 - {% if role.is_selected %}checked{% endif %}
135 - >
136 - <div class="flex-1">
137 - <span class="label-text font-medium">{{ role.name }}</span>
138 - {% if role.description.len() > 0 %}
139 - <p class="text-sm text-base-content/70">{{ role.description }}</p>
140 - {% endif %}
141 - </div>
142 - </label>
143 - {% endfor %}
144 - </div>
145 - <label class="label">
146 - <span class="label-text-alt">{{ roles_hint }}</span>
147 - </label>
148 - </div>
149 -
150 - <!-- Must Change Password (only for edit mode) -->
151 - {% if mode == "edit" %}
152 - <div class="form-control">
153 - <label class="label cursor-pointer justify-start gap-3">
154 - <input
155 - type="checkbox"
156 - name="must_change_password"
157 - value="true"
158 - class="checkbox checkbox-warning"
159 - {% if must_change_password %}checked{% endif %}
160 - >
161 - <div class="flex-1">
162 - <span class="label-text font-medium">{{ must_change_password_label }}</span>
163 - <p class="text-sm text-base-content/70">{{ must_change_password_desc }}</p>
164 - </div>
165 - </label>
166 - </div>
167 - {% endif %}
168 -
169 - <!-- Form Actions -->
170 - <div class="flex justify-end gap-4 pt-4 border-t">
171 - <a href="/admin/users" class="btn btn-ghost">{{ cancel }}</a>
172 - <button type="submit" class="btn btn-primary" id="submit-btn">
173 - <span id="form-loading" class="htmx-indicator loading loading-spinner loading-sm mr-2"></span>
174 - {{ submit_label }}
175 - </button>
176 - </div>
177 - </form>
178 - {% if mode != "create" %}
179 - {% endif %}
180 - </div>
181 - </div>
182 -</div>
183 -{% endblock %}
D templates/admin/users_list.html
+0 -509
@@ -1,509 +0,0 @@
1 -{% extends "base.html" %}
2 -
3 -{% block title %}{{ page_title }}{% endblock %}
4 -
5 -{% block content %}
6 -<div class="container mx-auto px-4 py-8">
7 - <!-- Breadcrumb -->
8 - <div class="text-sm breadcrumbs mb-6">
9 - <ul>
10 - <li><a href="/">{{ breadcrumb_home }}</a></li>
11 - <li><a href="/admin">{{ breadcrumb_admin }}</a></li>
12 - <li>{{ breadcrumb_users }}</li>
13 - </ul>
14 - </div>
15 -
16 - <div class="flex justify-between items-center mb-6">
17 - <h1 class="text-3xl font-bold">{{ title }}</h1>
18 - <a href="/admin/users/create" class="btn btn-primary">
19 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
20 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
21 - </svg>
22 - {{ create_user }}
23 - </a>
24 - </div>
25 -
26 - <!-- Search and Filter Form -->
27 - <div class="card bg-base-100 shadow-xl mb-6">
28 - <div class="card-body">
29 - <form method="GET" action="/admin/users" class="grid grid-cols-1 md:grid-cols-4 gap-4">
30 - <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
31 -
32 - <!-- Search Input -->
33 - <div class="form-control">
34 - <label class="label">
35 - <span class="label-text">{{ search_label }}</span>
36 - </label>
37 - <input
38 - type="text"
39 - name="search"
40 - placeholder="{{ search_placeholder }}"
41 - class="input input-bordered"
42 - value="{{ current_search }}"
43 - >
44 - </div>
45 -
46 - <!-- Role Filter -->
47 - <div class="form-control">
48 - <label class="label">
49 - <span class="label-text">{{ role_filter_label }}</span>
50 - </label>
51 - <select name="role_filter" class="select select-bordered">
52 - <option value="">{{ all_roles }}</option>
53 - <option value="admin" {% if is_admin_filter %}selected{% endif %}>Admin</option>
54 - <option value="user" {% if is_user_filter %}selected{% endif %}>User</option>
55 - </select>
56 - </div>
57 -
58 - <!-- Per Page -->
59 - <div class="form-control">
60 - <label class="label">
61 - <span class="label-text">{{ per_page_label }}</span>
62 - </label>
63 - <select name="per_page" class="select select-bordered">
64 - <option value="25" {% if per_page == 25 %}selected{% endif %}>25</option>
65 - <option value="50" {% if per_page == 50 %}selected{% endif %}>50</option>
66 - <option value="100" {% if per_page == 100 %}selected{% endif %}>100</option>
67 - </select>
68 - </div>
69 -
70 - <!-- Submit Button -->
71 - <div class="form-control">
72 - <label class="label">
73 - <span class="label-text">&nbsp;</span>
74 - </label>
75 - <button type="submit" class="btn btn-primary">{{ search_button }}</button>
76 - </div>
77 - </form>
78 - </div>
79 - </div>
80 -
81 - <!-- Results Summary -->
82 - <div class="mb-4">
83 - <p class="text-sm text-base-content/70">
84 - {{ results_summary }}
85 - {% if total_pages > 1 %}
86 - - {{ page_info }}
87 - {% endif %}
88 - </p>
89 - </div>
90 -
91 - <!-- Users Table -->
92 - <div class="card bg-base-100 shadow-xl">
93 - <div class="card-body">
94 - {% if users.is_empty() %}
95 - <div class="text-center py-8">
96 - <p class="text-lg text-base-content/70">{{ no_users }}</p>
97 - </div>
98 - {% else %}
99 - <div class="overflow-x-auto">
100 - <table class="table table-zebra w-full">
101 - <thead>
102 - <tr>
103 - <th>{{ table_username }}</th>
104 - <th>{{ table_email }}</th>
105 - <th>{{ table_roles }}</th>
106 - <th>{{ table_created_at }}</th>
107 - <th>{{ table_last_login }}</th>
108 - <th>{{ table_actions }}</th>
109 - </tr>
110 - </thead>
111 - <tbody>
112 - {% for user in users %}
113 - <tr>
114 - <td>
115 - <div class="font-bold">{{ user.username }}</div>
116 - </td>
117 - <td>{{ user.email }}</td>
118 - <td>
119 - <div class="flex flex-wrap gap-1">
120 - {% for role in user.roles %}
121 - <span class="badge {% if role.is_admin %}badge-error{% else %}badge-info{% endif %}">
122 - {{ role.name }}
123 - </span>
124 - {% endfor %}
125 - </div>
126 - </td>
127 - <td>
128 - <div class="text-sm">{{ user.created_at }}</div>
129 - </td>
130 - <td>
131 - {% if user.has_last_login %}
132 - <div class="text-sm">{{ user.last_login_at }}</div>
133 - {% if user.has_last_login_ip %}
134 - <div class="text-xs text-base-content/50">{{ user.last_login_ip }}</div>
135 - {% endif %}
136 - {% else %}
137 - <span class="text-sm text-base-content/50">{{ never }}</span>
138 - {% endif %}
139 - </td>
140 - <td>
141 - <div class="flex gap-2">
142 - <a href="/admin/users/{{ user.id }}" class="btn btn-sm btn-ghost">
143 - {{ edit }}
144 - </a>
145 - <button
146 - type="button"
147 - class="btn btn-sm btn-warning"
148 - onclick="openResetPasswordModal('{{ user.id }}', '{{ user.username }}')"
149 - title="{{ reset_password_title }}"
150 - >
151 - <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
152 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
153 - </svg>
154 - {{ reset_password }}
155 - </button>
156 - <button
157 - type="button"
158 - class="btn btn-sm btn-error"
159 - onclick="openDeleteUserModal('{{ user.id }}', '{{ user.username }}', '{{ user.email }}')"
160 - >
161 - <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
162 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
163 - </svg>
164 - {{ delete }}
165 - </button>
166 - </div>
167 - </td>
168 - </tr>
169 - {% endfor %}
170 - </tbody>
171 - </table>
172 - </div>
173 - {% endif %}
174 - </div>
175 - </div>
176 -
177 - <!-- Pagination -->
178 - {% if total_pages > 1 %}
179 - <div class="flex justify-center mt-6">
180 - <div class="join">
181 - {% if page > 1 %}
182 - <a href="?page={{ page - 1 }}&per_page={{ per_page }}{% if has_search %}&search={{ current_search }}{% endif %}{% if has_role_filter %}&role_filter={{ current_role_filter }}{% endif %}"
183 - class="join-item btn">«</a>
184 - {% else %}
185 - <button class="join-item btn btn-disabled">«</button>
186 - {% endif %}
187 -
188 - {% for p in 1..=total_pages %}
189 - {% if p == page %}
190 - <button class="join-item btn btn-active">{{ p }}</button>
191 - {% elif p == 1 || p == total_pages %}
192 - <a href="?page={{ p }}&per_page={{ per_page }}{% if has_search %}&search={{ current_search }}{% endif %}{% if has_role_filter %}&role_filter={{ current_role_filter }}{% endif %}"
193 - class="join-item btn">{{ p }}</a>
194 - {% elif p >= page - 2 && p <= page + 2 %}
195 - <a href="?page={{ p }}&per_page={{ per_page }}{% if has_search %}&search={{ current_search }}{% endif %}{% if has_role_filter %}&role_filter={{ current_role_filter }}{% endif %}"
196 - class="join-item btn">{{ p }}</a>
197 - {% elif p == page - 3 || p == page + 3 %}
198 - <button class="join-item btn btn-disabled">...</button>
199 - {% endif %}
200 - {% endfor %}
201 -
202 - {% if page < total_pages %}
203 - <a href="?page={{ page + 1 }}&per_page={{ per_page }}{% if has_search %}&search={{ current_search }}{% endif %}{% if has_role_filter %}&role_filter={{ current_role_filter }}{% endif %}"
204 - class="join-item btn">»</a>
205 - {% else %}
206 - <button class="join-item btn btn-disabled">»</button>
207 - {% endif %}
208 - </div>
209 - </div>
210 - {% endif %}
211 -</div>
212 -
213 -<!-- Modal Reset Password (T105) -->
214 -<dialog id="resetPasswordModal" class="modal">
215 - <div class="modal-box">
216 - <h3 class="font-bold text-lg mb-4">{{ reset_password_title }}</h3>
217 - <form id="resetPasswordForm" method="POST" class="space-y-4">
218 - <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
219 - <input type="hidden" id="resetUserId" name="user_id" value="">
220 -
221 - <!-- Reset Option -->
222 - <div class="form-control">
223 - <label class="label cursor-pointer">
224 - <span class="label-text">{{ reset_generate }}</span>
225 - <input type="radio" name="reset_type" value="generate" class="radio radio-primary" checked>
226 - </label>
227 - </div>
228 -
229 - <div class="form-control">
230 - <label class="label cursor-pointer">
231 - <span class="label-text">{{ reset_custom }}</span>
232 - <input type="radio" name="reset_type" value="custom" class="radio radio-primary">
233 - </label>
234 - </div>
235 -
236 - <div class="form-control">
237 - <label class="label cursor-pointer">
238 - <span class="label-text">{{ reset_email }}</span>
239 - <input type="radio" name="reset_type" value="email" class="radio radio-primary">
240 - </label>
241 - </div>
242 -
243 - <!-- Custom Password Field (hidden by default) -->
244 - <div id="customPasswordField" class="form-control hidden">
245 - <label class="label">
246 - <span class="label-text">{{ reset_new_password_label }}</span>
247 - </label>
248 - <input
249 - type="password"
250 - name="new_password"
251 - id="newPassword"
252 - placeholder="{{ reset_new_password_placeholder }}"
253 - class="input input-bordered w-full"
254 - minlength="{{ pw_min_length }}"
255 - >
256 - <label class="label">
257 - <span class="label-text-alt text-warning">
258 - {{ reset_password_requirements }}
259 - </span>
260 - </label>
261 - </div>
262 -
263 - <!-- Invalidate Sessions Option -->
264 - <div class="form-control">
265 - <label class="label cursor-pointer">
266 - <span class="label-text">{{ reset_invalidate_sessions }}</span>
267 - <input type="checkbox" name="invalidate_sessions" value="true" class="checkbox checkbox-secondary" checked>
268 - </label>
269 - <label class="label">
270 - <span class="label-text-alt">{{ reset_invalidate_sessions_desc }}</span>
271 - </label>
272 - </div>
273 -
274 - <div class="modal-action">
275 - <button type="button" class="btn" onclick="document.getElementById('resetPasswordModal').close()">{{ cancel }}</button>
276 - <button type="submit" class="btn btn-error">{{ reset_button }}</button>
277 - </div>
278 - </form>
279 - </div>
280 - <form method="dialog" class="modal-backdrop">
281 - <button>close</button>
282 - </form>
283 -</dialog>
284 -
285 -<!-- Modal Delete Confirmation (T093) -->
286 -<dialog id="deleteUserModal" class="modal">
287 - <div class="modal-box">
288 - <h3 class="font-bold text-lg text-error mb-4">{{ delete_confirm_title }}</h3>
289 - <p class="mb-4">{{ delete_confirm_message }}</p>
290 - <p class="text-sm text-base-content/70 mb-4">
291 - <strong>{{ delete_confirm_username }}</strong> <span id="deleteUsername"></span><br>
292 - <strong>{{ delete_confirm_email }}</strong> <span id="deleteEmail"></span>
293 - </p>
294 - <div class="alert alert-warning mb-4">
295 - <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
296 - <span>{{ delete_irreversible }}</span>
297 - </div>
298 - <form id="deleteUserForm" method="POST" class="modal-action">
299 - <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
300 - <input type="hidden" id="deleteUserId" name="user_id" value="">
301 - <button type="button" class="btn" onclick="document.getElementById('deleteUserModal').close()">{{ cancel }}</button>
302 - <button type="submit" class="btn btn-error">{{ delete_button }}</button>
303 - </form>
304 - </div>
305 - <form method="dialog" class="modal-backdrop">
306 - <button>close</button>
307 - </form>
308 -</dialog>
309 -
310 -<script>
311 -// Reset Password Modal Logic
312 -document.querySelectorAll('input[name="reset_type"]').forEach(radio => {
313 - radio.addEventListener('change', function() {
314 - const customField = document.getElementById('customPasswordField');
315 - const newPasswordInput = document.getElementById('newPassword');
316 - const form = document.getElementById('resetPasswordForm');
317 - const userId = document.getElementById('resetUserId').value;
318 -
319 - if (this.value === 'custom') {
320 - customField.classList.remove('hidden');
321 - newPasswordInput.setAttribute('required', 'required');
322 - form.action = `/admin/users/${userId}/reset-password`;
323 - } else if (this.value === 'email') {
324 - customField.classList.add('hidden');
325 - newPasswordInput.removeAttribute('required');
326 - form.action = `/admin/users/${userId}/reset-password-email`;
327 - } else {
328 - customField.classList.add('hidden');
329 - newPasswordInput.removeAttribute('required');
330 - form.action = `/admin/users/${userId}/reset-password`;
331 - }
332 - });
333 -});
334 -
335 -// Open Reset Password Modal
336 -function openResetPasswordModal(userId, username) {
337 - document.getElementById('resetUserId').value = userId;
338 - document.getElementById('resetPasswordForm').action = `/admin/users/${userId}/reset-password`;
339 - // Reset form state
340 - document.querySelector('input[name="reset_type"][value="generate"]').checked = true;
341 - document.getElementById('customPasswordField').classList.add('hidden');
342 - document.getElementById('newPassword').value = '';
343 - document.getElementById('newPassword').removeAttribute('required');
344 - document.getElementById('resetPasswordModal').showModal();
345 -}
346 -
347 -// Handle Reset Password Form Submission
348 -document.getElementById('resetPasswordForm').addEventListener('submit', async function(e) {
349 - e.preventDefault();
350 - const formData = new FormData(this);
351 - const resetType = formData.get('reset_type');
352 - const newPassword = formData.get('new_password');
353 - const userId = formData.get('user_id');
354 -
355 - const submitBtn = this.querySelector('button[type="submit"]');
356 - const originalBtnText = submitBtn.innerHTML;
357 - submitBtn.disabled = true;
358 - submitBtn.innerHTML = '<span class="loading loading-spinner"></span> {{ resetting }}';
359 -
360 - try {
361 - let response;
362 -
363 - if (resetType === 'email') {
364 - // For email reset, send empty body (no JSON needed)
365 - response = await fetch(this.action, {
366 - method: 'POST',
367 - headers: {
368 - 'Accept': 'text/html', // Expect HTML fragment response
369 - },
370 - credentials: 'include',
371 - });
372 - } else {
373 - // For generate/custom, send JSON with password
374 - const requestBody = {
375 - new_password: resetType === 'custom' && newPassword ? newPassword.toString() : null
376 - };
377 -
378 - response = await fetch(this.action, {
379 - method: 'POST',
380 - headers: {
381 - 'Content-Type': 'application/json',
382 - 'Accept': 'application/json'
383 - },
384 - credentials: 'include',
385 - body: JSON.stringify(requestBody)
386 - });
387 - }
388 -
389 - if (response.ok) {
390 - // Close the reset password dialog first so success UI is visible
391 - document.getElementById('resetPasswordModal').close();
392 - submitBtn.disabled = false;
393 - submitBtn.innerHTML = originalBtnText;
394 -
395 - if (resetType === 'email') {
396 - var htmlFragment = await response.text();
397 - var messageDiv = document.createElement('div');
398 - messageDiv.innerHTML = htmlFragment;
399 - var message = messageDiv.querySelector('.alert') || messageDiv;
400 -
401 - var successModal = document.createElement('div');
402 - successModal.className = 'modal modal-open';
403 - successModal.innerHTML = '<div class="modal-box">'
404 - + '<h3 class="font-bold text-lg mb-4">{{ reset_by_email_title }}</h3>'
405 - + '<div class="mb-4">' + message.innerHTML + '</div>'
406 - + '<div class="modal-action">'
407 - + '<button type="button" class="btn btn-primary" onclick="this.closest(\'.modal\').remove();">{{ close }}</button>'
408 - + '</div></div>'
409 - + '<form method="dialog" class="modal-backdrop"><button>close</button></form>';
410 - document.body.appendChild(successModal);
411 - return;
412 - }
413 -
414 - var result = await response.json();
415 -
416 - if (resetType === 'custom') {
417 - alert('{{ password_set_success }}');
418 - window.location.reload();
419 - } else if (result.password) {
420 - var passwordDisplay = document.createElement('div');
421 - passwordDisplay.className = 'modal modal-open';
422 - passwordDisplay.innerHTML = '<div class="modal-box">'
423 - + '<h3 class="font-bold text-lg mb-4">{{ password_reset_success_title }}</h3>'
424 - + '<div class="alert alert-warning mb-4">'
425 - + '<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>'
426 - + '<span>{{ password_warning_once }}</span></div>'
427 - + '<div class="form-control mb-4"><label class="label"><span class="label-text font-semibold">{{ new_password_label }}</span></label>'
428 - + '<div class="join"><input type="text" id="displayPassword" value="' + result.password.replace(/"/g, '&quot;') + '" class="input input-bordered join-item w-full font-mono text-lg" readonly>'
429 - + '<button type="button" class="btn btn-primary join-item" onclick="copyPassword()"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> {{ copy }}</button>'
430 - + '</div></div>'
431 - + '<div class="modal-action"><button type="button" class="btn btn-primary" onclick="this.closest(\'.modal\').remove(); window.location.reload();">{{ close }}</button></div>'
432 - + '</div>';
433 - document.body.appendChild(passwordDisplay);
434 -
435 - window.copyPassword = function() {
436 - var passwordInput = document.getElementById('displayPassword');
437 - navigator.clipboard.writeText(passwordInput.value).then(function() {
438 - alert('{{ password_copied }}');
439 - }).catch(function() {
440 - passwordInput.select();
441 - document.execCommand('copy');
442 - alert('{{ password_copied }}');
443 - });
444 - };
445 - } else {
446 - alert('{{ password_set_success }}');
447 - window.location.reload();
448 - }
449 - } else {
450 - const errorText = await response.text();
451 - console.error('Error:', errorText);
452 - let errorMessage = '❌ {{ reset_error }}';
453 - try {
454 - const errorJson = JSON.parse(errorText);
455 - if (errorJson.message) {
456 - errorMessage = '❌ ' + errorJson.message;
457 - }
458 - } catch (e) {
459 - // Not JSON, use default message
460 - }
461 - alert(errorMessage);
462 - submitBtn.disabled = false;
463 - submitBtn.innerHTML = originalBtnText;
464 - }
465 - } catch (error) {
466 - console.error('Error:', error);
467 - alert('{{ network_error }}');
468 - submitBtn.disabled = false;
469 - submitBtn.innerHTML = originalBtnText;
470 - }
471 -});
472 -
473 -// Open Delete User Modal
474 -function openDeleteUserModal(userId, username, email) {
475 - document.getElementById('deleteUserId').value = userId;
476 - document.getElementById('deleteUsername').textContent = username;
477 - document.getElementById('deleteEmail').textContent = email;
478 - document.getElementById('deleteUserForm').action = `/admin/users/${userId}`;
479 - document.getElementById('deleteUserModal').showModal();
480 -}
481 -
482 -// Handle form submissions
483 -document.getElementById('deleteUserForm').addEventListener('submit', async function(e) {
484 - e.preventDefault();
485 - const formData = new FormData(this);
486 - const userId = formData.get('user_id');
487 -
488 - try {
489 - const response = await fetch(this.action, {
490 - method: 'DELETE',
491 - headers: {
492 - 'Content-Type': 'application/x-www-form-urlencoded',
493 - },
494 - body: new URLSearchParams(formData)
495 - });
496 -
497 - if (response.ok) {
498 - window.location.reload();
499 - } else {
500 - alert('{{ delete_error }}');
501 - }
502 - } catch (error) {
503 - console.error('Error:', error);
504 - alert('{{ network_error }}');
505 - }
506 -});
507 -</script>
508 -
509 -{% endblock %}
D templates/base.html
+0 -175
@@ -1,175 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="fr" data-theme="light">
3 -<head>
4 - <script>
5 - (function() {
6 - var lm = document.cookie.match(/(?:^|;\s*)locale=([^;]*)/);
7 - if (lm && lm[1]) document.documentElement.lang = lm[1];
8 - var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
9 - var t = m ? m[1] : 'auto';
10 - if (t === 'dark') {
11 - document.documentElement.setAttribute('data-theme', 'dark');
12 - } else if (t === 'light') {
13 - document.documentElement.setAttribute('data-theme', 'light');
14 - } else {
15 - var d = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
16 - document.documentElement.setAttribute('data-theme', d);
17 - }
18 - })();
19 - </script>
20 - <meta charset="UTF-8">
21 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
22 - <meta name="description" content="{% if app_description != "" %}{{ app_description }}{% else %}{{ app_name }}{% endif %}">
23 - <title>{% block title %}{{ page_title }} - {{ app_name }}{% endblock %}</title>
24 -
25 - <!-- Favicon -->
26 - <link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg">
27 - <link rel="icon" type="image/png" href="/static/images/favicon.png">
28 - <link rel="apple-touch-icon" href="/static/images/apple-touch-icon.png">
29 -
30 - <!-- Tailwind CSS + DaisyUI -->
31 - <link href="/static/css/tailwind.css" rel="stylesheet">
32 -
33 - <!-- Custom CSS -->
34 - <link href="/static/css/custom.css" rel="stylesheet">
35 -
36 - <!-- HTMX (local) -->
37 - <script src="/static/js/htmx.min.js"></script>
38 -
39 - <!-- Alpine.js (local) -->
40 - <script src="/static/js/alpine.min.js" defer></script>
41 -
42 - {% block head_extra %}{% endblock %}
43 -</head>
44 -<body class="bg-base-100 min-h-screen flex flex-col">
45 - <!-- Navbar (fixe en haut) - Include in child templates that need it -->
46 - {% block navbar %}{% endblock %}
47 -
48 - <!-- Breadcrumb -->
49 - {% block breadcrumb %}
50 - {% endblock %}
51 -
52 - <!-- Main Layout: Sidebar + Content -->
53 - <div class="flex flex-1 overflow-hidden">
54 - <!-- Sidebar (gauche) -->
55 - {% block sidebar %}
56 - {% endblock %}
57 -
58 - <!-- Content Area (principal) -->
59 - <main class="flex-1 overflow-y-auto">
60 - <div class="container mx-auto px-4 py-6">
61 - {% block content %}
62 - <!-- Contenu spécifique de la page -->
63 - {% endblock %}
64 - </div>
65 - </main>
66 - </div>
67 -
68 - <!-- Footer (optionnel) -->
69 - {% block footer %}
70 - <footer class="bg-base-200 border-t border-base-300 py-4 mt-8">
71 - <div class="container mx-auto px-4 text-center text-base-content text-sm">
72 - <p>{{ app_name }} {{ app_version }}{% if app_domain != "" %} - {{ app_domain }}{% endif %}</p>
73 - {% if app_description != "" %}
74 - <p class="text-base-content/70 mt-1">{{ app_description }}</p>
75 - {% endif %}
76 - </div>
77 - </footer>
78 - {% endblock %}
79 -
80 - <!-- Scripts additionnels -->
81 - {% block scripts %}{% endblock %}
82 -
83 - <!-- Alpine.js initialization check and fallback -->
84 - <script>
85 - // Vérifier que Alpine.js est chargé
86 - document.addEventListener('DOMContentLoaded', function() {
87 - // Attendre un peu pour qu'Alpine.js s'initialise
88 - setTimeout(function() {
89 - if (typeof Alpine === 'undefined') {
90 - console.warn('Alpine.js n\'est pas chargé. Le dropdown utilisateur pourrait ne pas fonctionner.');
91 - }
92 - }, 100);
93 - });
94 -
95 - // Language selector functions (Feature 014)
96 - function getCookie(name) {
97 - const value = `; ${document.cookie}`;
98 - const parts = value.split(`; ${name}=`);
99 - if (parts.length === 2) return parts.pop().split(';').shift();
100 - return null;
101 - }
102 -
103 - function updateLanguageDisplay() {
104 - const locale = getCookie('locale') || 'fr';
105 - const langNames = {
106 - 'fr': 'FR',
107 - 'en': 'EN',
108 - 'de': 'DE',
109 - 'es': 'ES'
110 - };
111 - const displayName = langNames[locale] || 'FR';
112 -
113 - // Update desktop display
114 - const desktopDisplay = document.getElementById('navbar-lang-display');
115 - if (desktopDisplay) {
116 - desktopDisplay.textContent = displayName;
117 - }
118 -
119 - // Update mobile display
120 - const mobileDisplay = document.getElementById('navbar-lang-display-mobile');
121 - if (mobileDisplay) {
122 - mobileDisplay.textContent = displayName;
123 - }
124 -
125 - // Update active states
126 - ['fr', 'en', 'de', 'es'].forEach(code => {
127 - const desktopLink = document.getElementById('lang-' + code);
128 - const mobileLink = document.getElementById('lang-' + code + '-mobile');
129 -
130 - if (desktopLink) {
131 - if (code === locale) {
132 - desktopLink.classList.add('active');
133 - } else {
134 - desktopLink.classList.remove('active');
135 - }
136 - }
137 -
138 - if (mobileLink) {
139 - if (code === locale) {
140 - mobileLink.classList.add('active');
141 - } else {
142 - mobileLink.classList.remove('active');
143 - }
144 - }
145 - });
146 - }
147 -
148 - window.changeLanguage = function(locale) {
149 - if (!['fr', 'en', 'de', 'es'].includes(locale)) {
150 - return false;
151 - }
152 -
153 - document.cookie = 'locale=' + locale + '; path=/; max-age=31536000; SameSite=Lax';
154 -
155 - fetch('/settings/preferences', {
156 - method: 'PUT',
157 - headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
158 - credentials: 'include',
159 - body: 'language=' + encodeURIComponent(locale)
160 - }).then(function() {
161 - window.location.reload();
162 - }).catch(function() {
163 - window.location.reload();
164 - });
165 -
166 - return false; // Prevent default link behavior
167 - };
168 -
169 - // Initialize language display on page load
170 - document.addEventListener('DOMContentLoaded', function() {
171 - updateLanguageDisplay();
172 - });
173 - </script>
174 -</body>
175 -</html>
D templates/base_with_theme.html
+0 -10
@@ -1,10 +0,0 @@
1 -<!-- T155: Theme application in base.html -->
2 -<!-- Note: This requires modifying base.html to apply data-theme attribute -->
3 -<!-- Add to <html> tag: data-theme="{{ user_theme | default(value='light') }}" -->
4 -<!-- DaisyUI will automatically apply the theme based on this attribute -->
5 -
6 -<!-- Example integration:
7 -<html lang="fr" data-theme="{% if user_theme %}{{ user_theme }}{% else %}light{% endif %}">
8 -
9 -This allows dynamic theme switching based on user preferences.
10 --->
D templates/components/breadcrumb.html
+0 -37
@@ -1,37 +0,0 @@
1 -<!-- Breadcrumb Component for Feature 007 -->
2 -<!-- Uses DaisyUI breadcrumbs classes -->
3 -
4 -<div class="container mx-auto px-4 py-2 bg-base-200 border-b border-base-300">
5 - <ul class="breadcrumbs text-sm">
6 - {% for item in breadcrumb_items %}
7 - <li>
8 - {% if item.is_current %}
9 - <span class="text-base-content font-semibold">{{ item.label }}</span>
10 - {% else %}
11 - <a href="{{ item.url }}" class="text-primary hover:text-primary-focus">
12 - {{ item.label }}
13 - </a>
14 - {% endif %}
15 - </li>
16 - {% endfor %}
17 - </ul>
18 -</div>
19 -
20 -
21 -
22 -
23 -
24 -
25 -
26 -
27 -
28 -
29 -
30 -
31 -
32 -
33 -
34 -
35 -
36 -
37 -
D templates/components/header.html
+0 -54
@@ -1,54 +0,0 @@
1 -<header class="navbar bg-base-100 border-b border-base-300 shadow-sm">
2 - <div class="container mx-auto px-4">
3 - <div class="flex items-center justify-between h-16">
4 - <!-- Logo -->
5 - <div class="flex items-center">
6 - <a href="/home" class="flex items-center space-x-2 hover:opacity-80 transition">
7 - <img src="/static/images/logo.svg" alt="{{ app_name }}" class="h-8 w-8">
8 - <span class="text-xl font-bold text-primary">{{ app_name }}</span>
9 - </a>
10 - </div>
11 -
12 - <!-- Desktop Navigation -->
13 - <div class="hidden md:flex items-center flex-1 justify-center">
14 - {% include "components/navigation.html" %}
15 - </div>
16 -
17 - <!-- User Menu (Desktop) -->
18 - <div class="hidden md:block">
19 - {% include "components/user_menu.html" %}
20 - </div>
21 -
22 - <!-- Mobile Menu Button -->
23 - <div class="md:hidden" x-data="{ mobileMenuOpen: false }">
24 - <button @click="mobileMenuOpen = !mobileMenuOpen"
25 - class="btn btn-ghost btn-sm">
26 - <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
27 - <path x-show="!mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
28 - <path x-show="mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
29 - </svg>
30 - </button>
31 -
32 - <!-- Mobile Menu -->
33 - <div x-show="mobileMenuOpen"
34 - @click.away="mobileMenuOpen = false"
35 - x-transition:enter="transition ease-out duration-200"
36 - x-transition:enter-start="opacity-0 scale-95"
37 - x-transition:enter-end="opacity-100 scale-100"
38 - x-transition:leave="transition ease-in duration-150"
39 - x-transition:leave-start="opacity-100 scale-100"
40 - x-transition:leave-end="opacity-0 scale-95"
41 - class="absolute top-16 right-0 mt-2 w-64 card bg-base-100 shadow-xl border border-base-300 py-2 z-50">
42 - <!-- Mobile Navigation -->
43 - <div class="px-4 py-2">
44 - {% include "components/navigation.html" %}
45 - </div>
46 - <!-- Mobile User Info -->
47 - <div class="border-t border-base-300 mt-2 pt-2 px-4 py-2">
48 - {% include "fragments/user_menu_dropdown.html" %}
49 - </div>
50 - </div>
51 - </div>
52 - </div>
53 - </div>
54 -</header>
D templates/components/navbar.html
+0 -151
@@ -1,151 +0,0 @@
1 -<!-- Navbar Component for Feature 007 -->
2 -<!-- Uses DaisyUI navbar classes -->
3 -
4 -<nav class="navbar bg-base-200 border-b border-base-300 shadow-sm">
5 - <div class="container mx-auto px-4">
6 - <div class="navbar-start">
7 - <!-- Logo -->
8 - <a href="/home" class="btn btn-ghost normal-case text-xl">
9 - <img src="/static/images/logo.svg" alt="{{ app_name }}" class="h-8 w-8 mr-2">
10 - <span class="text-primary font-bold">{{ app_name }}</span>
11 - </a>
12 - </div>
13 -
14 - <!-- Desktop Navigation (centered) -->
15 - <div class="navbar-center hidden md:flex">
16 - {% include "components/navigation.html" %}
17 - </div>
18 -
19 - <!-- User Menu (right) -->
20 - <div class="navbar-end">
21 - <!-- Language Selector (Feature 014) -->
22 - <div class="hidden md:block mr-2">
23 - <div class="dropdown dropdown-end">
24 - <label tabindex="0" class="btn btn-ghost btn-sm gap-2">
25 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
26 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
27 - </svg>
28 - <span id="navbar-lang-display">FR</span>
29 - <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
30 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
31 - </svg>
32 - </label>
33 - <ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow">
34 - <li>
35 - <a href="#" onclick="return window.changeLanguage('fr');" id="lang-fr">
36 - 🇫🇷 Français
37 - </a>
38 - </li>
39 - <li>
40 - <a href="#" onclick="return window.changeLanguage('en');" id="lang-en">
41 - 🇬🇧 English
42 - </a>
43 - </li>
44 - <li>
45 - <a href="#" onclick="return window.changeLanguage('de');" id="lang-de">
46 - 🇩🇪 Deutsch
47 - </a>
48 - </li>
49 - <li>
50 - <a href="#" onclick="return window.changeLanguage('es');" id="lang-es">
51 - 🇪🇸 Español
52 - </a>
53 - </li>
54 - </ul>
55 - </div>
56 - </div>
57 -
58 - <!-- Desktop User Menu -->
59 - <div class="hidden md:block">
60 - {% include "components/user_menu.html" %}
61 - </div>
62 -
63 - <!-- Mobile Menu Button -->
64 - <div class="md:hidden" x-data="{ mobileMenuOpen: false }">
65 - <button @click="mobileMenuOpen = !mobileMenuOpen"
66 - class="btn btn-ghost btn-circle">
67 - <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
68 - <path x-show="!mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
69 - <path x-show="mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
70 - </svg>
71 - </button>
72 -
73 - <!-- Mobile Menu Dropdown -->
74 - <div x-show="mobileMenuOpen"
75 - @click.away="mobileMenuOpen = false"
76 - x-transition:enter="transition ease-out duration-200"
77 - x-transition:enter-start="opacity-0 scale-95"
78 - x-transition:enter-end="opacity-100 scale-100"
79 - x-transition:leave="transition ease-in duration-150"
80 - x-transition:leave-start="opacity-100 scale-100"
81 - x-transition:leave-end="opacity-0 scale-95"
82 - class="absolute top-16 right-4 mt-2 w-64 bg-base-100 rounded-lg shadow-lg border border-base-300 py-2 z-50">
83 - <!-- Mobile Navigation -->
84 - <div class="px-4 py-2">
85 - {% include "components/navigation.html" %}
86 - </div>
87 - <!-- Mobile Language Selector -->
88 - <div class="border-t border-base-300 mt-2 pt-2 px-4">
89 - <div class="dropdown dropdown-top">
90 - <label tabindex="0" class="btn btn-ghost btn-sm w-full justify-between">
91 - <span class="flex items-center gap-2">
92 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
93 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
94 - </svg>
95 - <span id="navbar-lang-display-mobile">FR</span>
96 - </span>
97 - <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
98 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
99 - </svg>
100 - </label>
101 - <ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[1] w-full p-2 shadow">
102 - <li>
103 - <a href="#" onclick="return window.changeLanguage('fr');" id="lang-fr-mobile">
104 - 🇫🇷 Français
105 - </a>
106 - </li>
107 - <li>
108 - <a href="#" onclick="return window.changeLanguage('en');" id="lang-en-mobile">
109 - 🇬🇧 English
110 - </a>
111 - </li>
112 - <li>
113 - <a href="#" onclick="return window.changeLanguage('de');" id="lang-de-mobile">
114 - 🇩🇪 Deutsch
115 - </a>
116 - </li>
117 - <li>
118 - <a href="#" onclick="return window.changeLanguage('es');" id="lang-es-mobile">
119 - 🇪🇸 Español
120 - </a>
121 - </li>
122 - </ul>
123 - </div>
124 - </div>
125 -
126 - <!-- Mobile User Info -->
127 - <div class="border-t border-base-300 mt-2 pt-2">
128 - <ul class="menu menu-vertical w-full">
129 - {% include "fragments/user_menu_dropdown.html" %}
130 - </ul>
131 - </div>
132 - </div>
133 - </div>
134 - </div>
135 - </div>
136 -</nav>
137 -
138 -
139 -
140 -
141 -
142 -
143 -
144 -
145 -
146 -
147 -
148 -
149 -
150 -
151 -
D templates/components/navigation.html
+0 -68
@@ -1,68 +0,0 @@
1 -<!-- Navigation Items -->
2 -<nav class="flex items-center space-x-1">
3 - {% for item in nav_items %}
4 - <div class="relative group">
5 - {% if item.enabled %}
6 - <!-- Enabled Item -->
7 - <a href="{{ item.url }}"
8 - class="btn btn-ghost btn-sm
9 - {% if item.active %}
10 - btn-active
11 - {% endif %}">
12 - <!-- Icon -->
13 - <span class="text-base">
14 - {% if item.icon == "home" %}🏠
15 - {% elif item.icon == "folder" %}📁
16 - {% elif item.icon == "settings" %}⚙️
17 - {% elif item.icon == "check-square" %}☑️
18 - {% elif item.icon == "shield" %}🛡️
19 - {% else %}📄{% endif %}
20 - </span>
21 - <span class="whitespace-nowrap">{{ item.label }}</span>
22 - {% if item.has_badge %}
23 - <span class="badge badge-sm
24 - {% if item.active %}
25 - badge-primary
26 - {% else %}
27 - badge-ghost
28 - {% endif %}">
29 - {{ item.badge }}
30 - </span>
31 - {% endif %}
32 - {% if item.id == "settings" %}
33 - {% if has_pending_password_change %}
34 - <span class="badge badge-sm badge-warning" title="Changement de mot de passe en attente de confirmation">
35 - ⚠️
36 - </span>
37 - {% endif %}
38 - {% endif %}
39 - </a>
40 - {% else %}
41 - <!-- Disabled Item -->
42 - <div class="btn btn-ghost btn-sm btn-disabled relative"
43 - {% if item.has_disabled_message %}title="{{ item.disabled_message }}"{% endif %}>
44 - <span class="text-base">
45 - {% if item.icon == "home" %}🏠
46 - {% elif item.icon == "folder" %}📁
47 - {% elif item.icon == "settings" %}⚙️
48 - {% elif item.icon == "check-square" %}☑️
49 - {% elif item.icon == "shield" %}🛡️
50 - {% else %}📄{% endif %}
51 - </span>
52 - <span class="whitespace-nowrap">{{ item.label }}</span>
53 - {% if item.has_badge %}
54 - <span class="badge badge-sm badge-warning">
55 - {{ item.badge }}
56 - </span>
57 - {% endif %}
58 - </div>
59 -
60 - <!-- Tooltip on hover -->
61 - {% if item.has_disabled_message %}
62 - <div class="tooltip tooltip-bottom absolute top-full left-0 mt-2 z-50 whitespace-nowrap" data-tip="{{ item.disabled_message }}">
63 - </div>
64 - {% endif %}
65 - {% endif %}
66 - </div>
67 - {% endfor %}
68 -</nav>
D templates/components/password_change_badge.html
+0 -6
@@ -1,6 +0,0 @@
1 -{% if has_pending_password_change %}
2 -<span class="badge badge-sm badge-warning" title="Changement de mot de passe en attente de confirmation">
3 - ⚠️
4 -</span>
5 -{% endif %}
6 -
D templates/components/sidebar.html
+0 -155
@@ -1,155 +0,0 @@
1 -<!-- Sidebar Component for Feature 007 -->
2 -<!-- Uses DaisyUI sidebar and menu classes -->
3 -
4 -<aside class="w-64 bg-base-200 border-r border-base-300 hidden lg:block overflow-y-auto"
5 - x-data="{ sidebarOpen: false }">
6 - <div class="p-4">
7 - {% for section in sidebar_sections %}
8 - <div class="mb-6">
9 - <h3 class="text-sm font-semibold text-base-content uppercase tracking-wider mb-2">
10 - {{ section.title }}
11 - </h3>
12 - <ul class="menu menu-vertical w-full">
13 - {% for item in section.items %}
14 - <li>
15 - {% if item.enabled %}
16 - {% if item.has_url %}
17 - <a href="{{ item.url }}"
18 - class="flex items-center gap-2 hover:bg-base-300">
19 - <span>{{ item.icon }}</span>
20 - <span>{{ item.label }}</span>
21 - {% if !item.badge.is_empty() %}
22 - <span class="badge badge-primary badge-sm ml-auto">{{ item.badge }}</span>
23 - {% endif %}
24 - </a>
25 - {% else %}
26 - <div class="flex items-center gap-2 text-base-content/60 cursor-default">
27 - <span>{{ item.icon }}</span>
28 - <span>{{ item.label }}</span>
29 - {% if !item.badge.is_empty() %}
30 - <span class="badge badge-ghost badge-sm ml-auto">{{ item.badge }}</span>
31 - {% endif %}
32 - </div>
33 - {% endif %}
34 - {% else %}
35 - <div class="flex items-center gap-2 text-base-content/60 cursor-default">
36 - <span>{{ item.icon }}</span>
37 - <span>{{ item.label }}</span>
38 - {% if !item.badge.is_empty() %}
39 - <span class="badge badge-ghost badge-sm ml-auto">{{ item.badge }}</span>
40 - {% endif %}
41 - </div>
42 - {% endif %}
43 - </li>
44 - {% endfor %}
45 - </ul>
46 - </div>
47 - {% endfor %}
48 - </div>
49 -</aside>
50 -
51 -<!-- Mobile Sidebar Toggle Button (visible only on mobile) -->
52 -<div class="lg:hidden fixed bottom-4 right-4 z-50" x-data="{ sidebarOpen: false }">
53 - <button @click="sidebarOpen = !sidebarOpen"
54 - class="btn btn-primary btn-circle shadow-lg">
55 - <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
56 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
57 - </svg>
58 - </button>
59 -
60 - <!-- Mobile Sidebar Overlay -->
61 - <div x-show="sidebarOpen"
62 - @click.away="sidebarOpen = false"
63 - x-transition:enter="transition ease-out duration-300"
64 - x-transition:enter-start="opacity-0"
65 - x-transition:enter-end="opacity-100"
66 - x-transition:leave="transition ease-in duration-200"
67 - x-transition:leave-start="opacity-100"
68 - x-transition:leave-end="opacity-0"
69 - class="fixed inset-0 bg-black bg-opacity-50 z-40"
70 - @click="sidebarOpen = false">
71 - </div>
72 -
73 - <!-- Mobile Sidebar Panel -->
74 - <div x-show="sidebarOpen"
75 - x-transition:enter="transition ease-out duration-300 transform"
76 - x-transition:enter-start="translate-x-full"
77 - x-transition:enter-end="translate-x-0"
78 - x-transition:leave="transition ease-in duration-200 transform"
79 - x-transition:leave-start="translate-x-0"
80 - x-transition:leave-end="translate-x-full"
81 - class="fixed top-0 right-0 h-full w-64 bg-base-200 border-l border-base-300 shadow-xl z-50 overflow-y-auto">
82 - <div class="p-4">
83 - <div class="flex justify-between items-center mb-4">
84 - <h2 class="text-lg font-bold text-base-content">Menu</h2>
85 - <button @click="sidebarOpen = false" class="btn btn-ghost btn-sm btn-circle">
86 - <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
87 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
88 - </svg>
89 - </button>
90 - </div>
91 -
92 - {% for section in sidebar_sections %}
93 - <div class="mb-6">
94 - <h3 class="text-sm font-semibold text-base-content uppercase tracking-wider mb-2">
95 - {{ section.title }}
96 - </h3>
97 - <ul class="menu menu-vertical w-full">
98 - {% for item in section.items %}
99 - <li>
100 - {% if item.enabled %}
101 - {% if item.has_url %}
102 - <a href="{{ item.url }}"
103 - class="flex items-center gap-2 hover:bg-base-300"
104 - @click="sidebarOpen = false">
105 - <span>{{ item.icon }}</span>
106 - <span>{{ item.label }}</span>
107 - {% if !item.badge.is_empty() %}
108 - <span class="badge badge-primary badge-sm ml-auto">{{ item.badge }}</span>
109 - {% endif %}
110 - </a>
111 - {% else %}
112 - <div class="flex items-center gap-2 text-base-content/60 cursor-default">
113 - <span>{{ item.icon }}</span>
114 - <span>{{ item.label }}</span>
115 - {% if !item.badge.is_empty() %}
116 - <span class="badge badge-ghost badge-sm ml-auto">{{ item.badge }}</span>
117 - {% endif %}
118 - </div>
119 - {% endif %}
120 - {% else %}
121 - <div class="flex items-center gap-2 text-base-content/60 cursor-default">
122 - <span>{{ item.icon }}</span>
123 - <span>{{ item.label }}</span>
124 - {% if !item.badge.is_empty() %}
125 - <span class="badge badge-ghost badge-sm ml-auto">{{ item.badge }}</span>
126 - {% endif %}
127 - </div>
128 - {% endif %}
129 - </li>
130 - {% endfor %}
131 - </ul>
132 - </div>
133 - {% endfor %}
134 - </div>
135 - </div>
136 -</div>
137 -
138 -
139 -
140 -
141 -
142 -
143 -
144 -
145 -
146 -
147 -
148 -
149 -
150 -
151 -
152 -
153 -
154 -
155 -
D templates/components/user_menu.html
+0 -43
@@ -1,43 +0,0 @@
1 -<!-- User Menu with DaisyUI and Alpine.js -->
2 -<div class="dropdown dropdown-end"
3 - x-data="{ open: false }"
4 - @click.away="open = false">
5 - <!-- User Menu Button -->
6 - <button type="button"
7 - @click.stop="open = !open"
8 - class="btn btn-ghost flex items-center gap-3 normal-case cursor-pointer">
9 - <!-- Avatar with initials -->
10 - <div class="avatar placeholder">
11 - <div class="bg-primary text-primary-content rounded-full w-10 h-10"
12 - style="background-color: {{ avatar_color }} !important;">
13 - <span class="text-sm font-semibold">{{ initials }}</span>
14 - </div>
15 - </div>
16 -
17 - <!-- Username (hidden on small screens) -->
18 - <div class="hidden lg:block text-left">
19 - <div class="text-sm font-medium text-base-content">{{ username }}</div>
20 - <div class="text-xs text-base-content/60">{{ email }}</div>
21 - </div>
22 -
23 - <!-- Dropdown Arrow -->
24 - <svg class="w-4 h-4 text-base-content transition-transform duration-200"
25 - :class="{ 'rotate-180': open }"
26 - fill="none" stroke="currentColor" viewBox="0 0 24 24">
27 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
28 - </svg>
29 - </button>
30 -
31 - <!-- Dropdown Menu -->
32 - <ul x-show="open"
33 - x-cloak
34 - x-transition:enter="transition ease-out duration-100"
35 - x-transition:enter-start="opacity-0 scale-95"
36 - x-transition:enter-end="opacity-100 scale-100"
37 - x-transition:leave="transition ease-in duration-75"
38 - x-transition:leave-start="opacity-100 scale-100"
39 - x-transition:leave-end="opacity-0 scale-95"
40 - class="dropdown-content menu bg-base-100 rounded-box z-[1] w-64 p-2 shadow-lg border border-base-300 mt-2">
41 - {% include "fragments/user_menu_dropdown.html" %}
42 - </ul>
43 -</div>
D templates/email_verified.html
+0 -119
@@ -1,119 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="fr" data-theme="dark">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - {% if success %}
7 - <meta http-equiv="refresh" content="5;url=/login">
8 - {% endif %}
9 - <title>{% if success %}Email vérifié{% else %}Erreur{% endif %}</title>
10 - <link href="/static/css/output.css" rel="stylesheet">
11 - <style>
12 - @keyframes spin {
13 - from { transform: rotate(0deg); }
14 - to { transform: rotate(360deg); }
15 - }
16 - .spinner {
17 - animation: spin 1s linear infinite;
18 - display: inline-block;
19 - }
20 - @keyframes fadeIn {
21 - from { opacity: 0; transform: translateY(-20px); }
22 - to { opacity: 1; transform: translateY(0); }
23 - }
24 - .fade-in {
25 - animation: fadeIn 0.5s ease-out;
26 - }
27 - .countdown {
28 - font-variant-numeric: tabular-nums;
29 - }
30 - </style>
31 -</head>
32 -<body class="min-h-screen bg-gradient-to-br from-base-300 via-base-200 to-base-300 flex items-center justify-center p-4">
33 - <div class="card bg-base-100 shadow-2xl w-full max-w-md fade-in">
34 - <div class="card-body text-center space-y-6">
35 - {% if success %}
36 - <!-- Success Icon -->
37 - <div class="flex justify-center">
38 - <div class="w-20 h-20 rounded-full bg-success/20 flex items-center justify-center">
39 - <svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
40 - <path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
41 - </svg>
42 - </div>
43 - </div>
44 -
45 - <!-- Title -->
46 - <h1 class="text-2xl font-bold text-success">Email vérifié !</h1>
47 -
48 - <!-- Message -->
49 - <p class="text-base-content/80">
50 - {{ message }}
51 - </p>
52 -
53 - <!-- Redirect Info -->
54 - <div class="bg-base-200 rounded-lg p-4 space-y-3">
55 - <div class="flex items-center justify-center gap-2 text-base-content/60">
56 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 spinner" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
57 - <path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
58 - </svg>
59 - <span>Redirection automatique dans <span id="countdown" class="countdown font-bold text-primary">5</span> secondes...</span>
60 - </div>
61 - </div>
62 -
63 - <!-- Manual Link -->
64 - <a href="/login" class="btn btn-primary btn-block">
65 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
66 - <path stroke-linecap="round" stroke-linejoin="round" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
67 - </svg>
68 - Se connecter maintenant
69 - </a>
70 - {% else %}
71 - <!-- Error Icon -->
72 - <div class="flex justify-center">
73 - <div class="w-20 h-20 rounded-full bg-error/20 flex items-center justify-center">
74 - <svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-error" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
75 - <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
76 - </svg>
77 - </div>
78 - </div>
79 -
80 - <!-- Title -->
81 - <h1 class="text-2xl font-bold text-error">Erreur de vérification</h1>
82 -
83 - <!-- Message -->
84 - <p class="text-base-content/80">
85 - {{ message }}
86 - </p>
87 -
88 - <!-- Links -->
89 - <div class="space-y-2">
90 - <a href="/resend-verification" class="btn btn-outline btn-primary btn-block">
91 - Renvoyer l'email de vérification
92 - </a>
93 - <a href="/login" class="btn btn-ghost btn-block">
94 - Retour à la connexion
95 - </a>
96 - </div>
97 - {% endif %}
98 - </div>
99 - </div>
100 -
101 - {% if success %}
102 - <script>
103 - // Countdown timer
104 - let seconds = 5;
105 - const countdownEl = document.getElementById('countdown');
106 -
107 - const timer = setInterval(() => {
108 - seconds--;
109 - countdownEl.textContent = seconds;
110 -
111 - if (seconds <= 0) {
112 - clearInterval(timer);
113 - window.location.href = '/login';
114 - }
115 - }, 1000);
116 - </script>
117 - {% endif %}
118 -</body>
119 -</html>
D templates/emails/account_modified.html
+0 -165
@@ -1,165 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="{{ locale }}">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>{% if locale == "fr" %}Compte modifié{% else %}Account Modified{% endif %}</title>
7 - <style>
8 - body {
9 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 - line-height: 1.6;
11 - color: #333;
12 - max-width: 600px;
13 - margin: 0 auto;
14 - padding: 20px;
15 - background-color: #f4f4f4;
16 - }
17 - .email-container {
18 - background-color: #ffffff;
19 - border-radius: 8px;
20 - padding: 30px;
21 - box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 - }
23 - .header {
24 - text-align: center;
25 - margin-bottom: 30px;
26 - }
27 - .logo {
28 - font-size: 24px;
29 - font-weight: bold;
30 - color: #2563eb;
31 - margin-bottom: 10px;
32 - }
33 - h1 {
34 - color: #1f2937;
35 - font-size: 24px;
36 - margin-bottom: 20px;
37 - }
38 - .content {
39 - margin-bottom: 30px;
40 - }
41 - .changes-list {
42 - margin: 20px 0;
43 - padding: 15px;
44 - background-color: #f3f4f6;
45 - border-radius: 6px;
46 - }
47 - .change-item {
48 - margin: 10px 0;
49 - padding: 10px;
50 - background-color: #ffffff;
51 - border-left: 3px solid #3b82f6;
52 - border-radius: 4px;
53 - }
54 - .change-label {
55 - font-weight: 600;
56 - color: #1f2937;
57 - }
58 - .change-value {
59 - color: #6b7280;
60 - margin-top: 5px;
61 - }
62 - .info-box {
63 - margin-top: 20px;
64 - padding: 15px;
65 - background-color: #fee2e2;
66 - border-left: 4px solid #ef4444;
67 - border-radius: 4px;
68 - font-size: 14px;
69 - }
70 - .footer {
71 - margin-top: 30px;
72 - padding-top: 20px;
73 - border-top: 1px solid #e5e7eb;
74 - font-size: 14px;
75 - color: #6b7280;
76 - text-align: center;
77 - }
78 - </style>
79 -</head>
80 -<body>
81 - <div class="email-container">
82 - <div class="header">
83 - <div class="logo">{{ app_name }}</div>
84 - </div>
85 -
86 - <h1>
87 - {% if locale == "fr" %}
88 - Compte modifié
89 - {% else %}
90 - Account Modified
91 - {% endif %}
92 - </h1>
93 -
94 - <div class="content">
95 - {% if locale == "fr" %}
96 - <p>Bonjour {{ username }},</p>
97 - <p>Nous vous informons que les informations de votre compte <strong>{{ app_name }}</strong> ont été modifiées.</p>
98 - <p><strong>Date et heure de modification :</strong> {{ modified_at }}</p>
99 - {% else %}
100 - <p>Hello {{ username }},</p>
101 - <p>We are informing you that your <strong>{{ app_name }}</strong> account information has been modified.</p>
102 - <p><strong>Date and time of modification:</strong> {{ modified_at }}</p>
103 - {% endif %}
104 - </div>
105 -
106 - {% if !changes.is_empty() %}
107 - <div class="changes-list">
108 - {% if locale == "fr" %}
109 - <p style="font-weight: 600; margin-bottom: 15px;">Modifications effectuées :</p>
110 - {% else %}
111 - <p style="font-weight: 600; margin-bottom: 15px;">Changes made:</p>
112 - {% endif %}
113 - {% for change in changes %}
114 - <div class="change-item">
115 - <div class="change-label">{{ change.field }}</div>
116 - {% if let Some(old_val) = change.old_value %}
117 - <div class="change-value">
118 - {% if locale == "fr" %}
119 - Ancien : {{ old_val }} → Nouveau : {{ change.new_value }}
120 - {% else %}
121 - Old: {{ old_val }} → New: {{ change.new_value }}
122 - {% endif %}
123 - </div>
124 - {% else %}
125 - <div class="change-value">{{ change.new_value }}</div>
126 - {% endif %}
127 - </div>
128 - {% endfor %}
129 - </div>
130 - {% endif %}
131 -
132 - <div class="info-box">
133 - {% if locale == "fr" %}
134 - <p><strong>⚠️ Sécurité :</strong></p>
135 - <ul style="margin: 10px 0; padding-left: 20px;">
136 - <li>Si vous n'avez pas effectué ces modifications, contactez immédiatement le support technique</li>
137 - <li>Vérifiez que vous pouvez toujours accéder à votre compte</li>
138 - <li>Si votre email a été modifié, vérifiez que vous recevez bien les emails à la nouvelle adresse</li>
139 - </ul>
140 - {% else %}
141 - <p><strong>⚠️ Security:</strong></p>
142 - <ul style="margin: 10px 0; padding-left: 20px;">
143 - <li>If you did not make these changes, contact technical support immediately</li>
144 - <li>Verify that you can still access your account</li>
145 - <li>If your email was changed, verify that you receive emails at the new address</li>
146 - </ul>
147 - {% endif %}
148 - </div>
149 -
150 - <div class="footer">
151 - {% if locale == "fr" %}
152 - <p>Cet email a été envoyé automatiquement pour votre sécurité.</p>
153 - <p>Si vous avez des questions, contactez le support technique.</p>
154 - {% else %}
155 - <p>This email was sent automatically for your security.</p>
156 - <p>If you have any questions, please contact technical support.</p>
157 - {% endif %}
158 - <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
159 - © {{ current_year }} {{ app_name }}. Tous droits réservés.
160 - </p>
161 - </div>
162 - </div>
163 -</body>
164 -</html>
165 -
D templates/emails/admin_new_user.html
+0 -151
@@ -1,151 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="{{ locale }}">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>{% if locale == "fr" %}Nouvel utilisateur inscrit{% else %}New User Registered{% endif %}</title>
7 - <style>
8 - body {
9 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 - line-height: 1.6;
11 - color: #333;
12 - max-width: 600px;
13 - margin: 0 auto;
14 - padding: 20px;
15 - background-color: #f4f4f4;
16 - }
17 - .email-container {
18 - background-color: #ffffff;
19 - border-radius: 8px;
20 - padding: 30px;
21 - box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 - }
23 - .header {
24 - text-align: center;
25 - margin-bottom: 30px;
26 - }
27 - .logo {
28 - font-size: 24px;
29 - font-weight: bold;
30 - color: #2563eb;
31 - margin-bottom: 10px;
32 - }
33 - h1 {
34 - color: #1f2937;
35 - font-size: 24px;
36 - margin-bottom: 20px;
37 - }
38 - .content {
39 - margin-bottom: 30px;
40 - }
41 - .user-info {
42 - margin: 20px 0;
43 - padding: 15px;
44 - background-color: #f3f4f6;
45 - border-radius: 6px;
46 - }
47 - .info-row {
48 - margin: 10px 0;
49 - padding: 8px 0;
50 - border-bottom: 1px solid #e5e7eb;
51 - }
52 - .info-row:last-child {
53 - border-bottom: none;
54 - }
55 - .info-label {
56 - font-weight: 600;
57 - color: #1f2937;
58 - display: inline-block;
59 - width: 120px;
60 - }
61 - .info-value {
62 - color: #6b7280;
63 - }
64 - .action-box {
65 - margin-top: 20px;
66 - padding: 15px;
67 - background-color: #dbeafe;
68 - border-left: 4px solid #3b82f6;
69 - border-radius: 4px;
70 - font-size: 14px;
71 - }
72 - .footer {
73 - margin-top: 30px;
74 - padding-top: 20px;
75 - border-top: 1px solid #e5e7eb;
76 - font-size: 14px;
77 - color: #6b7280;
78 - text-align: center;
79 - }
80 - </style>
81 -</head>
82 -<body>
83 - <div class="email-container">
84 - <div class="header">
85 - <div class="logo">{{ app_name }}</div>
86 - </div>
87 -
88 - <h1>
89 - {% if locale == "fr" %}
90 - Nouvel utilisateur inscrit
91 - {% else %}
92 - New User Registered
93 - {% endif %}
94 - </h1>
95 -
96 - <div class="content">
97 - {% if locale == "fr" %}
98 - <p>Bonjour,</p>
99 - <p>Un nouvel utilisateur s'est inscrit sur <strong>{{ app_name }}</strong>.</p>
100 - {% else %}
101 - <p>Hello,</p>
102 - <p>A new user has registered on <strong>{{ app_name }}</strong>.</p>
103 - {% endif %}
104 - </div>
105 -
106 - <div class="user-info">
107 - {% if locale == "fr" %}
108 - <p style="font-weight: 600; margin-bottom: 15px;">Informations de l'utilisateur :</p>
109 - {% else %}
110 - <p style="font-weight: 600; margin-bottom: 15px;">User Information:</p>
111 - {% endif %}
112 - <div class="info-row">
113 - <span class="info-label">{% if locale == "fr" %}Nom d'utilisateur{% else %}Username{% endif %}:</span>
114 - <span class="info-value">{{ username }}</span>
115 - </div>
116 - <div class="info-row">
117 - <span class="info-label">Email:</span>
118 - <span class="info-value">{{ email }}</span>
119 - </div>
120 - <div class="info-row">
121 - <span class="info-label">{% if locale == "fr" %}Date d'inscription{% else %}Registration Date{% endif %}:</span>
122 - <span class="info-value">{{ registered_at }}</span>
123 - </div>
124 - </div>
125 -
126 - <div class="action-box">
127 - {% if locale == "fr" %}
128 - <p><strong>ℹ️ Action requise :</strong></p>
129 - <p>Vous pouvez consulter et gérer cet utilisateur depuis le panneau d'administration.</p>
130 - {% else %}
131 - <p><strong>ℹ️ Action Required:</strong></p>
132 - <p>You can view and manage this user from the admin panel.</p>
133 - {% endif %}
134 - </div>
135 -
136 - <div class="footer">
137 - {% if locale == "fr" %}
138 - <p>Cet email a été envoyé automatiquement aux administrateurs.</p>
139 - <p>Si vous avez des questions, contactez le support technique.</p>
140 - {% else %}
141 - <p>This email was sent automatically to administrators.</p>
142 - <p>If you have any questions, please contact technical support.</p>
143 - {% endif %}
144 - <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
145 - © {{ current_year }} {{ app_name }}. Tous droits réservés.
146 - </p>
147 - </div>
148 - </div>
149 -</body>
150 -</html>
151 -
D templates/emails/email_validation.html
+0 -164
@@ -1,164 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="{{ locale }}">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>{% if locale == "fr" %}Vérification de votre adresse email{% else %}Verify Your Email Address{% endif %}</title>
7 - <style>
8 - body {
9 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 - line-height: 1.6;
11 - color: #333;
12 - max-width: 600px;
13 - margin: 0 auto;
14 - padding: 20px;
15 - background-color: #f4f4f4;
16 - }
17 - .email-container {
18 - background-color: #ffffff;
19 - border-radius: 8px;
20 - padding: 30px;
21 - box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 - }
23 - .header {
24 - text-align: center;
25 - margin-bottom: 30px;
26 - }
27 - .logo {
28 - font-size: 24px;
29 - font-weight: bold;
30 - color: #2563eb;
31 - margin-bottom: 10px;
32 - }
33 - h1 {
34 - color: #1f2937;
35 - font-size: 24px;
36 - margin-bottom: 20px;
37 - }
38 - .content {
39 - margin-bottom: 30px;
40 - }
41 - .button-container {
42 - text-align: center;
43 - margin: 30px 0;
44 - }
45 - .button {
46 - display: inline-block;
47 - padding: 12px 30px;
48 - background-color: #10b981;
49 - color: #ffffff !important;
50 - text-decoration: none;
51 - border-radius: 6px;
52 - font-weight: 600;
53 - font-size: 16px;
54 - }
55 - .button:hover {
56 - background-color: #059669;
57 - }
58 - .link-fallback {
59 - margin-top: 20px;
60 - padding: 15px;
61 - background-color: #f3f4f6;
62 - border-radius: 6px;
63 - word-break: break-all;
64 - font-size: 14px;
65 - color: #6b7280;
66 - }
67 - .footer {
68 - margin-top: 30px;
69 - padding-top: 20px;
70 - border-top: 1px solid #e5e7eb;
71 - font-size: 14px;
72 - color: #6b7280;
73 - text-align: center;
74 - }
75 - .info {
76 - margin-top: 20px;
77 - padding: 15px;
78 - background-color: #dbeafe;
79 - border-left: 4px solid #3b82f6;
80 - border-radius: 4px;
81 - font-size: 14px;
82 - }
83 - </style>
84 -</head>
85 -<body>
86 - <div class="email-container">
87 - <div class="header">
88 - <div class="logo">{{ app_name }}</div>
89 - </div>
90 -
91 - <h1>
92 - {% if locale == "fr" %}
93 - Vérification de votre adresse email
94 - {% else %}
95 - Verify Your Email Address
96 - {% endif %}
97 - </h1>
98 -
99 - <div class="content">
100 - {% if locale == "fr" %}
101 - <p>Bonjour {{ username }},</p>
102 - <p>Merci de vous être inscrit sur <strong>{{ app_name }}</strong>.</p>
103 - <p>Pour activer votre compte, veuillez vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
104 - {% else %}
105 - <p>Hello {{ username }},</p>
106 - <p>Thank you for signing up for <strong>{{ app_name }}</strong>.</p>
107 - <p>To activate your account, please verify your email address by clicking the button below:</p>
108 - {% endif %}
109 - </div>
110 -
111 - <div class="button-container">
112 - <a href="{{ validation_url }}" class="button">
113 - {% if locale == "fr" %}
114 - Vérifier mon email
115 - {% else %}
116 - Verify My Email
117 - {% endif %}
118 - </a>
119 - </div>
120 -
121 - <div class="link-fallback">
122 - {% if locale == "fr" %}
123 - <p><strong>Le bouton ne fonctionne pas ?</strong></p>
124 - <p>Copiez et collez ce lien dans votre navigateur :</p>
125 - {% else %}
126 - <p><strong>Button not working?</strong></p>
127 - <p>Copy and paste this link into your browser:</p>
128 - {% endif %}
129 - <p style="margin-top: 10px; word-break: break-all;">{{ validation_url }}</p>
130 - </div>
131 -
132 - <div class="info">
133 - {% if locale == "fr" %}
134 - <p><strong>ℹ️ Important :</strong></p>
135 - <ul style="margin: 10px 0; padding-left: 20px;">
136 - <li>Ce lien est valide pendant <strong>24 heures</strong></li>
137 - <li>Si vous n'avez pas créé de compte, ignorez cet email</li>
138 - <li>Vous devez vérifier votre email avant de pouvoir vous connecter</li>
139 - </ul>
140 - {% else %}
141 - <p><strong>ℹ️ Important:</strong></p>
142 - <ul style="margin: 10px 0; padding-left: 20px;">
143 - <li>This link is valid for <strong>24 hours</strong></li>
144 - <li>If you did not create an account, please ignore this email</li>
145 - <li>You must verify your email before you can log in</li>
146 - </ul>
147 - {% endif %}
148 - </div>
149 -
150 - <div class="footer">
151 - {% if locale == "fr" %}
152 - <p>Cet email a été envoyé automatiquement, merci de ne pas y répondre.</p>
153 - <p>Si vous avez des questions, contactez le support technique.</p>
154 - {% else %}
155 - <p>This email was sent automatically, please do not reply.</p>
156 - <p>If you have any questions, please contact technical support.</p>
157 - {% endif %}
158 - <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
159 - © {{ current_year }} {{ app_name }}. Tous droits réservés.
160 - </p>
161 - </div>
162 - </div>
163 -</body>
164 -</html>
D templates/emails/password_change_confirmation.html
+0 -167
@@ -1,167 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="{{ locale }}">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>{% if locale == "fr" %}Confirmation de changement de mot de passe{% else %}Password Change Confirmation{% endif %}</title>
7 - <style>
8 - body {
9 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 - line-height: 1.6;
11 - color: #333;
12 - max-width: 600px;
13 - margin: 0 auto;
14 - padding: 20px;
15 - background-color: #f4f4f4;
16 - }
17 - .email-container {
18 - background-color: #ffffff;
19 - border-radius: 8px;
20 - padding: 30px;
21 - box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 - }
23 - .header {
24 - text-align: center;
25 - margin-bottom: 30px;
26 - }
27 - .logo {
28 - font-size: 24px;
29 - font-weight: bold;
30 - color: #2563eb;
31 - margin-bottom: 10px;
32 - }
33 - h1 {
34 - color: #1f2937;
35 - font-size: 24px;
36 - margin-bottom: 20px;
37 - }
38 - .content {
39 - margin-bottom: 30px;
40 - }
41 - .button-container {
42 - text-align: center;
43 - margin: 30px 0;
44 - }
45 - .button {
46 - display: inline-block;
47 - padding: 12px 30px;
48 - background-color: #2563eb;
49 - color: #ffffff !important;
50 - text-decoration: none;
51 - border-radius: 6px;
52 - font-weight: 600;
53 - font-size: 16px;
54 - }
55 - .button:hover {
56 - background-color: #1d4ed8;
57 - }
58 - .link-fallback {
59 - margin-top: 20px;
60 - padding: 15px;
61 - background-color: #f3f4f6;
62 - border-radius: 6px;
63 - word-break: break-all;
64 - font-size: 14px;
65 - color: #6b7280;
66 - }
67 - .footer {
68 - margin-top: 30px;
69 - padding-top: 20px;
70 - border-top: 1px solid #e5e7eb;
71 - font-size: 14px;
72 - color: #6b7280;
73 - text-align: center;
74 - }
75 - .warning {
76 - margin-top: 20px;
77 - padding: 15px;
78 - background-color: #fef3c7;
79 - border-left: 4px solid #f59e0b;
80 - border-radius: 4px;
81 - font-size: 14px;
82 - }
83 - </style>
84 -</head>
85 -<body>
86 - <div class="email-container">
87 - <div class="header">
88 - <div class="logo">{{ app_name }}</div>
89 - </div>
90 -
91 - <h1>
92 - {% if locale == "fr" %}
93 - Confirmation de changement de mot de passe
94 - {% else %}
95 - Password Change Confirmation
96 - {% endif %}
97 - </h1>
98 -
99 - <div class="content">
100 - {% if locale == "fr" %}
101 - <p>Bonjour {{ username }},</p>
102 - <p>Vous avez demandé à changer votre mot de passe pour votre compte {{ app_name }}.</p>
103 - <p>Pour confirmer ce changement, cliquez sur le bouton ci-dessous :</p>
104 - {% else %}
105 - <p>Hello {{ username }},</p>
106 - <p>You have requested to change your password for your {{ app_name }} account.</p>
107 - <p>To confirm this change, click the button below:</p>
108 - {% endif %}
109 - </div>
110 -
111 - <div class="button-container">
112 - <a href="{{ confirmation_url }}" class="button">
113 - {% if locale == "fr" %}
114 - Confirmer le changement de mot de passe
115 - {% else %}
116 - Confirm Password Change
117 - {% endif %}
118 - </a>
119 - </div>
120 -
121 - <div class="link-fallback">
122 - {% if locale == "fr" %}
123 - <p><strong>Le bouton ne fonctionne pas ?</strong></p>
124 - <p>Copiez et collez ce lien dans votre navigateur :</p>
125 - {% else %}
126 - <p><strong>Button not working?</strong></p>
127 - <p>Copy and paste this link into your browser:</p>
128 - {% endif %}
129 - <p style="margin-top: 10px; word-break: break-all;">{{ confirmation_url }}</p>
130 - </div>
131 -
132 - <div class="warning">
133 - {% if locale == "fr" %}
134 - <p><strong>⚠️ Important :</strong></p>
135 - <ul style="margin: 10px 0; padding-left: 20px;">
136 - <li>Ce lien est valide pendant <strong>{{ expires_in_hours }} heures</strong> uniquement</li>
137 - <li>Si vous n'avez pas demandé ce changement, ignorez cet email</li>
138 - <li>Votre mot de passe actuel restera inchangé si vous n'utilisez pas ce lien</li>
139 - <li>Ce lien est à <strong>usage unique</strong> et ne peut être utilisé qu'une seule fois</li>
140 - </ul>
141 - {% else %}
142 - <p><strong>⚠️ Important:</strong></p>
143 - <ul style="margin: 10px 0; padding-left: 20px;">
144 - <li>This link is valid for <strong>{{ expires_in_hours }} hours</strong> only</li>
145 - <li>If you did not request this change, please ignore this email</li>
146 - <li>Your current password will remain unchanged if you don't use this link</li>
147 - <li>This link is <strong>single-use</strong> and can only be used once</li>
148 - </ul>
149 - {% endif %}
150 - </div>
151 -
152 - <div class="footer">
153 - {% if locale == "fr" %}
154 - <p>Cet email a été envoyé automatiquement, merci de ne pas y répondre.</p>
155 - <p>Si vous avez des questions, contactez le support technique.</p>
156 - {% else %}
157 - <p>This email was sent automatically, please do not reply.</p>
158 - <p>If you have any questions, please contact technical support.</p>
159 - {% endif %}
160 - <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
161 - © {{ current_year }} {{ app_name }}. {% if locale == "fr" %}Tous droits réservés.{% else %}All rights reserved.{% endif %}
162 - </p>
163 - </div>
164 - </div>
165 -</body>
166 -</html>
167 -
D templates/emails/password_changed.html
+0 -135
@@ -1,135 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="{{ locale }}">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>{% if locale == "fr" %}Mot de passe modifié{% else %}Password Changed{% endif %}</title>
7 - <style>
8 - body {
9 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 - line-height: 1.6;
11 - color: #333;
12 - max-width: 600px;
13 - margin: 0 auto;
14 - padding: 20px;
15 - background-color: #f4f4f4;
16 - }
17 - .email-container {
18 - background-color: #ffffff;
19 - border-radius: 8px;
20 - padding: 30px;
21 - box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 - }
23 - .header {
24 - text-align: center;
25 - margin-bottom: 30px;
26 - }
27 - .logo {
28 - font-size: 24px;
29 - font-weight: bold;
30 - color: #2563eb;
31 - margin-bottom: 10px;
32 - }
33 - h1 {
34 - color: #1f2937;
35 - font-size: 24px;
36 - margin-bottom: 20px;
37 - }
38 - .content {
39 - margin-bottom: 30px;
40 - }
41 - .info-box {
42 - margin-top: 20px;
43 - padding: 15px;
44 - background-color: #fef3c7;
45 - border-left: 4px solid #f59e0b;
46 - border-radius: 4px;
47 - font-size: 14px;
48 - }
49 - .warning-box {
50 - margin-top: 20px;
51 - padding: 15px;
52 - background-color: #fee2e2;
53 - border-left: 4px solid #ef4444;
54 - border-radius: 4px;
55 - font-size: 14px;
56 - }
57 - .footer {
58 - margin-top: 30px;
59 - padding-top: 20px;
60 - border-top: 1px solid #e5e7eb;
61 - font-size: 14px;
62 - color: #6b7280;
63 - text-align: center;
64 - }
65 - </style>
66 -</head>
67 -<body>
68 - <div class="email-container">
69 - <div class="header">
70 - <div class="logo">{{ app_name }}</div>
71 - </div>
72 -
73 - <h1>
74 - {% if locale == "fr" %}
75 - Mot de passe modifié
76 - {% else %}
77 - Password Changed
78 - {% endif %}
79 - </h1>
80 -
81 - <div class="content">
82 - {% if locale == "fr" %}
83 - <p>Bonjour {{ username }},</p>
84 - <p>Nous vous informons que le mot de passe de votre compte <strong>{{ app_name }}</strong> a été modifié avec succès.</p>
85 - <p><strong>Date et heure de modification :</strong> {{ changed_at }}</p>
86 - {% else %}
87 - <p>Hello {{ username }},</p>
88 - <p>We are informing you that the password for your <strong>{{ app_name }}</strong> account has been successfully changed.</p>
89 - <p><strong>Date and time of change:</strong> {{ changed_at }}</p>
90 - {% endif %}
91 - </div>
92 -
93 - <div class="info-box">
94 - {% if locale == "fr" %}
95 - <p><strong>ℹ️ Information :</strong></p>
96 - <p>Si vous n'avez pas effectué cette modification, veuillez contacter immédiatement le support technique et réinitialiser votre mot de passe.</p>
97 - {% else %}
98 - <p><strong>ℹ️ Information:</strong></p>
99 - <p>If you did not make this change, please contact technical support immediately and reset your password.</p>
100 - {% endif %}
101 - </div>
102 -
103 - <div class="warning-box">
104 - {% if locale == "fr" %}
105 - <p><strong>⚠️ Sécurité :</strong></p>
106 - <ul style="margin: 10px 0; padding-left: 20px;">
107 - <li>Ne partagez jamais votre mot de passe avec qui que ce soit</li>
108 - <li>Utilisez un mot de passe unique et fort</li>
109 - <li>Si vous suspectez une activité suspecte, changez votre mot de passe immédiatement</li>
110 - </ul>
111 - {% else %}
112 - <p><strong>⚠️ Security:</strong></p>
113 - <ul style="margin: 10px 0; padding-left: 20px;">
114 - <li>Never share your password with anyone</li>
115 - <li>Use a unique and strong password</li>
116 - <li>If you suspect suspicious activity, change your password immediately</li>
117 - </ul>
118 - {% endif %}
119 - </div>
120 -
121 - <div class="footer">
122 - {% if locale == "fr" %}
123 - <p>Cet email a été envoyé automatiquement pour votre sécurité.</p>
124 - <p>Si vous avez des questions, contactez le support technique.</p>
125 - {% else %}
126 - <p>This email was sent automatically for your security.</p>
127 - <p>If you have any questions, please contact technical support.</p>
128 - {% endif %}
129 - <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
130 - © {{ current_year }} {{ app_name }}. Tous droits réservés.
131 - </p>
132 - </div>
133 - </div>
134 -</body>
135 -</html>
D templates/emails/password_reset.html
+0 -164
@@ -1,164 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="{{ locale }}">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>{% if locale == "fr" %}Réinitialisation de mot de passe{% else %}Password Reset{% endif %}</title>
7 - <style>
8 - body {
9 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 - line-height: 1.6;
11 - color: #333;
12 - max-width: 600px;
13 - margin: 0 auto;
14 - padding: 20px;
15 - background-color: #f4f4f4;
16 - }
17 - .email-container {
18 - background-color: #ffffff;
19 - border-radius: 8px;
20 - padding: 30px;
21 - box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 - }
23 - .header {
24 - text-align: center;
25 - margin-bottom: 30px;
26 - }
27 - .logo {
28 - font-size: 24px;
29 - font-weight: bold;
30 - color: #2563eb;
31 - margin-bottom: 10px;
32 - }
33 - h1 {
34 - color: #1f2937;
35 - font-size: 24px;
36 - margin-bottom: 20px;
37 - }
38 - .content {
39 - margin-bottom: 30px;
40 - }
41 - .button-container {
42 - text-align: center;
43 - margin: 30px 0;
44 - }
45 - .button {
46 - display: inline-block;
47 - padding: 12px 30px;
48 - background-color: #2563eb;
49 - color: #ffffff !important;
50 - text-decoration: none;
51 - border-radius: 6px;
52 - font-weight: 600;
53 - font-size: 16px;
54 - }
55 - .button:hover {
56 - background-color: #1d4ed8;
57 - }
58 - .link-fallback {
59 - margin-top: 20px;
60 - padding: 15px;
61 - background-color: #f3f4f6;
62 - border-radius: 6px;
63 - word-break: break-all;
64 - font-size: 14px;
65 - color: #6b7280;
66 - }
67 - .footer {
68 - margin-top: 30px;
69 - padding-top: 20px;
70 - border-top: 1px solid #e5e7eb;
71 - font-size: 14px;
72 - color: #6b7280;
73 - text-align: center;
74 - }
75 - .warning {
76 - margin-top: 20px;
77 - padding: 15px;
78 - background-color: #fef3c7;
79 - border-left: 4px solid #f59e0b;
80 - border-radius: 4px;
81 - font-size: 14px;
82 - }
83 - </style>
84 -</head>
85 -<body>
86 - <div class="email-container">
87 - <div class="header">
88 - <div class="logo">{{ app_name }}</div>
89 - </div>
90 -
91 - <h1>
92 - {% if locale == "fr" %}
93 - Réinitialisation de votre mot de passe
94 - {% else %}
95 - Reset Your Password
96 - {% endif %}
97 - </h1>
98 -
99 - <div class="content">
100 - {% if locale == "fr" %}
101 - <p>Bonjour {{ username }},</p>
102 - <p>Vous avez demandé à réinitialiser votre mot de passe pour votre compte {{ app_name }}.</p>
103 - <p>Cliquez sur le bouton ci-dessous pour réinitialiser votre mot de passe :</p>
104 - {% else %}
105 - <p>Hello {{ username }},</p>
106 - <p>You have requested to reset your password for your {{ app_name }} account.</p>
107 - <p>Click the button below to reset your password:</p>
108 - {% endif %}
109 - </div>
110 -
111 - <div class="button-container">
112 - <a href="{{ reset_url }}" class="button">
113 - {% if locale == "fr" %}
114 - Réinitialiser mon mot de passe
115 - {% else %}
116 - Reset My Password
117 - {% endif %}
118 - </a>
119 - </div>
120 -
121 - <div class="link-fallback">
122 - {% if locale == "fr" %}
123 - <p><strong>Le bouton ne fonctionne pas ?</strong></p>
124 - <p>Copiez et collez ce lien dans votre navigateur :</p>
125 - {% else %}
126 - <p><strong>Button not working?</strong></p>
127 - <p>Copy and paste this link into your browser:</p>
128 - {% endif %}
129 - <p style="margin-top: 10px; word-break: break-all;">{{ reset_url }}</p>
130 - </div>
131 -
132 - <div class="warning">
133 - {% if locale == "fr" %}
134 - <p><strong>⚠️ Important :</strong></p>
135 - <ul style="margin: 10px 0; padding-left: 20px;">
136 - <li>Ce lien est valide pendant <strong>1 heure</strong> uniquement</li>
137 - <li>Si vous n'avez pas demandé cette réinitialisation, ignorez cet email</li>
138 - <li>Votre mot de passe actuel restera inchangé si vous n'utilisez pas ce lien</li>
139 - </ul>
140 - {% else %}
141 - <p><strong>⚠️ Important:</strong></p>
142 - <ul style="margin: 10px 0; padding-left: 20px;">
143 - <li>This link is valid for <strong>1 hour</strong> only</li>
144 - <li>If you did not request this reset, please ignore this email</li>
145 - <li>Your current password will remain unchanged if you don't use this link</li>
146 - </ul>
147 - {% endif %}
148 - </div>
149 -
150 - <div class="footer">
151 - {% if locale == "fr" %}
152 - <p>Cet email a été envoyé automatiquement, merci de ne pas y répondre.</p>
153 - <p>Si vous avez des questions, contactez le support technique.</p>
154 - {% else %}
155 - <p>This email was sent automatically, please do not reply.</p>
156 - <p>If you have any questions, please contact technical support.</p>
157 - {% endif %}
158 - <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
159 - © {{ current_year }} {{ app_name }}. Tous droits réservés.
160 - </p>
161 - </div>
162 - </div>
163 -</body>
164 -</html>
D templates/emails/welcome.html
+0 -176
@@ -1,176 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="{{ locale }}">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>{% if locale == "fr" %}Bienvenue{% else %}Welcome{% endif %}</title>
7 - <style>
8 - body {
9 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 - line-height: 1.6;
11 - color: #333;
12 - max-width: 600px;
13 - margin: 0 auto;
14 - padding: 20px;
15 - background-color: #f4f4f4;
16 - }
17 - .email-container {
18 - background-color: #ffffff;
19 - border-radius: 8px;
20 - padding: 30px;
21 - box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 - }
23 - .header {
24 - text-align: center;
25 - margin-bottom: 30px;
26 - }
27 - .logo {
28 - font-size: 28px;
29 - font-weight: bold;
30 - color: #2563eb;
31 - margin-bottom: 10px;
32 - }
33 - .welcome-icon {
34 - font-size: 48px;
35 - margin-bottom: 20px;
36 - }
37 - h1 {
38 - color: #1f2937;
39 - font-size: 28px;
40 - margin-bottom: 20px;
41 - text-align: center;
42 - }
43 - .content {
44 - margin-bottom: 30px;
45 - }
46 - .button-container {
47 - text-align: center;
48 - margin: 30px 0;
49 - }
50 - .button {
51 - display: inline-block;
52 - padding: 14px 32px;
53 - background-color: #10b981;
54 - color: #ffffff !important;
55 - text-decoration: none;
56 - border-radius: 6px;
57 - font-weight: 600;
58 - font-size: 16px;
59 - }
60 - .button:hover {
61 - background-color: #059669;
62 - }
63 - .features-list {
64 - margin: 20px 0;
65 - padding: 20px;
66 - background-color: #f3f4f6;
67 - border-radius: 6px;
68 - }
69 - .feature-item {
70 - margin: 12px 0;
71 - padding-left: 25px;
72 - position: relative;
73 - }
74 - .feature-item::before {
75 - content: "✓";
76 - position: absolute;
77 - left: 0;
78 - color: #10b981;
79 - font-weight: bold;
80 - font-size: 18px;
81 - }
82 - .info-box {
83 - margin-top: 20px;
84 - padding: 15px;
85 - background-color: #dbeafe;
86 - border-left: 4px solid #3b82f6;
87 - border-radius: 4px;
88 - font-size: 14px;
89 - }
90 - .footer {
91 - margin-top: 30px;
92 - padding-top: 20px;
93 - border-top: 1px solid #e5e7eb;
94 - font-size: 14px;
95 - color: #6b7280;
96 - text-align: center;
97 - }
98 - </style>
99 -</head>
100 -<body>
101 - <div class="email-container">
102 - <div class="header">
103 - <div class="logo">{{ app_name }}</div>
104 - <div class="welcome-icon">🎉</div>
105 - </div>
106 -
107 - <h1>
108 - {% if locale == "fr" %}
109 - Bienvenue sur {{ app_name }} !
110 - {% else %}
111 - Welcome to {{ app_name }}!
112 - {% endif %}
113 - </h1>
114 -
115 - <div class="content">
116 - {% if locale == "fr" %}
117 - <p>Bonjour {{ username }},</p>
118 - <p>Nous sommes ravis de vous accueillir sur <strong>{{ app_name }}</strong> ! Votre compte a été activé avec succès.</p>
119 - <p>Vous pouvez maintenant profiter de toutes les fonctionnalités de la plateforme.</p>
120 - {% else %}
121 - <p>Hello {{ username }},</p>
122 - <p>We are delighted to welcome you to <strong>{{ app_name }}</strong>! Your account has been successfully activated.</p>
123 - <p>You can now enjoy all the platform's features.</p>
124 - {% endif %}
125 - </div>
126 -
127 - <div class="button-container">
128 - <a href="{{ login_url }}" class="button">
129 - {% if locale == "fr" %}
130 - Se connecter
131 - {% else %}
132 - Log In
133 - {% endif %}
134 - </a>
135 - </div>
136 -
137 - <div class="features-list">
138 - {% if locale == "fr" %}
139 - <p style="font-weight: 600; margin-bottom: 15px;">Ce que vous pouvez faire :</p>
140 - <div class="feature-item">Accéder à votre tableau de bord personnalisé</div>
141 - <div class="feature-item">Gérer votre profil et vos préférences</div>
142 - <div class="feature-item">Profiter de toutes les fonctionnalités disponibles</div>
143 - {% else %}
144 - <p style="font-weight: 600; margin-bottom: 15px;">What you can do:</p>
145 - <div class="feature-item">Access your personalized dashboard</div>
146 - <div class="feature-item">Manage your profile and preferences</div>
147 - <div class="feature-item">Enjoy all available features</div>
148 - {% endif %}
149 - </div>
150 -
151 - <div class="info-box">
152 - {% if locale == "fr" %}
153 - <p><strong>ℹ️ Besoin d'aide ?</strong></p>
154 - <p>Si vous avez des questions ou besoin d'assistance, n'hésitez pas à consulter notre documentation ou à contacter le support technique.</p>
155 - {% else %}
156 - <p><strong>ℹ️ Need Help?</strong></p>
157 - <p>If you have any questions or need assistance, feel free to check our documentation or contact technical support.</p>
158 - {% endif %}
159 - </div>
160 -
161 - <div class="footer">
162 - {% if locale == "fr" %}
163 - <p>Merci de nous faire confiance et bonne utilisation de {{ app_name }} !</p>
164 - <p>Si vous avez des questions, contactez le support technique.</p>
165 - {% else %}
166 - <p>Thank you for trusting us and enjoy using {{ app_name }}!</p>
167 - <p>If you have any questions, please contact technical support.</p>
168 - {% endif %}
169 - <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
170 - © {{ current_year }} {{ app_name }}. Tous droits réservés.
171 - </p>
172 - </div>
173 - </div>
174 -</body>
175 -</html>
176 -
D templates/errors/401.html
+0 -36
@@ -1,36 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="fr">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>401 - Non Authentifié</title>
7 - <link href="/static/css/tailwind.css" rel="stylesheet">
8 -</head>
9 -<body class="bg-base-200 min-h-screen flex items-center justify-center px-4">
10 - <div class="max-w-md w-full text-center">
11 - <!-- Error Icon -->
12 - <div class="mb-8">
13 - <div class="inline-flex items-center justify-center w-24 h-24 rounded-full bg-error/20">
14 - <span class="text-6xl">🔒</span>
15 - </div>
16 - </div>
17 -
18 - <!-- Error Message -->
19 - <h1 class="text-4xl font-bold text-base-content mb-4">401</h1>
20 - <h2 class="text-2xl font-semibold text-base-content mb-4">Non Authentifié</h2>
21 - <p class="text-base-content/70 mb-8">
22 - Vous devez vous connecter pour accéder à cette page.
23 - </p>
24 -
25 - <!-- Actions -->
26 - <div class="space-y-3">
27 - <a href="/login" class="btn btn-primary w-full">
28 - Se Connecter
29 - </a>
30 - <a href="/" class="btn btn-ghost w-full">
31 - Retour à l'Accueil
32 - </a>
33 - </div>
34 - </div>
35 -</body>
36 -</html>
D templates/errors/403.html
+0 -36
@@ -1,36 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="fr">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>403 - Accès Refusé</title>
7 - <link href="/static/css/tailwind.css" rel="stylesheet">
8 -</head>
9 -<body class="bg-base-200 min-h-screen flex items-center justify-center px-4">
10 - <div class="max-w-md w-full text-center">
11 - <!-- Error Icon -->
12 - <div class="mb-8">
13 - <div class="inline-flex items-center justify-center w-24 h-24 rounded-full bg-warning/20">
14 - <span class="text-6xl">⛔</span>
15 - </div>
16 - </div>
17 -
18 - <!-- Error Message -->
19 - <h1 class="text-4xl font-bold text-base-content mb-4">403</h1>
20 - <h2 class="text-2xl font-semibold text-base-content mb-4">Accès Refusé</h2>
21 - <p class="text-base-content/70 mb-8">
22 - Vous n'avez pas les permissions nécessaires pour accéder à cette page.
23 - </p>
24 -
25 - <!-- Actions -->
26 - <div class="space-y-3">
27 - <a href="/home" class="btn btn-primary w-full">
28 - Retour à l'Accueil
29 - </a>
30 - <a href="/profile" class="btn btn-ghost w-full">
31 - Mon profil
32 - </a>
33 - </div>
34 - </div>
35 -</body>
36 -</html>
D templates/errors/500.html
+0 -41
@@ -1,41 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="fr">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>500 - Erreur Serveur</title>
7 - <link href="/static/css/tailwind.css" rel="stylesheet">
8 -</head>
9 -<body class="bg-base-200 min-h-screen flex items-center justify-center px-4">
10 - <div class="max-w-md w-full text-center">
11 - <!-- Error Icon -->
12 - <div class="mb-8">
13 - <div class="inline-flex items-center justify-center w-24 h-24 rounded-full bg-error/20">
14 - <span class="text-6xl">⚠️</span>
15 - </div>
16 - </div>
17 -
18 - <!-- Error Message -->
19 - <h1 class="text-4xl font-bold text-base-content mb-4">500</h1>
20 - <h2 class="text-2xl font-semibold text-base-content mb-4">Erreur Serveur</h2>
21 - <p class="text-base-content/70 mb-8">
22 - Une erreur interne est survenue. Nos équipes ont été notifiées et travaillent sur le problème.
23 - </p>
24 -
25 - <!-- Actions -->
26 - <div class="space-y-3">
27 - <button onclick="window.location.reload()" class="btn btn-primary w-full">
28 - Réessayer
29 - </button>
30 - <a href="/home" class="btn btn-ghost w-full">
31 - Retour à l'Accueil
32 - </a>
33 - </div>
34 -
35 - <!-- Support Info -->
36 - <div class="mt-8 text-sm text-base-content/50">
37 - <p>Si le problème persiste, contactez le support.</p>
38 - </div>
39 - </div>
40 -</body>
41 -</html>
D templates/fragments/form_message.html
+0 -23
@@ -1,23 +0,0 @@
1 -{% if message_type == "success" %}
2 -<div class="alert alert-success mb-4" role="alert">
3 - <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
4 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
5 - </svg>
6 - <span>{{ message }}</span>
7 -</div>
8 -{% else %}
9 -<div class="alert alert-error mb-4" role="alert">
10 - <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
11 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
12 - </svg>
13 - <span>{{ message }}</span>
14 -</div>
15 -{% endif %}
16 -
17 -
18 -
19 -
20 -
21 -
22 -
23 -
D templates/fragments/user_menu_dropdown.html
+0 -85
@@ -1,85 +0,0 @@
1 -<!-- User Information -->
2 -<li class="menu-title">
3 - <div class="flex items-center gap-3 px-2 py-2">
4 - <!-- Avatar -->
5 - <div class="avatar placeholder">
6 - <div class="bg-primary text-primary-content rounded-full w-10 h-10"
7 - style="background-color: {{ avatar_color }} !important;">
8 - <span class="text-sm font-semibold">{{ initials }}</span>
9 - </div>
10 - </div>
11 -
12 - <!-- User Info -->
13 - <div class="flex-1 min-w-0">
14 - <div class="text-sm font-medium text-base-content truncate">{{ username }}</div>
15 - <div class="text-xs text-base-content/60 truncate">{{ email }}</div>
16 - </div>
17 - </div>
18 -
19 - <!-- Roles -->
20 - {% if !roles_text.is_empty() %}
21 - <div class="px-2 mt-1">
22 - <span class="badge badge-primary badge-sm">{{ roles_text }}</span>
23 - </div>
24 - {% endif %}
25 -</li>
26 -
27 -<!-- Menu Items -->
28 -<li>
29 - <a href="/profile" class="gap-2">
30 - <span>👤</span>
31 - <span>{{ user_menu_profile }}</span>
32 - </a>
33 -</li>
34 -
35 -<!-- Divider -->
36 -<li><hr class="my-2"></li>
37 -
38 -<!-- Logout -->
39 -<li>
40 - <button
41 - type="button"
42 - onclick="handleLogout()"
43 - class="w-full text-left text-error hover:bg-error/10 gap-2"
44 - >
45 - <span>🚪</span>
46 - <span>{{ user_menu_logout }}</span>
47 - </button>
48 -</li>
49 -
50 -<script>
51 -function handleLogout() {
52 - // Send logout request with credentials (cookies)
53 - fetch('/api/v1/auth/logout', {
54 - method: 'POST',
55 - headers: {
56 - 'Content-Type': 'application/json',
57 - 'Accept': 'text/html,application/json'
58 - },
59 - credentials: 'include' // Include cookies
60 - })
61 - .then(response => {
62 - if (response.ok) {
63 - // Check if response is a redirect
64 - if (response.redirected || response.status === 200) {
65 - // Redirect to login page
66 - window.location.href = '/login';
67 - } else {
68 - return response.json().then(data => {
69 - // JSON response from API
70 - window.location.href = '/login';
71 - });
72 - }
73 - } else {
74 - // Error - still redirect to login
75 - console.error('Logout error:', response.status);
76 - window.location.href = '/login';
77 - }
78 - })
79 - .catch(error => {
80 - console.error('Logout error:', error);
81 - // Even on error, redirect to login
82 - window.location.href = '/login';
83 - });
84 -}
85 -</script>
D templates/home.html
+0 -37
@@ -1,37 +0,0 @@
1 -{% extends "base.html" %}
2 -
3 -{% block navbar %}
4 -{% include "components/navbar.html" %}
5 -{% endblock %}
6 -
7 -{% block content %}
8 -<!-- Welcome Section -->
9 -<div class="mb-8">
10 - <h1 class="text-3xl font-bold text-base-content mb-2">{{ welcome_message }}</h1>
11 - <p class="text-base-content/70">{{ home_subtitle }}</p>
12 -</div>
13 -
14 -<!-- Account Stats -->
15 -<div class="mb-12">
16 - <h2 class="text-xl font-semibold text-base-content mb-4">{{ home_my_account }}</h2>
17 - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
18 - {% for stat in account_stats %}
19 - <div class="card bg-base-100 shadow-md hover:shadow-xl transition-shadow">
20 - <div class="card-body p-6">
21 - <div class="flex items-center justify-between mb-2">
22 - <span class="text-2xl">
23 - {% if stat.icon == "user" %}👤
24 - {% elif stat.icon == "mail" %}✉️
25 - {% elif stat.icon == "calendar" %}📅
26 - {% elif stat.icon == "shield" %}🛡️
27 - {% else %}📊{% endif %}
28 - </span>
29 - </div>
30 - <div class="text-sm text-base-content/70 mb-1">{{ stat.label }}</div>
31 - <div class="text-lg font-semibold text-base-content">{{ stat.value }}</div>
32 - </div>
33 - </div>
34 - {% endfor %}
35 - </div>
36 -</div>
37 -{% endblock %}
D templates/login.html
+0 -599
@@ -1,599 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="{{ current_locale }}">
3 -<head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <meta name="description" content="{{ page_description }}">
7 - <title>{{ page_title }}</title>
8 -
9 - <!-- Tailwind CSS (local) -->
10 - <link href="/static/css/tailwind.css" rel="stylesheet">
11 -
12 - <!-- Custom CSS -->
13 - <link href="/static/css/custom.css" rel="stylesheet">
14 -
15 - <!-- HTMX (local) -->
16 - <script src="/static/js/htmx.min.js"></script>
17 -
18 - <!-- Alpine.js (local) -->
19 - <script src="/static/js/alpine.min.js" defer></script>
20 -</head>
21 -<body class="bg-base-200 min-h-screen flex items-center justify-center px-4">
22 - <div class="max-w-md w-full">
23 - <!-- Language Selector (Feature 014 - T065) -->
24 - <div class="flex justify-end mb-4">
25 - <div class="dropdown dropdown-end">
26 - <label tabindex="0" class="btn btn-ghost btn-sm gap-2">
27 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
28 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
29 - </svg>
30 - <span id="current-lang-display">
31 - {% for (code, name, is_current) in supported_languages %}
32 - {% if is_current %}{{ name }}{% endif %}
33 - {% endfor %}
34 - </span>
35 - <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
36 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
37 - </svg>
38 - </label>
39 - <ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow">
40 - {% for (code, name, is_current) in supported_languages %}
41 - <li>
42 - <a href="#" onclick="return window.changeLanguage('{{ code }}');" class="{% if is_current %}active{% endif %}">
43 - {{ name }}
44 - {% if is_current %}
45 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
46 - <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
47 - </svg>
48 - {% endif %}
49 - </a>
50 - </li>
51 - {% endfor %}
52 - </ul>
53 - </div>
54 - </div>
55 -
56 - <!-- Logo and Title -->
57 - <div class="text-center mb-8">
58 - <div class="inline-flex items-center justify-center w-16 h-16 mb-4">
59 - <img src="/static/images/logo.svg" alt="{{ app_name }}" class="h-16 w-16">
60 - </div>
61 - <h1 class="text-3xl font-bold text-base-content mb-2">{{ app_name }}</h1>
62 - <p class="text-base-content/70">{{ subtitle }}</p>
63 - </div>
64 -
65 - <!-- Login/Register Form Card -->
66 - <div class="card bg-base-100 shadow-xl p-8">
67 - <!-- Tabs for Login/Register -->
68 - <div class="tabs tabs-boxed mb-6">
69 - <a id="tab-login" class="tab tab-active" onclick="showLogin()">{{ tab_login }}</a>
70 - <a id="tab-register" class="tab" onclick="showRegister()">{{ tab_register }}</a>
71 - </div>
72 -
73 - <!-- Login Section -->
74 - <div id="login-section">
75 - <h2 class="text-2xl font-semibold text-base-content mb-6 text-center">{{ login_title }}</h2>
76 -
77 - <!-- Error Message (if any) -->
78 - <div id="error-message" role="alert" class="hidden mb-4 alert alert-error">
79 - <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
80 - <span id="error-text"></span>
81 - </div>
82 -
83 - <!-- Success Message (if any) -->
84 - <div id="success-message" role="alert" class="hidden mb-4 alert alert-success">
85 - <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
86 - <span id="success-text"></span>
87 - </div>
88 -
89 - <!-- Resend verification CTA -->
90 - <div id="resend-block" class="hidden mb-4 space-y-2">
91 - <p class="text-sm text-base-content">{{ email_unverified_message }}</p>
92 - <button id="resend-btn" type="button" class="btn btn-outline btn-primary w-full">{{ resend_button }}</button>
93 - <p id="resend-status" class="text-sm text-success hidden"></p>
94 - </div>
95 -
96 - <!-- Login Form -->
97 - <form
98 - id="login-form"
99 - class="space-y-4">
100 -
101 - <!-- Username -->
102 - <div class="form-control">
103 - <label for="username" class="label">
104 - <span class="label-text">{{ username_label }}</span>
105 - </label>
106 - <input
107 - type="text"
108 - id="username"
109 - name="username"
110 - required
111 - autocomplete="username"
112 - class="input input-bordered w-full"
113 - placeholder="{{ username_placeholder }}">
114 - </div>
115 -
116 - <!-- Password -->
117 - <div class="form-control">
118 - <label for="password" class="label">
119 - <span class="label-text">{{ password_label }}</span>
120 - </label>
121 - <input
122 - type="password"
123 - id="password"
124 - name="password"
125 - required
126 - autocomplete="current-password"
127 - class="input input-bordered w-full"
128 - placeholder="{{ password_placeholder }}">
129 - </div>
130 -
131 - <!-- Remember Me (optional) -->
132 - <div class="form-control">
133 - <label class="label cursor-pointer justify-start gap-2">
134 - <input
135 - type="checkbox"
136 - id="remember"
137 - name="remember"
138 - class="checkbox checkbox-primary">
139 - <span class="label-text">{{ remember_me }}</span>
140 - </label>
141 - </div>
142 -
143 - <!-- Submit Button -->
144 - <div class="form-control mt-6">
145 - <button
146 - type="submit"
147 - class="btn btn-primary w-full">
148 - {{ submit_button }}
149 - </button>
150 - </div>
151 - </form>
152 -
153 - <!-- Additional Links -->
154 - <div class="mt-6 text-center space-y-2">
155 - <a href="/forgot-password" class="text-sm text-primary hover:underline">
156 - {{ forgot_password }}
157 - </a>
158 - <p class="text-sm text-base-content/70">
159 - {{ email_not_verified }}
160 - <a href="/resend-verification" class="text-primary hover:underline">
161 - {{ resend_verification }}
162 - </a>
163 - </p>
164 - </div>
165 - </div>
166 -
167 - <!-- Register Section -->
168 - <div id="register-section" class="hidden">
169 - <h2 class="text-2xl font-semibold text-base-content mb-6 text-center">{{ register_title }}</h2>
170 -
171 - <!-- Error Message (if any) -->
172 - <div id="register-error-message" role="alert" class="hidden mb-4 alert alert-error">
173 - <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
174 - <span id="register-error-text"></span>
175 - </div>
176 -
177 - <!-- Success Message (if any) -->
178 - <div id="register-success-message" role="alert" class="hidden mb-4 alert alert-success">
179 - <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
180 - <span id="register-success-text"></span>
181 - </div>
182 -
183 - <!-- Register Form -->
184 - <form id="register-form" class="space-y-4">
185 - <!-- Username -->
186 - <div class="form-control">
187 - <label for="register-username" class="label">
188 - <span class="label-text">{{ username_label }}</span>
189 - </label>
190 - <input
191 - type="text"
192 - id="register-username"
193 - name="username"
194 - required
195 - minlength="3"
196 - maxlength="50"
197 - autocomplete="username"
198 - class="input input-bordered w-full"
199 - placeholder="{{ username_placeholder_register }}">
200 - </div>
201 -
202 - <!-- Email -->
203 - <div class="form-control">
204 - <label for="register-email" class="label">
205 - <span class="label-text">{{ email_label }}</span>
206 - </label>
207 - <input
208 - type="email"
209 - id="register-email"
210 - name="email"
211 - required
212 - autocomplete="email"
213 - class="input input-bordered w-full"
214 - placeholder="{{ email_placeholder }}">
215 - </div>
216 -
217 - <!-- Password -->
218 - <div class="form-control">
219 - <label for="register-password" class="label">
220 - <span class="label-text">{{ password_label }}</span>
221 - </label>
222 - <input
223 - type="password"
224 - id="register-password"
225 - name="password"
226 - required
227 - minlength="{{ pw_min_length }}"
228 - autocomplete="new-password"
229 - class="input input-bordered w-full"
230 - placeholder="{{ password_placeholder_register }}">
231 - </div>
232 -
233 - <!-- Submit Button -->
234 - <div class="form-control mt-6">
235 - <button
236 - type="submit"
237 - class="btn btn-primary w-full">
238 - {{ register_submit }}
239 - </button>
240 - </div>
241 - </form>
242 -
243 - <!-- Additional Links -->
244 - <div class="mt-6 text-center">
245 - <p class="text-sm text-base-content/70">
246 - {{ already_have_account }}
247 - <a href="#" onclick="showLogin(); return false;" class="text-primary hover:underline">
248 - {{ sign_in_link }}
249 - </a>
250 - </p>
251 - </div>
252 - </div>
253 - </div>
254 -
255 - <!-- Footer -->
256 - <div class="mt-8 text-center text-sm text-base-content/50">
257 - <p>{{ app_name }} v{{ app_version }} - {{ footer_text }}</p>
258 - </div>
259 - </div>
260 -
261 - <script>
262 - // Translated messages (Feature 014 - i18n login page)
263 - const i18nMessages = {
264 - successMessage: "{{ success_message }}",
265 - errorMessage: "{{ error_message }}",
266 - resendSuccess: "{{ resend_success }}",
267 - resendError: "{{ resend_error }}",
268 - resendPrompt: "{{ resend_prompt }}",
269 - registerSuccess: "{{ register_success }}",
270 - registerError: "{{ register_error }}",
271 - };
272 -
273 - // Tab switching functions
274 - function showLogin() {
275 - document.getElementById('login-section').classList.remove('hidden');
276 - document.getElementById('register-section').classList.add('hidden');
277 - document.getElementById('tab-login').classList.add('tab-active');
278 - document.getElementById('tab-register').classList.remove('tab-active');
279 - }
280 -
281 - function showRegister() {
282 - document.getElementById('login-section').classList.add('hidden');
283 - document.getElementById('register-section').classList.remove('hidden');
284 - document.getElementById('tab-login').classList.remove('tab-active');
285 - document.getElementById('tab-register').classList.add('tab-active');
286 - }
287 -
288 - function setErrorMessage(message) {
289 - const errorDiv = document.getElementById('error-message');
290 - const errorText = document.getElementById('error-text');
291 - if (errorDiv && errorText) {
292 - errorText.textContent = message || i18nMessages.errorMessage;
293 - errorDiv.classList.remove('hidden');
294 - if (isUnverifiedMessage(message)) {
295 - showResendBlock();
296 - }
297 - }
298 - }
299 -
300 - function normalizeText(text) {
301 - return text
302 - .normalize('NFD')
303 - .replace(/\p{Diacritic}/gu, '')
304 - .toLowerCase();
305 - }
306 -
307 - function isUnverifiedMessage(msg) {
308 - if (!msg) return false;
309 - const lower = normalizeText(msg);
310 - return (
311 - lower.includes('non verifie') ||
312 - lower.includes('not verified') ||
313 - (lower.includes('email') && lower.includes('verifi'))
314 - );
315 - }
316 -
317 - // Handle login response
318 - function handleLoginResponse(event) {
319 - const xhr = event.detail.xhr;
320 -
321 - if (xhr.status === 200) {
322 - // Login successful
323 - const response = JSON.parse(xhr.responseText);
324 -
325 - // Hide error message if visible
326 - const errorDiv = document.getElementById('error-message');
327 - if (errorDiv) {
328 - errorDiv.classList.add('hidden');
329 - }
330 -
331 - // Show success message
332 - const successDiv = document.getElementById('success-message');
333 - if (successDiv) {
334 - document.getElementById('success-text').textContent = i18nMessages.successMessage;
335 - successDiv.classList.remove('hidden');
336 - }
337 -
338 - // Redirect to home or redirect parameter
339 - const urlParams = new URLSearchParams(window.location.search);
340 - const redirect = urlParams.get('redirect') || '/home';
341 -
342 - // Small delay for user to see success message
343 - setTimeout(() => {
344 - window.location.href = redirect;
345 - }, 500);
346 - } else {
347 - // Login failed
348 - try {
349 - const errorResponse = JSON.parse(xhr.responseText);
350 - const message =
351 - errorResponse.error ||
352 - errorResponse.message ||
353 - i18nMessages.errorMessage;
354 - setErrorMessage(message);
355 - if (isUnverifiedMessage(message)) {
356 - showResendBlock();
357 - }
358 - } catch (e) {
359 - setErrorMessage(i18nMessages.errorMessage);
360 - }
361 - }
362 - }
363 -
364 - // Handle form submission with JSON
365 - document.getElementById('login-form').addEventListener('submit', function(e) {
366 - e.preventDefault();
367 -
368 - const username = document.getElementById('username').value;
369 - const password = document.getElementById('password').value;
370 -
371 - // Hide previous error
372 - const errorDiv = document.getElementById('error-message');
373 - if (errorDiv) {
374 - errorDiv.classList.add('hidden');
375 - }
376 - hideResendBlock();
377 -
378 - // Create JSON payload
379 - const payload = JSON.stringify({
380 - username: username,
381 - password: password
382 - });
383 -
384 - // Use fetch API to send JSON request
385 - fetch('/api/v1/auth/login', {
386 - method: 'POST',
387 - headers: {
388 - 'Content-Type': 'application/json',
389 - },
390 - body: payload,
391 - credentials: 'include' // Include cookies for session
392 - })
393 - .then(async response => {
394 - if (response.ok) {
395 - return response.json();
396 - } else {
397 - let msg = i18nMessages.errorMessage;
398 - try {
399 - const err = await response.json();
400 - msg = err.error || err.message || msg;
401 - } catch (_) {
402 - // ignore parse errors
403 - }
404 - throw new Error(msg);
405 - }
406 - })
407 - .then(data => {
408 - // Login successful
409 - const successDiv = document.getElementById('success-message');
410 - if (successDiv) {
411 - document.getElementById('success-text').textContent = i18nMessages.successMessage;
412 - successDiv.classList.remove('hidden');
413 - }
414 -
415 - // T130: Check if user must change password
416 - if (data.user && data.user.must_change_password === true) {
417 - // Redirect to password change page with must_change flag
418 - setTimeout(() => {
419 - window.location.href = '/settings/password?must_change=true';
420 - }, 500);
421 - return;
422 - }
423 -
424 - // Redirect to home or redirect parameter
425 - const urlParams = new URLSearchParams(window.location.search);
426 - const redirect = urlParams.get('redirect') || '/home';
427 -
428 - // Small delay for user to see success message
429 - setTimeout(() => {
430 - window.location.href = redirect;
431 - }, 500);
432 - })
433 - .catch(error => {
434 - const msg = error.message || i18nMessages.errorMessage;
435 - setErrorMessage(msg);
436 - if (isUnverifiedMessage(msg)) {
437 - showResendBlock();
438 - }
439 - });
440 - });
441 -
442 - // Register form handler
443 - document.getElementById('register-form').addEventListener('submit', function(e) {
444 - e.preventDefault();
445 -
446 - const username = document.getElementById('register-username').value;
447 - const email = document.getElementById('register-email').value;
448 - const password = document.getElementById('register-password').value;
449 -
450 - // Hide previous messages
451 - const errorDiv = document.getElementById('register-error-message');
452 - const successDiv = document.getElementById('register-success-message');
453 - if (errorDiv) errorDiv.classList.add('hidden');
454 - if (successDiv) successDiv.classList.add('hidden');
455 -
456 - // Create JSON payload
457 - const payload = JSON.stringify({
458 - username: username,
459 - email: email,
460 - password: password
461 - });
462 -
463 - // Send registration request
464 - fetch('/api/v1/auth/register', {
465 - method: 'POST',
466 - headers: {
467 - 'Content-Type': 'application/json',
468 - },
469 - body: payload,
470 - credentials: 'include'
471 - })
472 - .then(async response => {
473 - if (response.ok) {
474 - return response.json();
475 - } else {
476 - let msg = i18nMessages.registerError;
477 - try {
478 - const err = await response.json();
479 - msg = err.error || err.message || msg;
480 - } catch (_) {
481 - // ignore parse errors
482 - }
483 - throw new Error(msg);
484 - }
485 - })
486 - .then(data => {
487 - // Registration successful
488 - if (successDiv) {
489 - document.getElementById('register-success-text').textContent =
490 - data.message || i18nMessages.registerSuccess;
491 - successDiv.classList.remove('hidden');
492 - }
493 -
494 - // Clear form
495 - document.getElementById('register-form').reset();
496 -
497 - // Optionally redirect to login after 3 seconds
498 - setTimeout(() => {
499 - showLogin();
500 - if (successDiv) successDiv.classList.add('hidden');
501 - }, 3000);
502 - })
503 - .catch(error => {
504 - const msg = error.message || i18nMessages.registerError;
505 - if (errorDiv) {
506 - document.getElementById('register-error-text').textContent = msg;
507 - errorDiv.classList.remove('hidden');
508 - }
509 - });
510 - });
511 -
512 - function showResendBlock() {
513 - const block = document.getElementById('resend-block');
514 - if (block) {
515 - block.classList.remove('hidden');
516 - block.style.display = 'block';
517 - }
518 - }
519 -
520 - function hideResendBlock() {
521 - const block = document.getElementById('resend-block');
522 - const status = document.getElementById('resend-status');
523 - if (block) {
524 - block.classList.add('hidden');
525 - block.style.display = 'none';
526 - }
527 - if (status) {
528 - status.textContent = '';
529 - status.classList.add('hidden');
530 - }
531 - }
532 -
533 - document.getElementById('resend-btn')?.addEventListener('click', () => {
534 - const emailOrUsername = document.getElementById('username').value;
535 - const status = document.getElementById('resend-status');
536 - if (!emailOrUsername) {
537 - const errorDiv = document.getElementById('error-message');
538 - if (errorDiv) {
539 - document.getElementById('error-text').textContent = i18nMessages.resendPrompt;
540 - errorDiv.classList.remove('hidden');
541 - }
542 - return;
543 - }
544 - const isEmail = emailOrUsername.includes('@');
545 - const endpoint = isEmail
546 - ? '/api/v1/auth/resend-verification'
547 - : '/api/v1/auth/resend-verification/by-username';
548 - const payload = isEmail
549 - ? { email: emailOrUsername }
550 - : { username: emailOrUsername };
551 - fetch(endpoint, {
552 - method: 'POST',
553 - headers: { 'Content-Type': 'application/json' },
554 - body: JSON.stringify(payload),
555 - credentials: 'include'
556 - })
557 - .then(response => response.json())
558 - .then(data => {
559 - if (status) {
560 - status.textContent = data.message || i18nMessages.resendSuccess;
561 - status.classList.remove('hidden');
562 - }
563 - })
564 - .catch(() => {
565 - if (status) {
566 - status.textContent = i18nMessages.resendError;
567 - status.classList.remove('hidden');
568 - }
569 - });
570 - });
571 -
572 - // Language selector functionality (Feature 014 - T065)
573 - // changeLanguage is defined globally in base.html
574 - // For login page (which doesn't extend base.html), define it here
575 - if (typeof window.changeLanguage === 'undefined') {
576 - window.changeLanguage = function(locale) {
577 - console.log('changeLanguage called with locale:', locale);
578 -
579 - if (!['fr', 'en', 'de', 'es'].includes(locale)) {
580 - console.error('Invalid locale:', locale);
581 - return false;
582 - }
583 -
584 - // Set cookie with locale (expires in 1 year)
585 - document.cookie = `locale=${locale}; path=/; max-age=31536000; SameSite=Lax`;
586 - console.log('Cookie set to:', locale);
587 -
588 - // Reload page to apply new language
589 - window.location.reload();
590 - return false;
591 - };
592 - }
593 - </script>
594 -</body>
595 -</html>
596 -
597 -
598 -
599 -
D templates/profile.html
+0 -181
@@ -1,181 +0,0 @@
1 -{% extends "base.html" %}
2 -
3 -{% block title %}{{ page_title }}{% endblock %}
4 -
5 -{% block navbar %}
6 -{% include "components/navbar.html" %}
7 -{% endblock %}
8 -
9 -{% block content %}
10 -<div class="container mx-auto px-4 py-8">
11 - <!-- Breadcrumb -->
12 - <div class="text-sm breadcrumbs mb-6">
13 - <ul>
14 - <li><a href="/">{{ breadcrumb_home }}</a></li>
15 - <li><a href="/profile">{{ breadcrumb_settings }}</a></li>
16 - <li>{{ breadcrumb_profile }}</li>
17 - </ul>
18 - </div>
19 -
20 - <!-- Page Header -->
21 - <div class="mb-8">
22 - <h1 class="text-3xl font-bold mb-2">{{ title }}</h1>
23 - <p class="text-base-content/70">{{ subtitle }}</p>
24 - </div>
25 -
26 - <!-- Profile Form Card -->
27 - <div class="card bg-base-100 shadow-xl max-w-2xl">
28 - <div class="card-body">
29 - <h2 class="card-title mb-4">{{ account_info }}</h2>
30 -
31 - <form id="profileForm" method="POST" action="/profile" class="space-y-6">
32 - <!-- CSRF Token -->
33 - <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
34 -
35 - <!-- Username -->
36 - <div class="form-control">
37 - <label class="label">
38 - <span class="label-text font-semibold">{{ username_label }}</span>
39 - <span class="label-text-alt text-warning">{{ username_public }}</span>
40 - </label>
41 - <input
42 - type="text"
43 - name="username"
44 - value="{{ username }}"
45 - placeholder="{{ username_placeholder }}"
46 - class="input input-bordered w-full"
47 - minlength="3"
48 - maxlength="50"
49 - pattern="[a-zA-Z0-9_-]+"
50 - title="{{ username_pattern }}"
51 - required
52 - >
53 - <label class="label">
54 - <span class="label-text-alt">{{ username_help }}</span>
55 - </label>
56 - </div>
57 -
58 - <!-- Email -->
59 - <div class="form-control">
60 - <label class="label">
61 - <span class="label-text font-semibold">{{ email_label }}</span>
62 - <span class="label-text-alt text-info">{{ email_used_for }}</span>
63 - </label>
64 - <input
65 - type="email"
66 - name="email"
67 - value="{{ email }}"
68 - placeholder="{{ email_placeholder }}"
69 - class="input input-bordered w-full"
70 - required
71 - >
72 - <label class="label">
73 - <span class="label-text-alt">{{ email_warning }}</span>
74 - </label>
75 - </div>
76 -
77 - <!-- Alert Info -->
78 - <div class="alert alert-info">
79 - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
80 - <span>{{ email_change_info }}</span>
81 - </div>
82 -
83 - <!-- Form Actions -->
84 - <div class="card-actions justify-end">
85 - <a href="/profile" class="btn btn-ghost">{{ cancel }}</a>
86 - <button type="submit" class="btn btn-primary">
87 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
88 - {{ save_button }}
89 - </button>
90 - </div>
91 - </form>
92 - </div>
93 - </div>
94 -
95 - <!-- Additional Info Card -->
96 - <div class="card bg-base-200 shadow-xl max-w-2xl mt-6">
97 - <div class="card-body">
98 - <h3 class="card-title text-base">Informations supplémentaires</h3>
99 - <div class="grid grid-cols-2 gap-4 text-sm">
100 - <div>
101 - <p class="text-base-content/70">{{ account_created }}</p>
102 - <p class="font-medium">{{ created_at }}</p>
103 - </div>
104 - <div>
105 - <p class="text-base-content/70">{{ roles_label }}</p>
106 - <p class="font-medium">{{ roles }}</p>
107 - </div>
108 - <div>
109 - <p class="text-base-content/70">ID Utilisateur</p>
110 - <p class="font-mono text-xs">{{ user_id }}</p>
111 - </div>
112 - </div>
113 - </div>
114 - </div>
115 -
116 - <!-- Quick Links -->
117 - <div class="mt-8 flex gap-4 flex-wrap">
118 - <a href="/settings/password" class="btn btn-outline btn-secondary">
119 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
120 - Changer mon mot de passe
121 - </a>
122 - <a href="/settings/sessions" class="btn btn-outline">
123 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
124 - Gérer mes sessions
125 - </a>
126 - <a href="/settings/preferences" class="btn btn-outline">
127 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" /></svg>
128 - Préférences d'affichage
129 - </a>
130 - </div>
131 -</div>
132 -
133 -<script>
134 -// Form submission handling
135 -document.getElementById('profileForm').addEventListener('submit', async function(e) {
136 - e.preventDefault();
137 -
138 - const formData = new FormData(this);
139 - const submitBtn = this.querySelector('button[type="submit"]');
140 -
141 - // Disable submit button
142 - submitBtn.disabled = true;
143 - submitBtn.innerHTML = '<span class="loading loading-spinner"></span> Enregistrement...';
144 -
145 - try {
146 - const response = await fetch('/profile', {
147 - method: 'PUT',
148 - headers: {
149 - 'Content-Type': 'application/x-www-form-urlencoded',
150 - },
151 - body: new URLSearchParams(formData)
152 - });
153 -
154 - const result = await response.json();
155 -
156 - if (response.ok) {
157 - // Show success message
158 - alert('✅ Profil mis à jour avec succès !');
159 -
160 - // Reload if email changed (will redirect to login)
161 - const originalEmail = '{{ email }}';
162 - if (formData.get('email') !== originalEmail) {
163 - alert('⚠️ Votre email a été modifié. Vous allez être déconnecté.');
164 - window.location.href = '/login';
165 - } else {
166 - window.location.reload();
167 - }
168 - } else {
169 - alert('❌ Erreur : ' + (result.error || result.message || 'Échec de la mise à jour'));
170 - submitBtn.disabled = false;
171 - submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> Enregistrer les modifications';
172 - }
173 - } catch (error) {
174 - console.error('Error:', error);
175 - alert('❌ Erreur réseau. Veuillez réessayer.');
176 - submitBtn.disabled = false;
177 - submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> Enregistrer les modifications';
178 - }
179 -});
180 -</script>
181 -{% endblock %}
D templates/resend_verification.html
+0 -111
@@ -1,111 +0,0 @@
1 -{% extends "base.html" %}
2 -
3 -{% block title %}{{ page_title }}{% endblock %}
4 -
5 -{% block content %}
6 -<div class="min-h-screen flex items-center justify-center bg-base-200 px-4">
7 - <div class="max-w-lg w-full space-y-6">
8 - <div class="text-center">
9 - <h1 class="text-3xl font-bold text-base-content mb-2">{{ title }}</h1>
10 - <p class="text-base-content/70">
11 - {{ subtitle }}
12 - </p>
13 - </div>
14 -
15 - <div id="alert-error" class="hidden alert alert-error">
16 - <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
17 - <span id="alert-error-text"></span>
18 - </div>
19 -
20 - <div id="alert-success" class="hidden alert alert-success">
21 - <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
22 - <span id="alert-success-text"></span>
23 - </div>
24 -
25 - <div class="card bg-base-100 shadow-xl p-8">
26 - <form id="resend-form" class="space-y-4">
27 - <div class="form-control">
28 - <label for="email" class="label">
29 - <span class="label-text">{{ email_label }}</span>
30 - </label>
31 - <input
32 - type="email"
33 - id="email"
34 - name="email"
35 - required
36 - autocomplete="email"
37 - class="input input-bordered w-full"
38 - placeholder="{{ email_placeholder }}">
39 - </div>
40 -
41 - <div class="form-control mt-6">
42 - <button type="submit" class="btn btn-primary w-full">
43 - {{ submit }}
44 - </button>
45 - </div>
46 - </form>
47 -
48 - <div class="mt-4 text-sm text-base-content/70 text-center space-y-2">
49 - <a href="/login" class="text-primary hover:underline">{{ back_to_login }}</a>
50 - </div>
51 - </div>
52 - </div>
53 -</div>
54 -
55 -<script>
56 - const MSG_ERROR_DEFAULT = "{{ error_default }}";
57 - const MSG_SUCCESS_DEFAULT = "{{ success_default }}";
58 - const MSG_ERROR_MISSING_EMAIL = "{{ error_missing_email }}";
59 - const MSG_ERROR_CANNOT_SEND = "{{ error_cannot_send }}";
60 -
61 - const form = document.getElementById('resend-form');
62 - const alertError = document.getElementById('alert-error');
63 - const alertErrorText = document.getElementById('alert-error-text');
64 - const alertSuccess = document.getElementById('alert-success');
65 - const alertSuccessText = document.getElementById('alert-success-text');
66 -
67 - function showError(msg) {
68 - if (alertError && alertErrorText) {
69 - alertErrorText.textContent = msg || MSG_ERROR_DEFAULT;
70 - alertError.classList.remove('hidden');
71 - }
72 - if (alertSuccess) alertSuccess.classList.add('hidden');
73 - }
74 -
75 - function showSuccess(msg) {
76 - if (alertSuccess && alertSuccessText) {
77 - alertSuccessText.textContent = msg || MSG_SUCCESS_DEFAULT;
78 - alertSuccess.classList.remove('hidden');
79 - }
80 - if (alertError) alertError.classList.add('hidden');
81 - }
82 -
83 - form?.addEventListener('submit', (e) => {
84 - e.preventDefault();
85 - const email = document.getElementById('email')?.value;
86 - if (!email) {
87 - showError(MSG_ERROR_MISSING_EMAIL);
88 - return;
89 - }
90 - fetch('/api/v1/auth/resend-verification', {
91 - method: 'POST',
92 - headers: { 'Content-Type': 'application/json' },
93 - body: JSON.stringify({ email }),
94 - credentials: 'include'
95 - })
96 - .then(async (resp) => {
97 - let msg = '';
98 - try {
99 - const data = await resp.json();
100 - msg = data.message || data.error || msg;
101 - } catch (_) {}
102 - if (resp.ok) {
103 - showSuccess(msg || MSG_SUCCESS_DEFAULT);
104 - } else {
105 - showError(msg || MSG_ERROR_CANNOT_SEND);
106 - }
107 - })
108 - .catch(() => showError(MSG_ERROR_CANNOT_SEND));
109 - });
110 -</script>
111 -{% endblock %}
D templates/reset_password.html
+0 -164
@@ -1,164 +0,0 @@
1 -{% extends "base.html" %}
2 -
3 -{% block title %}{{ page_title }} - {{ app_name }}{% endblock %}
4 -
5 -{% block content %}
6 -<div class="min-h-screen flex items-center justify-center bg-base-200 px-4">
7 - <div class="max-w-lg w-full space-y-6">
8 - <div class="text-center">
9 - <div class="inline-flex items-center justify-center w-16 h-16 mb-4">
10 - <img src="/static/images/logo.svg" alt="{{ app_name }} Logo" class="h-16 w-16">
11 - </div>
12 - <h1 class="text-3xl font-bold text-base-content mb-2">{{ reset_password_title }}</h1>
13 - <p class="text-base-content/70">
14 - {{ subtitle }}
15 - </p>
16 - </div>
17 -
18 - <div id="alert-error" class="hidden alert alert-error">
19 - <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
20 - <span id="alert-error-text"></span>
21 - </div>
22 -
23 - <div id="alert-success" class="hidden alert alert-success">
24 - <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
25 - <span id="alert-success-text"></span>
26 - </div>
27 -
28 - <div class="card bg-base-100 shadow-xl p-8">
29 - <form id="reset-form" class="space-y-4">
30 - <input type="hidden" id="token" name="token" value="">
31 -
32 - <div class="form-control">
33 - <label for="new_password" class="label">
34 - <span class="label-text">{{ new_password_label }}</span>
35 - </label>
36 - <input
37 - type="password"
38 - id="new_password"
39 - name="new_password"
40 - required
41 - autocomplete="new-password"
42 - class="input input-bordered w-full"
43 - placeholder="{{ new_password_placeholder }}">
44 - <label class="label">
45 - <span class="label-text-alt text-base-content/70">{{ password_requirements }}</span>
46 - </label>
47 - </div>
48 -
49 - <div class="form-control">
50 - <label for="confirm_password" class="label">
51 - <span class="label-text">{{ confirm_password_label }}</span>
52 - </label>
53 - <input
54 - type="password"
55 - id="confirm_password"
56 - name="confirm_password"
57 - required
58 - autocomplete="new-password"
59 - class="input input-bordered w-full"
60 - placeholder="{{ confirm_password_placeholder }}">
61 - </div>
62 -
63 - <div class="form-control mt-6">
64 - <button type="submit" class="btn btn-primary w-full">
65 - {{ submit_button }}
66 - </button>
67 - </div>
68 - </form>
69 -
70 - <div class="mt-4 text-sm text-base-content/70 text-center space-y-2">
71 - <a href="/login" class="text-primary hover:underline">{{ back_to_login }}</a>
72 - </div>
73 - </div>
74 - </div>
75 -</div>
76 -
77 -<script>
78 - const MSG_MISSING_TOKEN_URL = "{{ missing_token_url }}";
79 - const MSG_MISSING_TOKEN = "{{ missing_token }}";
80 - const MSG_MISSING_NEW_PASSWORD = "{{ missing_new_password }}";
81 - const MSG_PASSWORDS_NOT_MATCH = "{{ passwords_not_match }}";
82 - const MSG_RESET_SUCCESS = "{{ reset_success_message }}";
83 - const MSG_ERROR_LINK_INVALID = "{{ error_link_invalid }}";
84 - const MSG_ERROR_GENERIC = "{{ error_generic }}";
85 - const MSG_ERROR_DEFAULT = "{{ error_message }}";
86 - const MSG_SUCCESS_DEFAULT = "{{ success_message }}";
87 -
88 - const urlParams = new URLSearchParams(window.location.search);
89 - const token = urlParams.get('token');
90 -
91 - if (token) {
92 - document.getElementById('token').value = token;
93 - } else {
94 - showError(MSG_MISSING_TOKEN_URL);
95 - }
96 -
97 - const form = document.getElementById('reset-form');
98 - const alertError = document.getElementById('alert-error');
99 - const alertErrorText = document.getElementById('alert-error-text');
100 - const alertSuccess = document.getElementById('alert-success');
101 - const alertSuccessText = document.getElementById('alert-success-text');
102 -
103 - function showError(msg) {
104 - if (alertError && alertErrorText) {
105 - alertErrorText.textContent = msg || MSG_ERROR_DEFAULT;
106 - alertError.classList.remove('hidden');
107 - }
108 - if (alertSuccess) alertSuccess.classList.add('hidden');
109 - }
110 -
111 - function showSuccess(msg) {
112 - if (alertSuccess && alertSuccessText) {
113 - alertSuccessText.textContent = msg || MSG_SUCCESS_DEFAULT;
114 - alertSuccess.classList.remove('hidden');
115 - }
116 - if (alertError) alertError.classList.add('hidden');
117 - }
118 -
119 - form?.addEventListener('submit', (e) => {
120 - e.preventDefault();
121 - const token = document.getElementById('token')?.value;
122 - const newPassword = document.getElementById('new_password')?.value;
123 - const confirmPassword = document.getElementById('confirm_password')?.value;
124 -
125 - if (!token) {
126 - showError(MSG_MISSING_TOKEN);
127 - return;
128 - }
129 -
130 - if (!newPassword) {
131 - showError(MSG_MISSING_NEW_PASSWORD);
132 - return;
133 - }
134 -
135 - if (newPassword !== confirmPassword) {
136 - showError(MSG_PASSWORDS_NOT_MATCH);
137 - return;
138 - }
139 -
140 - fetch('/api/v1/auth/reset-password', {
141 - method: 'POST',
142 - headers: { 'Content-Type': 'application/json' },
143 - body: JSON.stringify({ token, new_password: newPassword }),
144 - credentials: 'include'
145 - })
146 - .then(async (resp) => {
147 - let msg = '';
148 - try {
149 - const data = await resp.json();
150 - msg = data.message || data.error || msg;
151 - } catch (_) {}
152 - if (resp.ok) {
153 - showSuccess(msg || MSG_RESET_SUCCESS);
154 - setTimeout(() => {
155 - window.location.href = '/login';
156 - }, 3000);
157 - } else {
158 - showError(msg || MSG_ERROR_LINK_INVALID);
159 - }
160 - })
161 - .catch(() => showError(MSG_ERROR_GENERIC));
162 - });
163 -</script>
164 -{% endblock %}
D templates/settings/password.html
+0 -504
@@ -1,504 +0,0 @@
1 -{% extends "base.html" %}
2 -
3 -{% block title %}{{ page_title }}{% endblock %}
4 -
5 -{% block navbar %}
6 -{% if !must_change %}
7 -{% include "components/navbar.html" %}
8 -{% endif %}
9 -{% endblock %}
10 -
11 -{% block content %}
12 -{% if must_change %}
13 -<div class="min-h-screen flex items-center justify-center bg-base-200 px-4 py-12">
14 - <div class="w-full max-w-lg">
15 - <div class="text-center mb-8">
16 - <div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-warning/20 mb-4">
17 - <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
18 - </div>
19 - <h1 class="text-3xl font-bold mb-2">{{ title }}</h1>
20 - <p class="text-base-content/70 text-lg">{{ must_change_message }}</p>
21 - </div>
22 -{% else %}
23 -<div class="container mx-auto px-4 py-8">
24 - <div class="text-sm breadcrumbs mb-6">
25 - <ul>
26 - <li><a href="/">{{ breadcrumb_home }}</a></li>
27 - <li><a href="/profile">{{ breadcrumb_settings }}</a></li>
28 - <li>{{ breadcrumb_password }}</li>
29 - </ul>
30 - </div>
31 -
32 - <div class="mb-8">
33 - <h1 class="text-3xl font-bold mb-2">{{ title }}</h1>
34 - {% if password_change_require_email %}
35 - <p class="text-base-content/70">{{ subtitle }}</p>
36 - {% else %}
37 - <p class="text-base-content/70">{{ subtitle_no_email }}</p>
38 - {% endif %}
39 - </div>
40 -{% endif %}
41 -
42 - <!-- Feature 026: Pending password change request alert -->
43 - {% if has_pending_request %}
44 - <div class="alert alert-info mb-6 max-w-2xl">
45 - <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
46 - <div class="flex-1">
47 - <h3 class="font-bold">{{ pending_request_message }}</h3>
48 - <div class="text-sm">{{ pending_request_info }}</div>
49 - </div>
50 - <div>
51 - <button
52 - id="cancelPendingRequestBtn"
53 - class="btn btn-sm btn-ghost"
54 - onclick="cancelPendingRequest()"
55 - >
56 - {{ pending_request_cancel }}
57 - </button>
58 - </div>
59 - </div>
60 - {% endif %}
61 -
62 - <!-- Password Change Form Card -->
63 - <div class="card bg-base-100 shadow-xl max-w-2xl">
64 - <div class="card-body">
65 - <h2 class="card-title mb-4">
66 - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
67 - {{ new_password_label }}
68 - </h2>
69 -
70 - <form id="passwordForm" method="POST" action="/settings/password" class="space-y-6">
71 - <!-- CSRF Token -->
72 - <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
73 -
74 - <!-- Old Password -->
75 - <div class="form-control">
76 - <label class="label">
77 - <span class="label-text font-semibold">{{ current_password_label }}</span>
78 - <span class="label-text-alt text-error">{{ required }}</span>
79 - </label>
80 - <input
81 - type="password"
82 - name="old_password"
83 - id="oldPassword"
84 - placeholder="{{ current_password_placeholder }}"
85 - class="input input-bordered w-full"
86 - required
87 - autocomplete="current-password"
88 - >
89 - </div>
90 -
91 - <!-- New Password -->
92 - <div class="form-control">
93 - <label class="label">
94 - <span class="label-text font-semibold">{{ new_password_label }}</span>
95 - <span class="label-text-alt text-error">{{ required }}</span>
96 - </label>
97 - <input
98 - type="password"
99 - name="new_password"
100 - id="newPassword"
101 - placeholder="{{ new_password_placeholder }}"
102 - class="input input-bordered w-full"
103 - required
104 - minlength="{{ pw_min_length }}"
105 - autocomplete="new-password"
106 - >
107 - <label class="label">
108 - <span class="label-text-alt text-base-content/70">
109 - {{ requirements }}
110 - </span>
111 - </label>
112 - <!-- Password Strength Indicator -->
113 - <div class="mt-2">
114 - <div id="strengthBar" class="hidden">
115 - <label class="label">
116 - <span class="label-text-alt">{{ strength_label }}</span>
117 - <span id="strengthText" class="label-text-alt font-semibold"></span>
118 - </label>
119 - <progress id="strengthProgress" class="progress w-full" value="0" max="100"></progress>
120 - </div>
121 - </div>
122 - </div>
123 -
124 - <!-- Confirm Password -->
125 - <div class="form-control">
126 - <label class="label">
127 - <span class="label-text font-semibold">{{ confirm_password_label }}</span>
128 - <span class="label-text-alt text-error">{{ required }}</span>
129 - </label>
130 - <input
131 - type="password"
132 - name="confirm_password"
133 - id="confirmPassword"
134 - placeholder="{{ confirm_password_placeholder }}"
135 - class="input input-bordered w-full"
136 - required
137 - minlength="{{ pw_min_length }}"
138 - autocomplete="new-password"
139 - >
140 - <label class="label">
141 - <span id="matchIndicator" class="label-text-alt text-base-content/70"></span>
142 - </label>
143 - </div>
144 -
145 - <!-- Security Alert -->
146 - <div class="alert alert-warning">
147 - <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
148 - <div>
149 - <h3 class="font-bold">{{ logout_warning_title }}</h3>
150 - <div class="text-xs">{{ logout_warning_desc }}</div>
151 - </div>
152 - </div>
153 -
154 - <!-- Form Actions -->
155 - <div class="card-actions justify-end">
156 - {% if must_change %}
157 - <a href="/api/v1/auth/logout" class="btn btn-ghost"
158 - onclick="event.preventDefault(); fetch('/api/v1/auth/logout', {method:'POST', credentials:'include', headers:{'Accept':'text/html'}}).then(()=>window.location.href='/login');">
159 - {{ logout_label }}
160 - </a>
161 - {% else %}
162 - <a href="/profile" class="btn btn-ghost">{{ cancel }}</a>
163 - {% endif %}
164 - <button type="submit" class="btn btn-error" id="submitBtn" disabled>
165 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
166 - {{ change_button }}
167 - </button>
168 - </div>
169 - </form>
170 - </div>
171 - </div>
172 -
173 - {% if must_change %}
174 - </div>
175 -</div>
176 - {% endif %}
177 -
178 - <!-- Security Tips Card -->
179 - {% if !must_change %}
180 - <div class="card bg-base-200 shadow-xl max-w-2xl mt-6">
181 - <div class="card-body">
182 - <h3 class="card-title text-base">
183 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
184 - {{ security_tips_title }}
185 - </h3>
186 - <ul class="list-disc list-inside text-sm space-y-2 text-base-content/80">
187 - <li>{{ security_tip_unique }}</li>
188 - <li>{{ security_tip_share }}</li>
189 - <li>{{ security_tip_regular }}</li>
190 - <li>{{ security_tip_2fa }}</li>
191 - <li>{{ security_tip_manager }}</li>
192 - </ul>
193 - </div>
194 - </div>
195 - {% endif %}
196 -
197 -{% if !must_change %}
198 -</div>
199 -{% endif %}
200 -
201 -<script>
202 -const newPasswordInput = document.getElementById('newPassword');
203 -const confirmPasswordInput = document.getElementById('confirmPassword');
204 -const submitBtn = document.getElementById('submitBtn');
205 -const strengthBar = document.getElementById('strengthBar');
206 -const strengthProgress = document.getElementById('strengthProgress');
207 -const strengthText = document.getElementById('strengthText');
208 -const matchIndicator = document.getElementById('matchIndicator');
209 -
210 -// Password rules from app_settings (injected by server)
211 -const PW_MIN_LENGTH = {{ pw_min_length }};
212 -const PW_REQ_UPPER = {{ pw_require_uppercase }};
213 -const PW_REQ_LOWER = {{ pw_require_lowercase }};
214 -const PW_REQ_DIGITS = {{ pw_require_digits }};
215 -const PW_REQ_SPECIAL= {{ pw_require_special }};
216 -
217 -function checkPasswordStrength(password) {
218 - let strength = 0;
219 - let feedback = [];
220 - const totalChecks = 1
221 - + (PW_REQ_UPPER ? 1 : 0)
222 - + (PW_REQ_LOWER ? 1 : 0)
223 - + (PW_REQ_DIGITS ? 1 : 0)
224 - + (PW_REQ_SPECIAL? 1 : 0);
225 - const pointsPerCheck = Math.floor(100 / (totalChecks + 1));
226 -
227 - if (password.length >= PW_MIN_LENGTH) strength += pointsPerCheck;
228 - if (password.length >= PW_MIN_LENGTH + 4) strength += pointsPerCheck;
229 - if (password.length < PW_MIN_LENGTH) feedback.push('{{ strength_too_short }}');
230 -
231 - if (PW_REQ_LOWER) {
232 - if (/[a-z]/.test(password)) strength += pointsPerCheck; else feedback.push('{{ strength_missing_lowercase }}');
233 - }
234 - if (PW_REQ_UPPER) {
235 - if (/[A-Z]/.test(password)) strength += pointsPerCheck; else feedback.push('{{ strength_missing_uppercase }}');
236 - }
237 - if (PW_REQ_DIGITS) {
238 - if (/[0-9]/.test(password)) strength += pointsPerCheck; else feedback.push('{{ strength_missing_digit }}');
239 - }
240 - if (PW_REQ_SPECIAL) {
241 - if (/[^a-zA-Z0-9]/.test(password)) strength += pointsPerCheck; else feedback.push('{{ strength_missing_special }}');
242 - }
243 -
244 - if (!PW_REQ_LOWER && /[a-z]/.test(password)) strength += 5;
245 - if (!PW_REQ_UPPER && /[A-Z]/.test(password)) strength += 5;
246 - if (!PW_REQ_DIGITS && /[0-9]/.test(password)) strength += 5;
247 - if (!PW_REQ_SPECIAL&& /[^a-zA-Z0-9]/.test(password)) strength += 5;
248 -
249 - strength = Math.min(strength, 100);
250 - return { strength, feedback };
251 -}
252 -
253 -// Update password strength indicator
254 -newPasswordInput.addEventListener('input', function() {
255 - const password = this.value;
256 -
257 - if (password.length > 0) {
258 - strengthBar.classList.remove('hidden');
259 - const { strength, feedback } = checkPasswordStrength(password);
260 -
261 - strengthProgress.value = strength;
262 -
263 - if (strength < 40) {
264 - strengthProgress.className = 'progress progress-error w-full';
265 - strengthText.textContent = '{{ strength_weak }} (' + feedback.join(', ') + ')';
266 - strengthText.className = 'label-text-alt font-semibold text-error';
267 - } else if (strength < 70) {
268 - strengthProgress.className = 'progress progress-warning w-full';
269 - strengthText.textContent = '{{ strength_medium }}' + (feedback.length ? ' (' + feedback.join(', ') + ')' : '');
270 - strengthText.className = 'label-text-alt font-semibold text-warning';
271 - } else {
272 - strengthProgress.className = 'progress progress-success w-full';
273 - strengthText.textContent = '{{ strength_strong }}';
274 - strengthText.className = 'label-text-alt font-semibold text-success';
275 - }
276 - } else {
277 - strengthBar.classList.add('hidden');
278 - }
279 -
280 - checkFormValidity();
281 -});
282 -
283 -// Check password match
284 -confirmPasswordInput.addEventListener('input', function() {
285 - if (this.value.length > 0) {
286 - if (this.value === newPasswordInput.value) {
287 - matchIndicator.textContent = '{{ passwords_match }}';
288 - matchIndicator.className = 'label-text-alt text-success font-semibold';
289 - } else {
290 - matchIndicator.textContent = '{{ passwords_not_match }}';
291 - matchIndicator.className = 'label-text-alt text-error font-semibold';
292 - }
293 - } else {
294 - matchIndicator.textContent = '';
295 - }
296 -
297 - checkFormValidity();
298 -});
299 -
300 -// Enable submit button only if form is valid
301 -function checkFormValidity() {
302 - const oldPassword = document.getElementById('oldPassword').value;
303 - const newPassword = newPasswordInput.value;
304 - const confirmPassword = confirmPasswordInput.value;
305 -
306 - const { strength, feedback } = checkPasswordStrength(newPassword);
307 - const isValid = oldPassword.length > 0 &&
308 - newPassword.length >= PW_MIN_LENGTH &&
309 - confirmPassword === newPassword &&
310 - feedback.length === 0;
311 -
312 - submitBtn.disabled = !isValid;
313 -}
314 -
315 -// Form submission
316 -document.getElementById('passwordForm').addEventListener('submit', async function(e) {
317 - e.preventDefault();
318 -
319 - const formData = new FormData(this);
320 - submitBtn.disabled = true;
321 - submitBtn.innerHTML = '<span class="loading loading-spinner"></span> {{ changing }}';
322 -
323 - try {
324 - // Convert FormData to JSON object
325 - const oldPassword = formData.get('old_password');
326 - const newPassword = formData.get('new_password');
327 - const confirmPassword = formData.get('confirm_password');
328 -
329 - // Validate that all fields are present
330 - if (!oldPassword || !newPassword || !confirmPassword) {
331 - alert('❌ {{ error_all_fields_required }}');
332 - submitBtn.disabled = false;
333 - submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> {{ change_button }}';
334 - return;
335 - }
336 -
337 - const jsonData = {
338 - old_password: oldPassword.toString(),
339 - new_password: newPassword.toString(),
340 - confirm_password: confirmPassword.toString()
341 - };
342 -
343 - const jsonBody = JSON.stringify(jsonData);
344 -
345 - // Validate JSON is not empty
346 - if (!jsonBody || jsonBody === '{}' || jsonBody.length === 0) {
347 - alert('❌ {{ error_empty_data }}');
348 - submitBtn.disabled = false;
349 - submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> {{ change_button }}';
350 - return;
351 - }
352 -
353 - console.log('Sending password change request:', {
354 - method: 'PUT',
355 - url: '/settings/password',
356 - headers: { 'Content-Type': 'application/json' },
357 - body: jsonBody,
358 - bodyLength: jsonBody.length
359 - });
360 -
361 - const response = await fetch('/settings/password', {
362 - method: 'PUT',
363 - headers: {
364 - 'Content-Type': 'application/json',
365 - 'Accept': 'application/json'
366 - },
367 - credentials: 'include', // Important: inclure les cookies (JWT token)
368 - body: jsonBody
369 - });
370 -
371 - console.log('Response status:', response.status);
372 - console.log('Response headers:', Object.fromEntries(response.headers.entries()));
373 -
374 - // Clone response to read it multiple times if needed
375 - const responseClone = response.clone();
376 -
377 - let result = {};
378 - let errorText = '';
379 -
380 - // Try to parse as JSON first
381 - try {
382 - const contentType = response.headers.get('content-type') || '';
383 - if (contentType.includes('application/json')) {
384 - result = await response.json();
385 - } else {
386 - // Not JSON, read as text
387 - errorText = await responseClone.text();
388 - console.error('Non-JSON response (status ' + response.status + '):', errorText);
389 - }
390 - } catch (e) {
391 - // Failed to parse JSON, try text
392 - try {
393 - errorText = await responseClone.text();
394 - console.error('Failed to parse response (status ' + response.status + '):', errorText);
395 - } catch (e2) {
396 - console.error('Failed to read response:', e2);
397 - errorText = 'Erreur lors de la lecture de la réponse du serveur';
398 - }
399 - }
400 -
401 - const savedEmailMsg = "{{ saved }}";
402 - const savedDirectMsg = "{{ saved_direct }}";
403 - const errorAllFields = "{{ error_all_fields_required }}";
404 - const errorEmpty = "{{ error_empty_data }}";
405 - const errorInvalid = "{{ error_invalid_data }}";
406 - const errorIncorrect = "{{ error_incorrect_current }}";
407 - const error415 = "{{ error_415 }}";
408 - const networkErr = "{{ network_error }}";
409 - const changeBtnText = "{{ change_button }}";
410 -
411 - if (response.ok) {
412 - const msgKey = result && result.message;
413 - if (msgKey === 'email_confirmation_sent') {
414 - alert('✅ ' + savedEmailMsg);
415 - } else {
416 - alert('✅ ' + savedDirectMsg);
417 - }
418 - window.location.href = '/settings';
419 - } else {
420 - let errorMessage = 'Échec du changement de mot de passe';
421 -
422 - if (response.status === 415) {
423 - errorMessage = error415;
424 - console.error('415 Error details:', errorText || 'No error text available');
425 - } else if (result && result.error) {
426 - errorMessage = result.error;
427 - } else if (result && result.message) {
428 - errorMessage = result.message;
429 - } else if (errorText) {
430 - errorMessage = errorText;
431 - } else if (response.status === 400) {
432 - errorMessage = errorInvalid;
433 - } else if (response.status === 401) {
434 - errorMessage = errorIncorrect;
435 - }
436 -
437 - alert('❌ ' + errorMessage);
438 - submitBtn.disabled = false;
439 - submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> ' + changeBtnText;
440 - }
441 - } catch (error) {
442 - console.error('Error:', error);
443 - alert('❌ ' + networkErr);
444 - submitBtn.disabled = false;
445 - submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> ' + changeBtnText;
446 - }
447 -});
448 -
449 -// Initialize form check
450 -document.getElementById('oldPassword').addEventListener('input', checkFormValidity);
451 -
452 -// Feature 026: Cancel pending password change request
453 -async function cancelPendingRequest() {
454 - const cancelBtn = document.getElementById('cancelPendingRequestBtn');
455 - if (!cancelBtn) return;
456 -
457 - if (!confirm('{{ pending_request_cancel }} ?')) {
458 - return;
459 - }
460 -
461 - cancelBtn.disabled = true;
462 - cancelBtn.innerHTML = '<span class="loading loading-spinner"></span>';
463 -
464 - try {
465 - const response = await fetch('/settings/password/pending', {
466 - method: 'DELETE',
467 - headers: {
468 - 'Content-Type': 'application/json',
469 - },
470 - credentials: 'same-origin'
471 - });
472 -
473 - if (response.ok) {
474 - // Reload page to update UI
475 - window.location.reload();
476 - } else {
477 - const result = await response.json().catch(() => ({}));
478 - const errorMessage = result.message || result.error || '{{ network_error }}';
479 - alert('❌ ' + errorMessage);
480 - cancelBtn.disabled = false;
481 - cancelBtn.innerHTML = '{{ pending_request_cancel }}';
482 - }
483 - } catch (error) {
484 - console.error('Error canceling pending request:', error);
485 - alert('❌ {{ network_error }}');
486 - cancelBtn.disabled = false;
487 - cancelBtn.innerHTML = '{{ pending_request_cancel }}';
488 - }
489 -}
490 -</script>
491 -{% endblock %}
492 -
493 -{% block footer %}
494 -{% if !must_change %}
495 -<footer class="bg-base-200 border-t border-base-300 py-4 mt-8">
496 - <div class="container mx-auto px-4 text-center text-base-content text-sm">
497 - <p>{{ app_name }} {{ app_version }}{% if app_domain != "" %} - {{ app_domain }}{% endif %}</p>
498 - {% if app_description != "" %}
499 - <p class="text-base-content/70 mt-1">{{ app_description }}</p>
500 - {% endif %}
501 - </div>
502 -</footer>
503 -{% endif %}
504 -{% endblock %}
D templates/settings/preferences.html
+0 -366
@@ -1,366 +0,0 @@
1 -{% extends "base.html" %}
2 -
3 -{% block title %}{{ page_title }}{% endblock %}
4 -
5 -{% block navbar %}
6 -{% include "components/navbar.html" %}
7 -{% endblock %}
8 -
9 -{% block content %}
10 -<div class="container mx-auto px-4 py-8">
11 - <!-- Quick Language Selector (Feature 014) -->
12 - <div class="flex justify-end mb-4">
13 - <div class="dropdown dropdown-end">
14 - <label tabindex="0" class="btn btn-ghost btn-sm gap-2">
15 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
16 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
17 - </svg>
18 - <span id="current-lang-display">
19 - {% for (code, name, is_current) in supported_languages %}
20 - {% if is_current %}{{ name }}{% endif %}
21 - {% endfor %}
22 - </span>
23 - <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
24 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
25 - </svg>
26 - </label>
27 - <ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow">
28 - {% for (code, name, is_current) in supported_languages %}
29 - <li>
30 - <a href="#" onclick="return window.changeLanguage('{{ code }}');" class="{% if is_current %}active{% endif %}">
31 - {{ name }}
32 - {% if is_current %}
33 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
34 - <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
35 - </svg>
36 - {% endif %}
37 - </a>
38 - </li>
39 - {% endfor %}
40 - </ul>
41 - </div>
42 - </div>
43 -
44 - <!-- Breadcrumb -->
45 - <div class="text-sm breadcrumbs mb-6">
46 - <ul>
47 - <li><a href="/">{{ breadcrumb_home }}</a></li>
48 - <li><a href="/profile">{{ breadcrumb_settings }}</a></li>
49 - <li>{{ breadcrumb_preferences }}</li>
50 - </ul>
51 - </div>
52 -
53 - <!-- Page Header -->
54 - <div class="mb-8">
55 - <h1 class="text-3xl font-bold mb-2">{{ title }}</h1>
56 - <p class="text-base-content/70">{{ subtitle }}</p>
57 - </div>
58 -
59 - <!-- Preferences Form -->
60 - <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 max-w-6xl">
61 - <!-- Theme Preferences Card -->
62 - <div class="card bg-base-100 shadow-xl">
63 - <div class="card-body">
64 - <h2 class="card-title mb-4">
65 - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" /></svg>
66 - {{ theme_label }}
67 - </h2>
68 -
69 - <form id="preferencesForm" class="space-y-4">
70 - <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
71 -
72 - <!-- Theme Options -->
73 - <div class="form-control">
74 - <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors">
75 - <input
76 - type="radio"
77 - name="theme"
78 - value="light"
79 - class="radio radio-primary"
80 - {% if is_theme_light %}checked{% endif %}
81 - >
82 - <div class="flex-1">
83 - <div class="flex items-center gap-2">
84 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
85 - <span class="label-text font-semibold">{{ theme_light }}</span>
86 - </div>
87 - <span class="label-text-alt text-base-content/70">{{ theme_light_desc }}</span>
88 - </div>
89 - </label>
90 - </div>
91 -
92 - <div class="form-control">
93 - <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors">
94 - <input
95 - type="radio"
96 - name="theme"
97 - value="dark"
98 - class="radio radio-primary"
99 - {% if is_theme_dark %}checked{% endif %}
100 - >
101 - <div class="flex-1">
102 - <div class="flex items-center gap-2">
103 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
104 - <span class="label-text font-semibold">{{ theme_dark }}</span>
105 - </div>
106 - <span class="label-text-alt text-base-content/70">{{ theme_dark_desc }}</span>
107 - </div>
108 - </label>
109 - </div>
110 -
111 - <div class="form-control">
112 - <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors">
113 - <input
114 - type="radio"
115 - name="theme"
116 - value="auto"
117 - class="radio radio-primary"
118 - {% if is_theme_auto %}checked{% endif %}
119 - >
120 - <div class="flex-1">
121 - <div class="flex items-center gap-2">
122 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" /></svg>
123 - <span class="label-text font-semibold">{{ theme_auto }}</span>
124 - </div>
125 - <span class="label-text-alt text-base-content/70">{{ theme_auto_desc }}</span>
126 - </div>
127 - </label>
128 - </div>
129 -
130 - <!-- Theme Preview -->
131 - <div class="alert" id="themePreview">
132 - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
133 - <span>{{ theme_preview }}</span>
134 - </div>
135 - </form>
136 - </div>
137 - </div>
138 -
139 - <!-- Language Preferences Card -->
140 - <div class="card bg-base-100 shadow-xl">
141 - <div class="card-body">
142 - <h2 class="card-title mb-4">
143 - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" /></svg>
144 - {{ language_label }}
145 - </h2>
146 -
147 - <div class="space-y-4">
148 - <!-- Language Options -->
149 - <div class="form-control">
150 - <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors">
151 - <input
152 - type="radio"
153 - name="language"
154 - value="fr"
155 - class="radio radio-primary"
156 - form="preferencesForm"
157 - {% if is_language_fr %}checked{% endif %}
158 - >
159 - <div class="flex-1">
160 - <div class="flex items-center gap-2">
161 - <span class="text-2xl">🇫🇷</span>
162 - <span class="label-text font-semibold">{{ language_fr }}</span>
163 - </div>
164 - <span class="label-text-alt text-base-content/70">{{ language_fr_desc }}</span>
165 - </div>
166 - </label>
167 - </div>
168 -
169 - <div class="form-control">
170 - <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors">
171 - <input
172 - type="radio"
173 - name="language"
174 - value="en"
175 - class="radio radio-primary"
176 - form="preferencesForm"
177 - {% if is_language_en %}checked{% endif %}
178 - >
179 - <div class="flex-1">
180 - <div class="flex items-center gap-2">
181 - <span class="text-2xl">🇬🇧</span>
182 - <span class="label-text font-semibold">{{ language_en }}</span>
183 - </div>
184 - <span class="label-text-alt text-base-content/70">{{ language_en_desc }}</span>
185 - </div>
186 - </label>
187 - </div>
188 -
189 - <div class="form-control">
190 - <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors">
191 - <input
192 - type="radio"
193 - name="language"
194 - value="de"
195 - class="radio radio-primary"
196 - form="preferencesForm"
197 - {% if is_language_de %}checked{% endif %}
198 - >
199 - <div class="flex-1">
200 - <div class="flex items-center gap-2">
201 - <span class="text-2xl">🇩🇪</span>
202 - <span class="label-text font-semibold">{{ language_de }}</span>
203 - </div>
204 - <span class="label-text-alt text-base-content/70">{{ language_de_desc }}</span>
205 - </div>
206 - </label>
207 - </div>
208 -
209 - <div class="form-control">
210 - <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors">
211 - <input
212 - type="radio"
213 - name="language"
214 - value="es"
215 - class="radio radio-primary"
216 - form="preferencesForm"
217 - {% if is_language_es %}checked{% endif %}
218 - >
219 - <div class="flex-1">
220 - <div class="flex items-center gap-2">
221 - <span class="text-2xl">🇪🇸</span>
222 - <span class="label-text font-semibold">{{ language_es }}</span>
223 - </div>
224 - <span class="label-text-alt text-base-content/70">{{ language_es_desc }}</span>
225 - </div>
226 - </label>
227 - </div>
228 -
229 - <div class="alert alert-warning">
230 - <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
231 - <span class="text-xs">{{ translation_warning }}</span>
232 - </div>
233 - </div>
234 - </div>
235 - </div>
236 - </div>
237 -
238 - <!-- Save Button (Fixed at Bottom) -->
239 - <div class="sticky bottom-4 mt-8 max-w-6xl">
240 - <div class="card bg-base-100 shadow-2xl border-2 border-primary">
241 - <div class="card-body flex-row items-center justify-between p-4">
242 - <div>
243 - <h3 class="font-semibold">Préférences modifiées</h3>
244 - <p class="text-sm text-base-content/70">Enregistrez vos changements pour les appliquer</p>
245 - </div>
246 - <div class="flex gap-2">
247 - <a href="/profile" class="btn btn-ghost">Annuler</a>
248 - <button type="submit" form="preferencesForm" class="btn btn-primary" id="saveBtn">
249 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
250 - Enregistrer les préférences
251 - </button>
252 - </div>
253 - </div>
254 - </div>
255 - </div>
256 -
257 - <!-- Info Card -->
258 - <div class="card bg-info text-info-content shadow-xl max-w-6xl mt-6">
259 - <div class="card-body">
260 - <h3 class="card-title text-base">
261 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
262 - {{ sync_title }}
263 - </h3>
264 - <p class="text-sm">
265 - {{ sync_desc }}
266 - </p>
267 - </div>
268 - </div>
269 -</div>
270 -
271 -<script>
272 -var SAVED_RELOAD_MSG = {{ saved_reload_json|safe }};
273 -var SAVED_SUCCESS_MSG = {{ saved_success_json|safe }};
274 -var ERROR_MSG = {{ error_prefix_json|safe }};
275 -var NETWORK_ERROR_MSG = {{ network_error_json|safe }};
276 -var SAVE_BUTTON_TEXT = {{ save_button_json|safe }};
277 -var SAVE_BUTTON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> ';
278 -
279 -function resetSaveBtn() {
280 - var saveBtn = document.getElementById('saveBtn');
281 - saveBtn.disabled = false;
282 - saveBtn.innerHTML = SAVE_BUTTON_SVG + SAVE_BUTTON_TEXT;
283 -}
284 -
285 -document.getElementById('preferencesForm').addEventListener('submit', async function(e) {
286 - e.preventDefault();
287 -
288 - var formData = new FormData(this);
289 - var saveBtn = document.getElementById('saveBtn');
290 - var theme = formData.get('theme');
291 - var language = formData.get('language');
292 -
293 - saveBtn.disabled = true;
294 - saveBtn.innerHTML = '<span class="loading loading-spinner"></span> ' + "{{ saving }}";
295 -
296 - try {
297 - var params = new URLSearchParams();
298 - if (theme) params.set('theme', theme);
299 - if (language) params.set('language', language);
300 - params.set('csrf_token', formData.get('csrf_token') || '');
301 -
302 - var response = await fetch('/settings/preferences', {
303 - method: 'PUT',
304 - headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
305 - credentials: 'include',
306 - body: params.toString()
307 - });
308 -
309 - var result = await response.json();
310 -
311 - if (response.ok) {
312 - document.cookie = 'theme=' + (theme || 'auto') + '; path=/; max-age=31536000; SameSite=Lax';
313 -
314 - if (theme === 'dark') {
315 - document.documentElement.setAttribute('data-theme', 'dark');
316 - } else if (theme === 'light') {
317 - document.documentElement.setAttribute('data-theme', 'light');
318 - } else {
319 - var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
320 - document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
321 - }
322 -
323 - if (result.reload_required) {
324 - document.cookie = 'locale=' + language + '; path=/; max-age=31536000; SameSite=Lax';
325 - if (confirm('✅ ' + SAVED_RELOAD_MSG)) {
326 - window.location.reload();
327 - return;
328 - }
329 - } else {
330 - alert('✅ ' + SAVED_SUCCESS_MSG);
331 - }
332 - resetSaveBtn();
333 - } else {
334 - var detail = result.error || result.message || '';
335 - alert('❌ ' + ERROR_MSG.replace('{message}', detail));
336 - resetSaveBtn();
337 - }
338 - } catch (error) {
339 - console.error('Error:', error);
340 - alert('❌ ' + NETWORK_ERROR_MSG);
341 - resetSaveBtn();
342 - }
343 -});
344 -
345 -// Preview theme changes in real-time
346 -document.querySelectorAll('input[name="theme"]').forEach(radio => {
347 - radio.addEventListener('change', function() {
348 - const preview = document.getElementById('themePreview');
349 - const previewDark = "{{ preview_dark }}";
350 - const previewLight = "{{ preview_light }}";
351 - const previewAuto = "{{ preview_auto }}";
352 -
353 - if (this.value === 'dark') {
354 - preview.className = 'alert alert-info';
355 - preview.querySelector('span').textContent = previewDark;
356 - } else if (this.value === 'light') {
357 - preview.className = 'alert alert-success';
358 - preview.querySelector('span').textContent = previewLight;
359 - } else {
360 - preview.className = 'alert';
361 - preview.querySelector('span').textContent = previewAuto;
362 - }
363 - });
364 -});
365 -</script>
366 -{% endblock %}
D templates/settings/sessions.html
+0 -224
@@ -1,224 +0,0 @@
1 -{% extends "base.html" %}
2 -
3 -{% block title %}{{ page_title }}{% endblock %}
4 -
5 -{% block navbar %}
6 -{% include "components/navbar.html" %}
7 -{% endblock %}
8 -
9 -{% block content %}
10 -<div class="container mx-auto px-4 py-8">
11 - <!-- Breadcrumb -->
12 - <div class="text-sm breadcrumbs mb-6">
13 - <ul>
14 - <li><a href="/">{{ breadcrumb_home }}</a></li>
15 - <li><a href="/profile">{{ breadcrumb_settings }}</a></li>
16 - <li>{{ breadcrumb_sessions }}</li>
17 - </ul>
18 - </div>
19 -
20 - <!-- Page Header -->
21 - <div class="mb-8">
22 - <h1 class="text-3xl font-bold mb-2">{{ title }}</h1>
23 - <p class="text-base-content/70">{{ subtitle }}</p>
24 - </div>
25 -
26 - <!-- Active Sessions List -->
27 - <div class="space-y-4 max-w-4xl">
28 - {% if sessions|length > 0 %}
29 - {% for session in sessions %}
30 - <div class="card bg-base-100 shadow-xl">
31 - <div class="card-body">
32 - <div class="flex justify-between items-start">
33 - <div class="flex-1">
34 - <h3 class="card-title text-lg">
35 - {% if session.is_current %}
36 - <span class="badge badge-primary badge-sm">{{ current_session }}</span>
37 - {% endif %}
38 - {% if !session.device_info.is_empty() %}
39 - {{ session.device_info }}
40 - {% else %}
41 - {{ unknown_device }}
42 - {% endif %}
43 - </h3>
44 -
45 - <div class="mt-3 space-y-2 text-sm">
46 - <!-- IP Address -->
47 - <div class="flex items-center gap-2">
48 - <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/60" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
49 - <span class="text-base-content/70">{{ ip_address }}</span>
50 - <span class="font-mono">{{ session.ip_address }}</span>
51 - </div>
52 -
53 - <!-- Created At -->
54 - <div class="flex items-center gap-2">
55 - <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/60" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
56 - <span class="text-base-content/70">{{ created_at }}</span>
57 - <span>{{ session.created_at }}</span>
58 - </div>
59 -
60 - <!-- Last Activity -->
61 - <div class="flex items-center gap-2">
62 - <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/60" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
63 - <span class="text-base-content/70">{{ last_activity }}</span>
64 - <span>{{ session.last_activity }}</span>
65 - </div>
66 -
67 - <!-- Session ID (for debugging) -->
68 - <div class="flex items-center gap-2">
69 - <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/60" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
70 - <span class="text-base-content/70">{{ session_id }}</span>
71 - <span class="font-mono text-xs">{{ session.id }}</span>
72 - </div>
73 - </div>
74 - </div>
75 -
76 - <!-- Revoke Button -->
77 - <div class="card-actions">
78 - {% if !session.is_current %}
79 - <button
80 - class="btn btn-error btn-sm"
81 - onclick="revokeSession('{{ session.id }}', '{{ session.device_info }}')"
82 - >
83 - <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
84 - {{ revoke }}
85 - </button>
86 - {% else %}
87 - <div class="badge badge-lg badge-success gap-2">
88 - <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
89 - {{ current_badge }}
90 - </div>
91 - {% endif %}
92 - </div>
93 - </div>
94 - </div>
95 - </div>
96 - {% endfor %}
97 -
98 - <!-- Revoke All Button -->
99 - {% if sessions|length > 1 %}
100 - <div class="card bg-base-200 shadow-xl">
101 - <div class="card-body">
102 - <h3 class="card-title text-base text-error">
103 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
104 - {{ danger_zone }}
105 - </h3>
106 - <p class="text-sm text-base-content/70">
107 - {{ danger_zone_desc }}
108 - </p>
109 - <div class="card-actions justify-end">
110 - <button class="btn btn-error" onclick="revokeAllSessions()">
111 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
112 - {{ revoke_all }}
113 - </button>
114 - </div>
115 - </div>
116 - </div>
117 - {% endif %}
118 - {% else %}
119 - <!-- No Sessions State -->
120 - <div class="card bg-base-100 shadow-xl">
121 - <div class="card-body items-center text-center">
122 - <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
123 - <h3 class="card-title">{{ no_sessions }}</h3>
124 - <p class="text-base-content/70">{{ no_sessions_desc }}</p>
125 - </div>
126 - </div>
127 - {% endif %}
128 - </div>
129 -
130 - <!-- Info Card -->
131 - <div class="card bg-info text-info-content shadow-xl max-w-4xl mt-8">
132 - <div class="card-body">
133 - <h3 class="card-title text-base">
134 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
135 - {{ about_title }}
136 - </h3>
137 - <p class="text-sm">
138 - {{ about_desc }}
139 - </p>
140 - </div>
141 - </div>
142 -
143 - <!-- Back to Settings -->
144 - <div class="mt-8">
145 - <a href="/profile" class="btn btn-outline">
146 - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
147 - {{ back_to_settings }}
148 - </a>
149 - </div>
150 -</div>
151 -
152 -<script>
153 -// Revoke a specific session
154 -async function revokeSession(sessionId, deviceInfo) {
155 - if (!confirm(`Êtes-vous sûr de vouloir révoquer la session pour "${deviceInfo}" ?\n\nCet appareil sera déconnecté immédiatement.`)) {
156 - return;
157 - }
158 -
159 - try {
160 - const response = await fetch(`/settings/sessions/${sessionId}`, {
161 - method: 'DELETE',
162 - headers: {
163 - 'Content-Type': 'application/json',
164 - }
165 - });
166 -
167 - if (response.ok) {
168 - alert('✅ Session révoquée avec succès !');
169 - window.location.reload();
170 - } else {
171 - const result = await response.json();
172 - alert('❌ Erreur : ' + (result.error || result.message || 'Échec de la révocation'));
173 - }
174 - } catch (error) {
175 - console.error('Error:', error);
176 - alert('❌ Erreur réseau. Veuillez réessayer.');
177 - }
178 -}
179 -
180 -// Revoke all other sessions
181 -const revokeAllConfirmMsg = "{{ revoke_all_confirm }}";
182 -const revokeAllSuccessMsg = "{{ revoke_all_success }}";
183 -const revokeAllErrorMsg = "{{ revoke_all_error }}";
184 -
185 -async function revokeAllSessions() {
186 - if (!confirm(revokeAllConfirmMsg)) {
187 - return;
188 - }
189 -
190 - // Get all session IDs except current one
191 - const sessionCards = document.querySelectorAll('.card');
192 - const promises = [];
193 -
194 - sessionCards.forEach(card => {
195 - const revokeBtn = card.querySelector('button.btn-error');
196 - if (revokeBtn) {
197 - const onclick = revokeBtn.getAttribute('onclick');
198 - const match = onclick.match(/revokeSession\('([^']+)'/);
199 - if (match) {
200 - const sessionId = match[1];
201 - promises.push(
202 - fetch(`/settings/sessions/${sessionId}`, {
203 - method: 'DELETE',
204 - headers: {
205 - 'Content-Type': 'application/json',
206 - }
207 - })
208 - );
209 - }
210 - }
211 - });
212 -
213 - try {
214 - await Promise.all(promises);
215 - alert('✅ ' + revokeAllSuccessMsg);
216 - window.location.reload();
217 - } catch (error) {
218 - console.error('Error:', error);
219 - alert('❌ ' + revokeAllErrorMsg);
220 - window.location.reload();
221 - }
222 -}
223 -</script>
224 -{% endblock %}
A templates_old/admin/dashboard.html
+191 -0
@@ -0,0 +1,191 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block content %}
6 +<div class="container mx-auto px-4 py-4">
7 + <!-- Breadcrumb -->
8 + <div class="text-sm breadcrumbs mb-3">
9 + <ul>
10 + <li><a href="/">{{ breadcrumb_home }}</a></li>
11 + <li>{{ breadcrumb_admin }}</li>
12 + </ul>
13 + </div>
14 +
15 + <h1 class="text-2xl font-bold mb-4">{{ heading }}</h1>
16 +
17 + <!-- Statistics Cards -->
18 + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
19 + <!-- Total Users Card -->
20 + <div class="card bg-base-100 shadow-md">
21 + <div class="card-body p-4">
22 + <h2 class="card-title text-primary text-base">👥 {{ users_card_title }}</h2>
23 + <p class="text-xs text-base-content/70 mb-1">{{ users_card_subtitle }}</p>
24 + <p class="text-2xl font-bold">{{ total_users }}</p>
25 + <div class="card-actions justify-end mt-2">
26 + <a href="/admin/users" class="btn btn-primary btn-sm">{{ access_button }}</a>
27 + </div>
28 + </div>
29 + </div>
30 +
31 + <!-- Active Sessions Card -->
32 + <div class="card bg-base-100 shadow-md">
33 + <div class="card-body p-4">
34 + <h2 class="card-title text-secondary text-base">🔐 {{ sessions_card_title }}</h2>
35 + <p class="text-xs text-base-content/70 mb-1">{{ sessions_card_subtitle }}</p>
36 + <p class="text-2xl font-bold">{{ active_sessions }}</p>
37 + </div>
38 + </div>
39 +
40 + <!-- Admins Card -->
41 + <div class="card bg-base-100 shadow-md">
42 + <div class="card-body p-4">
43 + <h2 class="card-title text-accent text-base">👑 {{ admins_card_title }}</h2>
44 + <p class="text-xs text-base-content/70 mb-1">{{ admins_card_subtitle }}</p>
45 + <p class="text-2xl font-bold">{{ total_admins }}</p>
46 + </div>
47 + </div>
48 +
49 + <!-- Last Activity Card -->
50 + <div class="card bg-base-100 shadow-md">
51 + <div class="card-body p-4">
52 + <h2 class="card-title text-info text-base">⏰ {{ last_activity_card_title }}</h2>
53 + <p class="text-xs text-base-content/70 mb-1">{{ last_activity_card_subtitle }}</p>
54 + <p class="text-base font-semibold">{{ last_activity }}</p>
55 + </div>
56 + </div>
57 + </div>
58 +
59 + <!-- Audit Log Statistics (if available) -->
60 + {% if has_audit_logs %}
61 + <div class="card bg-base-100 shadow-md mb-4">
62 + <div class="card-body p-4">
63 + <h2 class="card-title text-lg mb-3">📊 {{ audit_section_title }}</h2>
64 + <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
65 + <!-- Histogram Chart -->
66 + <div>
67 + <canvas id="audit-logs-chart" width="400" height="200"></canvas>
68 + </div>
69 + <!-- Statistics and Legends -->
70 + <div class="space-y-3">
71 + <div class="grid grid-cols-2 gap-3">
72 + <div>
73 + <p class="text-xs text-base-content/70">{{ audit_total_logs_label }}</p>
74 + <p class="text-xl font-bold">{{ audit_total_logs }}</p>
75 + </div>
76 + <div>
77 + <p class="text-xs text-base-content/70">{{ audit_last_24h }}</p>
78 + <p class="text-xl font-bold">{{ audit_logs_last_24h }}</p>
79 + </div>
80 + </div>
81 + <div>
82 + <p class="text-xs text-base-content/70 mb-2">{{ audit_by_level }}</p>
83 + <div class="flex flex-wrap gap-2">
84 + <span class="badge badge-info badge-sm">DEBUG: {{ audit_debug_count }}</span>
85 + <span class="badge badge-success badge-sm">INFO: {{ audit_info_count }}</span>
86 + <span class="badge badge-warning badge-sm">WARN: {{ audit_warn_count }}</span>
87 + <span class="badge badge-error badge-sm">ERROR: {{ audit_error_count }}</span>
88 + </div>
89 + </div>
90 + </div>
91 + </div>
92 + </div>
93 + </div>
94 + {% endif %}
95 +
96 + <!-- Navigation Links -->
97 + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
98 + <a href="/admin/roles" class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
99 + <div class="card-body p-4">
100 + <h2 class="card-title text-warning text-base">🛡️ {{ roles_card_title }}</h2>
101 + <p class="text-sm">{{ roles_card_desc }}</p>
102 + <div class="stats shadow-sm mt-2">
103 + <div class="stat py-1 px-2">
104 + <div class="stat-title text-xs">{{ roles_stat_title }}</div>
105 + <div class="stat-value text-base">{{ total_roles }}</div>
106 + </div>
107 + </div>
108 + <div class="card-actions justify-end mt-2">
109 + <button class="btn btn-warning btn-sm">{{ access_button }}</button>
110 + </div>
111 + </div>
112 + </a>
113 +
114 + <a href="/admin/settings" class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
115 + <div class="card-body p-4">
116 + <h2 class="card-title text-secondary text-base">⚙️ {{ settings_card_title }}</h2>
117 + <p class="text-sm">{{ settings_card_desc }}</p>
118 + <div class="card-actions justify-end mt-2">
119 + <button class="btn btn-secondary btn-sm">{{ access_button }}</button>
120 + </div>
121 + </div>
122 + </a>
123 +
124 + <a href="/admin/apps" class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
125 + <div class="card-body p-4">
126 + <h2 class="card-title text-accent text-base">📦 {{ apps_card_title }}</h2>
127 + <p class="text-sm">{{ apps_card_desc }}</p>
128 + <div class="card-actions justify-end mt-2">
129 + <button class="btn btn-accent btn-sm">{{ access_button }}</button>
130 + </div>
131 + </div>
132 + </a>
133 +
134 + </div>
135 +</div>
136 +{% endblock %}
137 +
138 +{% block scripts %}
139 +{% if has_audit_logs %}
140 +<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
141 +<script>
142 +document.addEventListener('DOMContentLoaded', function() {
143 + const ctx = document.getElementById('audit-logs-chart');
144 + if (ctx) {
145 + new Chart(ctx, {
146 + type: 'bar',
147 + data: {
148 + labels: ['DEBUG', 'INFO', 'WARN', 'ERROR'],
149 + datasets: [{
150 + label: '{{ chart_label }}',
151 + data: [
152 + {{ audit_debug_count }},
153 + {{ audit_info_count }},
154 + {{ audit_warn_count }},
155 + {{ audit_error_count }}
156 + ],
157 + backgroundColor: [
158 + 'rgba(59, 130, 246, 0.5)', // info
159 + 'rgba(34, 197, 94, 0.5)', // success
160 + 'rgba(251, 191, 36, 0.5)', // warning
161 + 'rgba(239, 68, 68, 0.5)' // error
162 + ],
163 + borderColor: [
164 + 'rgba(59, 130, 246, 1)',
165 + 'rgba(34, 197, 94, 1)',
166 + 'rgba(251, 191, 36, 1)',
167 + 'rgba(239, 68, 68, 1)'
168 + ],
169 + borderWidth: 1
170 + }]
171 + },
172 + options: {
173 + responsive: true,
174 + maintainAspectRatio: true,
175 + scales: {
176 + y: {
177 + beginAtZero: true
178 + }
179 + },
180 + plugins: {
181 + legend: {
182 + display: false
183 + }
184 + }
185 + }
186 + });
187 + }
188 +});
189 +</script>
190 +{% endif %}
191 +{% endblock %}
A templates_old/admin/errors.html
+62 -0
@@ -0,0 +1,62 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block content %}
6 +<div class="container mx-auto px-4 py-4">
7 + <!-- Breadcrumb -->
8 + <div class="text-sm breadcrumbs mb-3">
9 + <ul>
10 + <li><a href="/">{{ breadcrumb_home }}</a></li>
11 + <li><a href="/admin">{{ breadcrumb_admin }}</a></li>
12 + <li>{{ breadcrumb_errors }}</li>
13 + </ul>
14 + </div>
15 +
16 + <h1 class="text-2xl font-bold mb-4">{{ title }}</h1>
17 + <p class="text-base-content/70 mb-6">{{ description }}</p>
18 +
19 + <!-- Error Codes Table -->
20 + <div class="overflow-x-auto">
21 + <table class="table table-zebra w-full">
22 + <thead>
23 + <tr>
24 + <th>{{ table_code }}</th>
25 + <th>{{ table_description }}</th>
26 + <th>{{ table_category }}</th>
27 + <th>{{ table_middleware }}</th>
28 + <th>{{ table_context }}</th>
29 + <th>{{ table_example }}</th>
30 + </tr>
31 + </thead>
32 + <tbody>
33 + {% for error in error_codes %}
34 + <tr>
35 + <td><code class="badge badge-error">{{ error.code }}</code></td>
36 + <td>{{ error.description }}</td>
37 + <td><span class="badge badge-outline">{{ error.category }}</span></td>
38 + <td><code class="text-xs">{{ error.middleware }}</code></td>
39 + <td class="text-sm text-base-content/70">{{ error.context }}</td>
40 + <td class="text-sm text-base-content/70">{{ error.example }}</td>
41 + </tr>
42 + {% endfor %}
43 + </tbody>
44 + </table>
45 + </div>
46 +
47 + <!-- Legend -->
48 + <div class="mt-6 card bg-base-200">
49 + <div class="card-body">
50 + <h2 class="card-title text-lg">{{ legend_title }}</h2>
51 + <ul class="list-disc list-inside space-y-2 text-sm">
52 + <li><strong>{{ table_code }}</strong> : {{ legend_code }}</li>
53 + <li><strong>{{ table_description }}</strong> : {{ legend_description }}</li>
54 + <li><strong>{{ table_category }}</strong> : {{ legend_category }}</li>
55 + <li><strong>{{ table_middleware }}</strong> : {{ legend_middleware }}</li>
56 + <li><strong>{{ table_context }}</strong> : {{ legend_context }}</li>
57 + <li><strong>{{ table_example }}</strong> : {{ legend_example }}</li>
58 + </ul>
59 + </div>
60 + </div>
61 +</div>
62 +{% endblock %}
A templates_old/admin/role_form.html
+135 -0
@@ -0,0 +1,135 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block content %}
6 +<div class="container mx-auto px-4 py-8">
7 + <!-- Breadcrumb -->
8 + <div class="text-sm breadcrumbs mb-6">
9 + <ul>
10 + <li><a href="/">{{ breadcrumb_home }}</a></li>
11 + <li><a href="/admin">{{ breadcrumb_admin }}</a></li>
12 + <li><a href="/admin/roles">{{ breadcrumb_roles }}</a></li>
13 + <li>{{ breadcrumb_mode }}</li>
14 + </ul>
15 + </div>
16 +
17 + <div class="flex items-center gap-4 mb-6">
18 + <a href="/admin/roles" class="btn btn-ghost btn-sm">
19 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
20 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
21 + </svg>
22 + {{ back_to_list }}
23 + </a>
24 + <h1 class="text-3xl font-bold">{{ form_title }}</h1>
25 + </div>
26 +
27 + <div class="card bg-base-100 shadow-xl max-w-3xl">
28 + <div class="card-body">
29 + <form id="roleForm"
30 + {% if mode == "create" %}
31 + hx-post="/admin/roles"
32 + {% else %}
33 + action="/admin/roles/{{ role_id }}"
34 + method="POST"
35 + {% endif %}
36 + hx-target="#form-message"
37 + hx-swap="innerHTML">
38 + {% if mode == "edit" %}
39 + <input type="hidden" name="_method" value="PUT">
40 + {% endif %}
41 + <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
42 +
43 + <!-- Name -->
44 + <div class="form-control mb-4">
45 + <label class="label">
46 + <span class="label-text font-semibold">{{ name_label }}</span>
47 + </label>
48 + {% if is_protected %}
49 + <input type="text" name="name" value="{{ form_name }}" class="input input-bordered" readonly>
50 + <label class="label">
51 + <span class="label-text-alt text-warning">{{ protected_name_hint }}</span>
52 + </label>
53 + {% else %}
54 + <input type="text" name="name" value="{{ form_name }}" class="input input-bordered"
55 + placeholder="{{ name_placeholder }}" required>
56 + {% endif %}
57 + </div>
58 +
59 + <!-- Description -->
60 + <div class="form-control mb-4">
61 + <label class="label">
62 + <span class="label-text font-semibold">{{ description_label }}</span>
63 + </label>
64 + <textarea name="description" class="textarea textarea-bordered"
65 + placeholder="{{ description_placeholder }}" rows="3">{{ form_description }}</textarea>
66 + </div>
67 +
68 + <!-- Permissions grouped by category -->
69 + <div class="form-control mb-6">
70 + <label class="label">
71 + <span class="label-text font-semibold">{{ permissions_label }}</span>
72 + </label>
73 +
74 + {% for group in permission_groups %}
75 + <div class="collapse collapse-arrow bg-base-200 mb-2">
76 + <input type="checkbox" checked>
77 + <div class="collapse-title font-medium capitalize">
78 + {{ group.category }}
79 + <span class="badge badge-sm badge-ghost ml-2">{{ group.permissions.len() }}</span>
80 + </div>
81 + <div class="collapse-content">
82 + <div class="grid grid-cols-1 md:grid-cols-2 gap-2 pt-2">
83 + {% for perm in group.permissions %}
84 + <label class="flex items-start gap-3 cursor-pointer p-2 rounded hover:bg-base-300 transition-colors">
85 + <input type="checkbox" name="permissions" value="{{ perm.id }}"
86 + class="checkbox checkbox-sm checkbox-primary mt-0.5"
87 + {% if perm.is_selected %}checked{% endif %}>
88 + <div>
89 + <div class="text-sm font-mono">{{ perm.name }}</div>
90 + {% if !perm.description.is_empty() %}
91 + <div class="text-xs text-base-content/60">{{ perm.description }}</div>
92 + {% endif %}
93 + </div>
94 + </label>
95 + {% endfor %}
96 + </div>
97 + </div>
98 + </div>
99 + {% endfor %}
100 + </div>
101 +
102 + <!-- Message area -->
103 + <div id="form-message" class="mb-4"></div>
104 +
105 + <!-- Actions -->
106 + <div class="flex justify-end gap-4">
107 + <a href="/admin/roles" class="btn btn-ghost">{{ cancel }}</a>
108 + <button type="submit" class="btn btn-primary">{{ submit_label }}</button>
109 + </div>
110 + </form>
111 + </div>
112 + </div>
113 +</div>
114 +
115 +{% if mode == "edit" %}
116 +<script>
117 +document.getElementById('roleForm').addEventListener('submit', function(e) {
118 + e.preventDefault();
119 + const form = e.target;
120 + const formData = new FormData(form);
121 + fetch(form.action, {
122 + method: 'PUT',
123 + headers: {
124 + 'X-CSRF-Token': '{{ csrf_token }}',
125 + },
126 + body: new URLSearchParams(formData),
127 + }).then(function(resp) {
128 + if (resp.redirected) {
129 + window.location.href = resp.url;
130 + }
131 + });
132 +});
133 +</script>
134 +{% endif %}
135 +{% endblock %}
A templates_old/admin/roles_list.html
+132 -0
@@ -0,0 +1,132 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block content %}
6 +<div class="container mx-auto px-4 py-8">
7 + <!-- Breadcrumb -->
8 + <div class="text-sm breadcrumbs mb-6">
9 + <ul>
10 + <li><a href="/">{{ breadcrumb_home }}</a></li>
11 + <li><a href="/admin">{{ breadcrumb_admin }}</a></li>
12 + <li>{{ breadcrumb_roles }}</li>
13 + </ul>
14 + </div>
15 +
16 + <div class="flex justify-between items-center mb-6">
17 + <h1 class="text-3xl font-bold">{{ title }}</h1>
18 + <a href="/admin/roles/create" class="btn btn-primary">
19 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
20 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
21 + </svg>
22 + {{ create_role }}
23 + </a>
24 + </div>
25 +
26 + <!-- Roles Table -->
27 + <div class="card bg-base-100 shadow-xl">
28 + <div class="card-body p-0">
29 + {% if roles.is_empty() %}
30 + <div class="p-8 text-center text-base-content/60">
31 + <svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-4 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
32 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
33 + </svg>
34 + <p>{{ no_roles }}</p>
35 + </div>
36 + {% else %}
37 + <div class="overflow-x-auto">
38 + <table class="table">
39 + <thead>
40 + <tr>
41 + <th>{{ table_name }}</th>
42 + <th>{{ table_description }}</th>
43 + <th class="text-center">{{ table_users }}</th>
44 + <th class="text-center">{{ table_permissions }}</th>
45 + <th class="text-right">{{ table_actions }}</th>
46 + </tr>
47 + </thead>
48 + <tbody>
49 + {% for role in roles %}
50 + <tr>
51 + <td>
52 + <div class="font-bold">{{ role.name }}</div>
53 + {% if role.is_protected %}
54 + <span class="badge badge-ghost badge-sm">{{ protected_hint }}</span>
55 + {% endif %}
56 + </td>
57 + <td>{{ role.description }}</td>
58 + <td class="text-center">
59 + <span class="badge badge-outline">{{ role.user_count }}</span>
60 + </td>
61 + <td class="text-center">
62 + <span class="badge badge-outline">{{ role.permission_count }}</span>
63 + </td>
64 + <td class="text-right">
65 + <div class="flex justify-end gap-2">
66 + <a href="/admin/roles/{{ role.id }}" class="btn btn-ghost btn-sm">
67 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
68 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
69 + </svg>
70 + {{ edit }}
71 + </a>
72 + {% if !role.is_protected %}
73 + <button class="btn btn-ghost btn-sm text-error"
74 + onclick="confirmDeleteRole('{{ role.id }}', '{{ role.name }}')">
75 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
76 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
77 + </svg>
78 + {{ delete_label }}
79 + </button>
80 + {% endif %}
81 + </div>
82 + </td>
83 + </tr>
84 + {% endfor %}
85 + </tbody>
86 + </table>
87 + </div>
88 + {% endif %}
89 + </div>
90 + </div>
91 +</div>
92 +
93 +<!-- Delete Confirmation Modal -->
94 +<dialog id="deleteRoleModal" class="modal">
95 + <div class="modal-box">
96 + <h3 class="text-lg font-bold">{{ delete_confirm_title }}</h3>
97 + <p class="py-4">{{ delete_confirm_message }} <strong id="deleteRoleName"></strong>?</p>
98 + <div class="modal-action">
99 + <form method="dialog">
100 + <button class="btn">{{ cancel }}</button>
101 + </form>
102 + <button id="confirmDeleteBtn" class="btn btn-error">{{ delete_button }}</button>
103 + </div>
104 + </div>
105 + <form method="dialog" class="modal-backdrop">
106 + <button>close</button>
107 + </form>
108 +</dialog>
109 +
110 +<script>
111 +let deleteRoleId = null;
112 +
113 +function confirmDeleteRole(id, name) {
114 + deleteRoleId = id;
115 + document.getElementById('deleteRoleName').textContent = name;
116 + document.getElementById('deleteRoleModal').showModal();
117 +}
118 +
119 +document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
120 + if (!deleteRoleId) return;
121 + fetch('/admin/roles/' + deleteRoleId, {
122 + method: 'DELETE',
123 + headers: {
124 + 'X-CSRF-Token': '{{ csrf_token }}',
125 + }
126 + }).then(function() {
127 + window.location.reload();
128 + });
129 + document.getElementById('deleteRoleModal').close();
130 +});
131 +</script>
132 +{% endblock %}
A templates_old/admin/settings.html
+697 -0
@@ -0,0 +1,697 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block content %}
6 +<div class="container mx-auto px-4 py-8">
7 + <!-- Breadcrumb -->
8 + <div class="text-sm breadcrumbs mb-6">
9 + <ul>
10 + <li><a href="/">{{ breadcrumb_home }}</a></li>
11 + <li><a href="/admin">{{ breadcrumb_admin }}</a></li>
12 + <li>{{ breadcrumb_settings }}</li>
13 + </ul>
14 + </div>
15 +
16 + <!-- Page Header -->
17 + <div class="mb-8">
18 + <h1 class="text-3xl font-bold mb-2">⚙️ {{ title }}</h1>
19 + <p class="text-base-content/70">{{ subtitle }}</p>
20 + </div>
21 +
22 + <form id="settingsForm" method="POST" action="/admin/settings" class="space-y-6 max-w-6xl">
23 + <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
24 +
25 + <!-- General Settings Card -->
26 + <div class="card bg-base-100 shadow-xl">
27 + <div class="card-body">
28 + <h2 class="card-title text-xl mb-4">
29 + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
30 + {{ general }}
31 + </h2>
32 +
33 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
34 + <!-- App Name -->
35 + <div class="form-control">
36 + <label class="label">
37 + <span class="label-text font-semibold">{{ app_name_label }}</span>
38 + </label>
39 + <input
40 + type="text"
41 + name="app_name"
42 + value="{{ settings.app_name }}"
43 + class="input input-bordered w-full"
44 + >
45 + </div>
46 +
47 + <!-- App Domain -->
48 + <div class="form-control">
49 + <label class="label">
50 + <span class="label-text font-semibold">{{ app_domain_label }}</span>
51 + </label>
52 + <input
53 + type="text"
54 + name="app_domain"
55 + value="{{ settings.app_domain }}"
56 + class="input input-bordered w-full"
57 + placeholder="{{ app_domain_placeholder }}"
58 + >
59 + </div>
60 +
61 + <!-- App Description -->
62 + <div class="form-control md:col-span-2">
63 + <label class="label">
64 + <span class="label-text font-semibold">{{ app_description_label }}</span>
65 + </label>
66 + <textarea
67 + name="app_description"
68 + class="textarea textarea-bordered w-full"
69 + rows="2"
70 + >{{ settings.app_description }}</textarea>
71 + </div>
72 +
73 + <!-- Allow Registration -->
74 + <div class="form-control">
75 + <label class="label cursor-pointer justify-start gap-4">
76 + <input
77 + type="checkbox"
78 + name="allow_registration"
79 + value="true"
80 + class="checkbox checkbox-primary"
81 + {% if settings.allow_registration == "true" %}checked{% endif %}
82 + >
83 + <div>
84 + <span class="label-text font-semibold">{{ allow_registration_label }}</span>
85 + <span class="label-text-alt block">{{ allow_registration_desc }}</span>
86 + </div>
87 + </label>
88 + </div>
89 +
90 + <!-- Validation email requise -->
91 + <div class="form-control">
92 + <label class="label cursor-pointer justify-start gap-4">
93 + <input
94 + type="checkbox"
95 + name="validation_email_requise"
96 + value="true"
97 + class="checkbox checkbox-primary"
98 + {% if settings.validation_email_requise == "true" %}checked{% endif %}
99 + >
100 + <div>
101 + <span class="label-text font-semibold">{{ email_validation_required_label }}</span>
102 + <span class="label-text-alt block">{{ email_validation_required_desc }}</span>
103 + </div>
104 + </label>
105 + </div>
106 + </div>
107 + </div>
108 + </div>
109 +
110 + <!-- Password Security Card -->
111 + <div class="card bg-base-100 shadow-xl">
112 + <div class="card-body">
113 + <h2 class="card-title text-xl mb-4">
114 + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
115 + {{ password_security }}
116 + </h2>
117 +
118 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
119 + <!-- Min Length -->
120 + <div class="form-control">
121 + <label class="label">
122 + <span class="label-text font-semibold">{{ password_min_length }}</span>
123 + </label>
124 + <input
125 + type="number"
126 + name="password_min_length"
127 + value="{{ settings.password_min_length }}"
128 + class="input input-bordered w-full"
129 + min="6"
130 + max="128"
131 + >
132 + <label class="label">
133 + <span class="label-text-alt">{{ password_min_length_hint }}</span>
134 + </label>
135 + </div>
136 +
137 + <!-- Complexity Requirements -->
138 + <div class="space-y-2">
139 + <label class="label">
140 + <span class="label-text font-semibold">{{ password_complexity }}</span>
141 + </label>
142 + <div class="form-control">
143 + <label class="label cursor-pointer justify-start gap-3 py-1">
144 + <input
145 + type="checkbox"
146 + name="password_require_uppercase"
147 + value="true"
148 + class="checkbox checkbox-sm"
149 + {% if settings.password_require_uppercase == "true" %}checked{% endif %}
150 + >
151 + <span class="label-text">{{ require_uppercase }}</span>
152 + </label>
153 + </div>
154 + <div class="form-control">
155 + <label class="label cursor-pointer justify-start gap-3 py-1">
156 + <input
157 + type="checkbox"
158 + name="password_require_lowercase"
159 + value="true"
160 + class="checkbox checkbox-sm"
161 + {% if settings.password_require_lowercase == "true" %}checked{% endif %}
162 + >
163 + <span class="label-text">{{ require_lowercase }}</span>
164 + </label>
165 + </div>
166 + <div class="form-control">
167 + <label class="label cursor-pointer justify-start gap-3 py-1">
168 + <input
169 + type="checkbox"
170 + name="password_require_digits"
171 + value="true"
172 + class="checkbox checkbox-sm"
173 + {% if settings.password_require_digits == "true" %}checked{% endif %}
174 + >
175 + <span class="label-text">{{ require_digits }}</span>
176 + </label>
177 + </div>
178 + <div class="form-control">
179 + <label class="label cursor-pointer justify-start gap-3 py-1">
180 + <input
181 + type="checkbox"
182 + name="password_require_special"
183 + value="true"
184 + class="checkbox checkbox-sm"
185 + {% if settings.password_require_special == "true" %}checked{% endif %}
186 + >
187 + <span class="label-text">{{ require_special }}</span>
188 + </label>
189 + </div>
190 + </div>
191 +
192 + <!-- Password change email confirmation -->
193 + <div class="form-control mt-4">
194 + <label class="label cursor-pointer justify-start gap-4">
195 + <input
196 + type="checkbox"
197 + name="password_change_require_email"
198 + value="true"
199 + class="toggle toggle-primary"
200 + {% if settings.password_change_require_email == "true" %}checked{% endif %}
201 + >
202 + <div>
203 + <span class="label-text font-medium">{{ password_change_require_email }}</span>
204 + <p class="text-xs text-base-content/60 mt-0.5">{{ password_change_require_email_desc }}</p>
205 + </div>
206 + </label>
207 + </div>
208 +
209 + <!-- Password Expiration -->
210 + <div class="form-control">
211 + <label class="label cursor-pointer justify-start gap-4">
212 + <input
213 + type="checkbox"
214 + name="password_expiration_enabled"
215 + value="true"
216 + class="checkbox checkbox-primary"
217 + id="expirationEnabled"
218 + {% if settings.password_expiration_enabled == "true" %}checked{% endif %}
219 + >
220 + <div>
221 + <span class="label-text font-semibold">{{ password_expiration }}</span>
222 + <span class="label-text-alt block">{{ password_expiration_desc }}</span>
223 + </div>
224 + </label>
225 + </div>
226 +
227 + <div id="expirationSettings" class="space-y-3">
228 + <div class="form-control">
229 + <label class="label">
230 + <span class="label-text">{{ expiration_days }}</span>
231 + </label>
232 + <input
233 + type="number"
234 + name="password_expiration_days"
235 + value="{{ settings.password_expiration_days }}"
236 + class="input input-bordered input-sm w-full"
237 + min="0"
238 + max="365"
239 + >
240 + </div>
241 +
242 + <div class="form-control">
243 + <label class="label cursor-pointer justify-start gap-3">
244 + <input
245 + type="checkbox"
246 + name="password_expiration_alert_enabled"
247 + value="true"
248 + class="checkbox checkbox-sm"
249 + {% if settings.password_expiration_alert_enabled == "true" %}checked{% endif %}
250 + >
251 + <span class="label-text">{{ expiration_alert }}</span>
252 + </label>
253 + </div>
254 +
255 + <div class="form-control">
256 + <label class="label">
257 + <span class="label-text">{{ expiration_alert_days }}</span>
258 + </label>
259 + <input
260 + type="number"
261 + name="password_expiration_alert_days"
262 + value="{{ settings.password_expiration_alert_days_before }}"
263 + class="input input-bordered input-sm w-full"
264 + min="0"
265 + max="30"
266 + >
267 + </div>
268 + </div>
269 + </div>
270 + </div>
271 + </div>
272 +
273 + <!-- Audit Logging Card -->
274 + <div class="card bg-base-100 shadow-xl">
275 + <div class="card-body">
276 + <h2 class="card-title text-xl mb-4">
277 + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
278 + {{ audit_logging }}
279 + </h2>
280 +
281 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
282 + <!-- Log Level -->
283 + <div class="form-control">
284 + <label class="label">
285 + <span class="label-text font-semibold">{{ log_level }}</span>
286 + </label>
287 + <select
288 + name="audit_log_level"
289 + class="select select-bordered w-full"
290 + >
291 + <option value="DEBUG" {% if settings.audit_log_level == "DEBUG" %}selected{% endif %}>{{ log_level_debug }}</option>
292 + <option value="INFO" {% if settings.audit_log_level == "INFO" %}selected{% endif %}>{{ log_level_info }}</option>
293 + <option value="WARN" {% if settings.audit_log_level == "WARN" %}selected{% endif %}>{{ log_level_warn }}</option>
294 + <option value="ERROR" {% if settings.audit_log_level == "ERROR" %}selected{% endif %}>{{ log_level_error }}</option>
295 + </select>
296 + <label class="label">
297 + <span class="label-text-alt">{{ log_level_hint }}</span>
298 + </label>
299 + </div>
300 +
301 + <!-- Retention -->
302 + <div class="form-control">
303 + <label class="label">
304 + <span class="label-text font-semibold">{{ log_retention }}</span>
305 + </label>
306 + <input
307 + type="number"
308 + name="audit_log_retention_days"
309 + value="{{ settings.audit_log_retention_days }}"
310 + class="input input-bordered w-full"
311 + min="1"
312 + max="365"
313 + >
314 + <label class="label">
315 + <span class="label-text-alt">{{ log_retention_hint }}</span>
316 + </label>
317 + </div>
318 + </div>
319 +
320 + <div class="alert alert-info mt-4">
321 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
322 + <span class="text-sm">{{ audit_info_message }}</span>
323 + </div>
324 + </div>
325 + </div>
326 +
327 + <!-- OAuth / SSO Card -->
328 + <div class="card bg-base-100 shadow-xl">
329 + <div class="card-body">
330 + <h2 class="card-title text-xl mb-4">
331 + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
332 + {{ oauth_sso_title }}
333 + </h2>
334 +
335 + <div class="space-y-4">
336 + <!-- Global OAuth Toggle -->
337 + <div class="form-control">
338 + <label class="label cursor-pointer justify-start gap-4">
339 + <input type="checkbox" name="oauth_enabled" value="true" class="toggle toggle-primary" id="oauthEnabled"
340 + {% if settings.oauth_enabled == "true" %}checked{% endif %}>
341 + <div>
342 + <span class="label-text font-semibold">{{ oauth_enabled_label }}</span>
343 + <span class="label-text-alt block">{{ oauth_enabled_desc }}</span>
344 + </div>
345 + </label>
346 + </div>
347 +
348 + <!-- OAuth global options (shown when OAuth is enabled) -->
349 + <div id="oauthGlobalOptions" class="space-y-4">
350 + <!-- Base redirect URL -->
351 + <div class="form-control">
352 + <label class="label"><span class="label-text font-semibold">{{ oauth_redirect_url_label }}</span></label>
353 + <input type="text" name="oauth_redirect_base_url" value="{{ settings.oauth_redirect_base_url }}"
354 + placeholder="https://myapp.example.com" class="input input-bordered w-full">
355 + <label class="label"><span class="label-text-alt">{{ oauth_redirect_url_desc }}</span></label>
356 + </div>
357 +
358 + <!-- Auto-register & Link existing toggles -->
359 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
360 + <div class="form-control">
361 + <label class="label cursor-pointer justify-start gap-4">
362 + <input type="checkbox" name="oauth_auto_register" value="true" class="toggle toggle-secondary"
363 + {% if settings.oauth_auto_register == "true" %}checked{% endif %}>
364 + <div>
365 + <span class="label-text font-semibold">{{ oauth_auto_register_label }}</span>
366 + <span class="label-text-alt block">{{ oauth_auto_register_desc }}</span>
367 + </div>
368 + </label>
369 + </div>
370 + <div class="form-control">
371 + <label class="label cursor-pointer justify-start gap-4">
372 + <input type="checkbox" name="oauth_link_existing_account" value="true" class="toggle toggle-secondary"
373 + {% if settings.oauth_link_existing_account == "true" %}checked{% endif %}>
374 + <div>
375 + <span class="label-text font-semibold">{{ oauth_link_existing_label }}</span>
376 + <span class="label-text-alt block">{{ oauth_link_existing_desc }}</span>
377 + </div>
378 + </label>
379 + </div>
380 + </div>
381 +
382 + <div class="divider my-2"></div>
383 +
384 + <!-- ===== Google ===== -->
385 + <div class="collapse collapse-arrow bg-base-200 rounded-box" id="collapseGoogle">
386 + <input type="checkbox" class="peer oauth-provider-collapse" data-provider="google"
387 + {% if settings.oauth_google_enabled == "true" %}checked{% endif %} />
388 + <div class="collapse-title flex items-center gap-3">
389 + <svg class="h-5 w-5" viewBox="0 0 24 24"><path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
390 + <span class="font-semibold">{{ oauth_google_label }}</span>
391 + <span class="badge badge-sm badge-outline ml-1">{{ oauth_google_desc }}</span>
392 + <input type="checkbox" name="oauth_google_enabled" value="true" class="toggle toggle-primary toggle-sm ml-auto oauth-provider-toggle"
393 + data-provider="google"
394 + {% if settings.oauth_google_enabled == "true" %}checked{% endif %}
395 + onclick="event.stopPropagation()">
396 + </div>
397 + <div class="collapse-content space-y-3">
398 + <div class="form-control">
399 + <label class="label"><span class="label-text">{{ oauth_client_id }}</span></label>
400 + <input type="text" name="oauth_google_client_id" value="{{ settings.oauth_google_client_id }}"
401 + placeholder="xxxx.apps.googleusercontent.com" class="input input-bordered input-sm w-full">
402 + </div>
403 + <div class="form-control">
404 + <label class="label">
405 + <span class="label-text">{{ oauth_client_secret }}</span>
406 + {% if settings.oauth_google_has_secret %}
407 + <span class="badge badge-success badge-sm gap-1">
408 + <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
409 + {{ oauth_secret_configured }}
410 + </span>
411 + {% else %}
412 + <span class="badge badge-warning badge-sm">{{ oauth_secret_not_configured }}</span>
413 + {% endif %}
414 + </label>
415 + <input type="password" name="oauth_google_client_secret"
416 + placeholder="{% if settings.oauth_google_has_secret %}{{ oauth_secret_change }}{% else %}GOCSPX-...{% endif %}"
417 + class="input input-bordered input-sm w-full" autocomplete="off">
418 + </div>
419 + <div class="form-control">
420 + <label class="label"><span class="label-text">{{ oauth_redirect_uri }}</span></label>
421 + <div class="join w-full">
422 + <input type="text" readonly
423 + value="{{ settings.oauth_redirect_base_url }}/api/v1/auth/oauth/google/callback"
424 + class="input input-bordered input-sm join-item w-full bg-base-200 font-mono text-xs" id="googleRedirectUri">
425 + <button type="button" class="btn btn-sm join-item btn-outline copy-uri-btn" data-target="googleRedirectUri">{{ oauth_copy_uri }}</button>
426 + </div>
427 + </div>
428 + </div>
429 + </div>
430 +
431 + <!-- ===== GitHub ===== -->
432 + <div class="collapse collapse-arrow bg-base-200 rounded-box" id="collapseGithub">
433 + <input type="checkbox" class="peer oauth-provider-collapse" data-provider="github"
434 + {% if settings.oauth_github_enabled == "true" %}checked{% endif %} />
435 + <div class="collapse-title flex items-center gap-3">
436 + <svg class="h-5 w-5" viewBox="0 0 24 24"><path fill="currentColor" d="M12 .3a12 12 0 0 0-3.8 23.38c.6.12.83-.26.83-.57L9 21.07c-3.34.72-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.08-.74.09-.73.09-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.81 1.3 3.5 1 .1-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.14-.3-.54-1.52.1-3.18 0 0 1-.32 3.3 1.23a11.5 11.5 0 0 1 6.02 0c2.28-1.55 3.29-1.23 3.29-1.23.64 1.66.24 2.88.12 3.18a4.65 4.65 0 0 1 1.23 3.22c0 4.61-2.8 5.63-5.48 5.92.42.36.81 1.1.81 2.22l-.01 3.29c0 .31.2.69.82.57A12 12 0 0 0 12 .3z"/></svg>
437 + <span class="font-semibold">{{ oauth_github_label }}</span>
438 + <span class="badge badge-sm badge-outline ml-1">{{ oauth_github_desc }}</span>
439 + <input type="checkbox" name="oauth_github_enabled" value="true" class="toggle toggle-primary toggle-sm ml-auto oauth-provider-toggle"
440 + data-provider="github"
441 + {% if settings.oauth_github_enabled == "true" %}checked{% endif %}
442 + onclick="event.stopPropagation()">
443 + </div>
444 + <div class="collapse-content space-y-3">
445 + <div class="form-control">
446 + <label class="label"><span class="label-text">{{ oauth_client_id }}</span></label>
447 + <input type="text" name="oauth_github_client_id" value="{{ settings.oauth_github_client_id }}"
448 + placeholder="Iv1.xxxxxx" class="input input-bordered input-sm w-full">
449 + </div>
450 + <div class="form-control">
451 + <label class="label">
452 + <span class="label-text">{{ oauth_client_secret }}</span>
453 + {% if settings.oauth_github_has_secret %}
454 + <span class="badge badge-success badge-sm gap-1">
455 + <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
456 + {{ oauth_secret_configured }}
457 + </span>
458 + {% else %}
459 + <span class="badge badge-warning badge-sm">{{ oauth_secret_not_configured }}</span>
460 + {% endif %}
461 + </label>
462 + <input type="password" name="oauth_github_client_secret"
463 + placeholder="{% if settings.oauth_github_has_secret %}{{ oauth_secret_change }}{% else %}ghp_...{% endif %}"
464 + class="input input-bordered input-sm w-full" autocomplete="off">
465 + </div>
466 + <div class="form-control">
467 + <label class="label"><span class="label-text">{{ oauth_redirect_uri }}</span></label>
468 + <div class="join w-full">
469 + <input type="text" readonly
470 + value="{{ settings.oauth_redirect_base_url }}/api/v1/auth/oauth/github/callback"
471 + class="input input-bordered input-sm join-item w-full bg-base-200 font-mono text-xs" id="githubRedirectUri">
472 + <button type="button" class="btn btn-sm join-item btn-outline copy-uri-btn" data-target="githubRedirectUri">{{ oauth_copy_uri }}</button>
473 + </div>
474 + </div>
475 + </div>
476 + </div>
477 +
478 + <!-- ===== Discord ===== -->
479 + <div class="collapse collapse-arrow bg-base-200 rounded-box" id="collapseDiscord">
480 + <input type="checkbox" class="peer oauth-provider-collapse" data-provider="discord"
481 + {% if settings.oauth_discord_enabled == "true" %}checked{% endif %} />
482 + <div class="collapse-title flex items-center gap-3">
483 + <svg class="h-5 w-5" viewBox="0 0 24 24"><path fill="currentColor" d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
484 + <span class="font-semibold">{{ oauth_discord_label }}</span>
485 + <span class="badge badge-sm badge-outline ml-1">{{ oauth_discord_desc }}</span>
486 + <input type="checkbox" name="oauth_discord_enabled" value="true" class="toggle toggle-primary toggle-sm ml-auto oauth-provider-toggle"
487 + data-provider="discord"
488 + {% if settings.oauth_discord_enabled == "true" %}checked{% endif %}
489 + onclick="event.stopPropagation()">
490 + </div>
491 + <div class="collapse-content space-y-3">
492 + <div class="form-control">
493 + <label class="label"><span class="label-text">{{ oauth_client_id }}</span></label>
494 + <input type="text" name="oauth_discord_client_id" value="{{ settings.oauth_discord_client_id }}"
495 + placeholder="123456789012345678" class="input input-bordered input-sm w-full">
496 + </div>
497 + <div class="form-control">
498 + <label class="label">
499 + <span class="label-text">{{ oauth_client_secret }}</span>
500 + {% if settings.oauth_discord_has_secret %}
501 + <span class="badge badge-success badge-sm gap-1">
502 + <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
503 + {{ oauth_secret_configured }}
504 + </span>
505 + {% else %}
506 + <span class="badge badge-warning badge-sm">{{ oauth_secret_not_configured }}</span>
507 + {% endif %}
508 + </label>
509 + <input type="password" name="oauth_discord_client_secret"
510 + placeholder="{% if settings.oauth_discord_has_secret %}{{ oauth_secret_change }}{% endif %}"
511 + class="input input-bordered input-sm w-full" autocomplete="off">
512 + </div>
513 + <div class="form-control">
514 + <label class="label"><span class="label-text">{{ oauth_redirect_uri }}</span></label>
515 + <div class="join w-full">
516 + <input type="text" readonly
517 + value="{{ settings.oauth_redirect_base_url }}/api/v1/auth/oauth/discord/callback"
518 + class="input input-bordered input-sm join-item w-full bg-base-200 font-mono text-xs" id="discordRedirectUri">
519 + <button type="button" class="btn btn-sm join-item btn-outline copy-uri-btn" data-target="discordRedirectUri">{{ oauth_copy_uri }}</button>
520 + </div>
521 + </div>
522 + </div>
523 + </div>
524 +
525 + <!-- ===== Microsoft ===== -->
526 + <div class="collapse collapse-arrow bg-base-200 rounded-box" id="collapseMicrosoft">
527 + <input type="checkbox" class="peer oauth-provider-collapse" data-provider="microsoft"
528 + {% if settings.oauth_microsoft_enabled == "true" %}checked{% endif %} />
529 + <div class="collapse-title flex items-center gap-3">
530 + <svg class="h-5 w-5" viewBox="0 0 24 24"><path fill="#f25022" d="M1 1h10v10H1z"/><path fill="#00a4ef" d="M1 13h10v10H1z"/><path fill="#7fba00" d="M13 1h10v10H13z"/><path fill="#ffb900" d="M13 13h10v10H13z"/></svg>
531 + <span class="font-semibold">{{ oauth_microsoft_label }}</span>
532 + <span class="badge badge-sm badge-outline ml-1">{{ oauth_microsoft_desc }}</span>
533 + <input type="checkbox" name="oauth_microsoft_enabled" value="true" class="toggle toggle-primary toggle-sm ml-auto oauth-provider-toggle"
534 + data-provider="microsoft"
535 + {% if settings.oauth_microsoft_enabled == "true" %}checked{% endif %}
536 + onclick="event.stopPropagation()">
537 + </div>
538 + <div class="collapse-content space-y-3">
539 + <div class="form-control">
540 + <label class="label"><span class="label-text">{{ oauth_client_id }}</span></label>
541 + <input type="text" name="oauth_microsoft_client_id" value="{{ settings.oauth_microsoft_client_id }}"
542 + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" class="input input-bordered input-sm w-full">
543 + </div>
544 + <div class="form-control">
545 + <label class="label">
546 + <span class="label-text">{{ oauth_client_secret }}</span>
547 + {% if settings.oauth_microsoft_has_secret %}
548 + <span class="badge badge-success badge-sm gap-1">
549 + <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
550 + {{ oauth_secret_configured }}
551 + </span>
552 + {% else %}
553 + <span class="badge badge-warning badge-sm">{{ oauth_secret_not_configured }}</span>
554 + {% endif %}
555 + </label>
556 + <input type="password" name="oauth_microsoft_client_secret"
557 + placeholder="{% if settings.oauth_microsoft_has_secret %}{{ oauth_secret_change }}{% endif %}"
558 + class="input input-bordered input-sm w-full" autocomplete="off">
559 + </div>
560 + <div class="form-control">
561 + <label class="label"><span class="label-text">{{ oauth_tenant }}</span></label>
562 + <input type="text" name="oauth_microsoft_tenant" value="{{ settings.oauth_microsoft_tenant }}"
563 + placeholder="common" class="input input-bordered input-sm w-full">
564 + </div>
565 + <div class="form-control">
566 + <label class="label"><span class="label-text">{{ oauth_redirect_uri }}</span></label>
567 + <div class="join w-full">
568 + <input type="text" readonly
569 + value="{{ settings.oauth_redirect_base_url }}/api/v1/auth/oauth/microsoft/callback"
570 + class="input input-bordered input-sm join-item w-full bg-base-200 font-mono text-xs" id="microsoftRedirectUri">
571 + <button type="button" class="btn btn-sm join-item btn-outline copy-uri-btn" data-target="microsoftRedirectUri">{{ oauth_copy_uri }}</button>
572 + </div>
573 + </div>
574 + </div>
575 + </div>
576 + </div>
577 +
578 + <!-- Restart warning -->
579 + <div class="alert alert-warning mt-4">
580 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
581 + <span class="text-sm">{{ oauth_restart_warning }}</span>
582 + </div>
583 +
584 + <div class="alert alert-info mt-2">
585 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
586 + <span class="text-sm">{{ oauth_env_info }}</span>
587 + </div>
588 + </div>
589 + </div>
590 + </div>
591 +
592 + <!-- Action Buttons -->
593 + <div class="card bg-base-200 shadow-xl">
594 + <div class="card-body">
595 + <div class="flex justify-between items-center">
596 + <div>
597 + <h3 class="font-semibold">{{ save_changes }}</h3>
598 + <p class="text-sm text-base-content/70">{{ save_changes_desc }}</p>
599 + </div>
600 + <div class="flex gap-2">
601 + <a href="/admin" class="btn btn-ghost">{{ cancel }}</a>
602 + <button type="submit" class="btn btn-primary">
603 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
604 + {{ save_button }}
605 + </button>
606 + </div>
607 + </div>
608 + </div>
609 + </div>
610 + </form>
611 +</div>
612 +
613 +<script>
614 +const expirationCheckbox = document.getElementById('expirationEnabled');
615 +const expirationSettings = document.getElementById('expirationSettings');
616 +
617 +function toggleExpirationSettings() {
618 + if (expirationCheckbox.checked) {
619 + expirationSettings.classList.remove('hidden');
620 + } else {
621 + expirationSettings.classList.add('hidden');
622 + }
623 +}
624 +
625 +if (expirationCheckbox) {
626 + expirationCheckbox.addEventListener('change', toggleExpirationSettings);
627 + toggleExpirationSettings();
628 +}
629 +
630 +const oauthCheckbox = document.getElementById('oauthEnabled');
631 +const oauthGlobalOptions = document.getElementById('oauthGlobalOptions');
632 +
633 +function toggleOauthSection() {
634 + if (oauthCheckbox && oauthGlobalOptions) {
635 + if (oauthCheckbox.checked) {
636 + oauthGlobalOptions.classList.remove('hidden', 'opacity-50');
637 + oauthGlobalOptions.style.pointerEvents = '';
638 + } else {
639 + oauthGlobalOptions.classList.add('hidden');
640 + }
641 + }
642 +}
643 +
644 +if (oauthCheckbox) {
645 + oauthCheckbox.addEventListener('change', toggleOauthSection);
646 + toggleOauthSection();
647 +}
648 +
649 +document.querySelectorAll('.copy-uri-btn').forEach(function(btn) {
650 + btn.addEventListener('click', function() {
651 + var target = document.getElementById(this.dataset.target);
652 + if (target) {
653 + navigator.clipboard.writeText(target.value).then(function() {
654 + btn.textContent = '✓';
655 + setTimeout(function() { btn.textContent = '{{ oauth_copy_uri }}'; }, 1500);
656 + });
657 + }
658 + });
659 +});
660 +
661 +document.getElementById('settingsForm').addEventListener('submit', async function(e) {
662 + e.preventDefault();
663 +
664 + const formData = new FormData(this);
665 + const submitBtn = this.querySelector('button[type="submit"]');
666 +
667 + submitBtn.disabled = true;
668 + submitBtn.innerHTML = '<span class="loading loading-spinner"></span> {{ saving }}';
669 +
670 + try {
671 + const response = await fetch('/admin/settings', {
672 + method: 'PUT',
673 + headers: {
674 + 'Content-Type': 'application/x-www-form-urlencoded',
675 + },
676 + body: new URLSearchParams(formData)
677 + });
678 +
679 + const result = await response.json();
680 +
681 + if (response.ok) {
682 + alert('✅ {{ save_success }}');
683 + window.location.reload();
684 + } else {
685 + alert('❌ {{ save_error_prefix }} : ' + (result.error || result.message || ''));
686 + submitBtn.disabled = false;
687 + submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> {{ save_button }}';
688 + }
689 + } catch (error) {
690 + console.error('Error:', error);
691 + alert('❌ {{ network_error }}');
692 + submitBtn.disabled = false;
693 + submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> {{ save_button }}';
694 + }
695 +});
696 +</script>
697 +{% endblock %}
A templates_old/admin/user_form.html
+183 -0
@@ -0,0 +1,183 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block content %}
6 +<div class="container mx-auto px-4 py-8">
7 + <!-- Breadcrumb -->
8 + <div class="text-sm breadcrumbs mb-6">
9 + <ul>
10 + <li><a href="/">{{ breadcrumb_home }}</a></li>
11 + <li><a href="/admin">{{ breadcrumb_admin }}</a></li>
12 + <li><a href="/admin/users">{{ breadcrumb_users }}</a></li>
13 + <li>{{ breadcrumb_mode }}</li>
14 + </ul>
15 + </div>
16 +
17 + <div class="flex justify-between items-center mb-6">
18 + <h1 class="text-3xl font-bold">{{ form_title }}</h1>
19 + <a href="/admin/users" class="btn btn-ghost">
20 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
21 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
22 + </svg>
23 + {{ back_to_list }}
24 + </a>
25 + </div>
26 +
27 + <!-- Messages d'erreur/succès -->
28 + <div id="form-messages" class="mb-6"></div>
29 +
30 + <!-- User Form -->
31 + <div class="card bg-base-100 shadow-xl">
32 + <div class="card-body">
33 + {% if mode == "create" %}
34 + <form
35 + method="POST"
36 + action="/admin/users"
37 + class="space-y-6"
38 + hx-post="/admin/users"
39 + hx-target="#form-messages"
40 + hx-swap="innerHTML"
41 + hx-indicator="#form-loading"
42 + >
43 + {% else %}
44 + <form
45 + method="PUT"
46 + action="/admin/users/{{ user_id }}"
47 + class="space-y-6"
48 + hx-put="/admin/users/{{ user_id }}"
49 + hx-target="#form-messages"
50 + hx-swap="innerHTML"
51 + hx-indicator="#form-loading"
52 + >
53 + {% endif %}
54 + <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
55 +
56 + <!-- Username Field -->
57 + <div class="form-control">
58 + <label class="label">
59 + <span class="label-text font-semibold">{{ username_label }}</span>
60 + <span class="label-text-alt text-error">*</span>
61 + </label>
62 + {% if mode == "create" %}
63 + <input
64 + type="text"
65 + name="username"
66 + value="{{ form_username }}"
67 + placeholder="{{ username_placeholder }}"
68 + class="input input-bordered w-full"
69 + required
70 + >
71 + {% else %}
72 + <input
73 + type="text"
74 + value="{{ form_username }}"
75 + class="input input-bordered w-full input-disabled"
76 + readonly
77 + >
78 + <input type="hidden" name="username" value="{{ form_username }}">
79 + {% endif %}
80 + <label class="label">
81 + <span class="label-text-alt">{{ username_readonly_hint }}</span>
82 + </label>
83 + </div>
84 +
85 + <!-- Email Field -->
86 + <div class="form-control">
87 + <label class="label">
88 + <span class="label-text font-semibold">{{ email_label }}</span>
89 + <span class="label-text-alt text-error">*</span>
90 + </label>
91 + <input
92 + type="email"
93 + name="email"
94 + value="{{ form_email }}"
95 + placeholder="{{ email_placeholder }}"
96 + class="input input-bordered w-full"
97 + required
98 + >
99 + </div>
100 +
101 + <!-- Password Field (only for create mode or if resetting) -->
102 + {% if mode == "create" %}
103 + <div class="form-control">
104 + <label class="label">
105 + <span class="label-text font-semibold">{{ password_label }}</span>
106 + <span class="label-text-alt">{{ password_optional }}</span>
107 + </label>
108 + <input
109 + type="password"
110 + name="password"
111 + placeholder="{{ password_placeholder }}"
112 + class="input input-bordered w-full"
113 + >
114 + <label class="label">
115 + <span class="label-text-alt">{{ password_hint }}</span>
116 + </label>
117 + </div>
118 + {% endif %}
119 +
120 + <!-- Roles Field -->
121 + <div class="form-control">
122 + <label class="label">
123 + <span class="label-text font-semibold">{{ roles_label }}</span>
124 + <span class="label-text-alt text-error">*</span>
125 + </label>
126 + <div class="space-y-2">
127 + {% for role in available_roles %}
128 + <label class="label cursor-pointer justify-start gap-3">
129 + <input
130 + type="checkbox"
131 + name="roles"
132 + value="{{ role.name }}"
133 + class="checkbox checkbox-primary"
134 + {% if role.is_selected %}checked{% endif %}
135 + >
136 + <div class="flex-1">
137 + <span class="label-text font-medium">{{ role.name }}</span>
138 + {% if role.description.len() > 0 %}
139 + <p class="text-sm text-base-content/70">{{ role.description }}</p>
140 + {% endif %}
141 + </div>
142 + </label>
143 + {% endfor %}
144 + </div>
145 + <label class="label">
146 + <span class="label-text-alt">{{ roles_hint }}</span>
147 + </label>
148 + </div>
149 +
150 + <!-- Must Change Password (only for edit mode) -->
151 + {% if mode == "edit" %}
152 + <div class="form-control">
153 + <label class="label cursor-pointer justify-start gap-3">
154 + <input
155 + type="checkbox"
156 + name="must_change_password"
157 + value="true"
158 + class="checkbox checkbox-warning"
159 + {% if must_change_password %}checked{% endif %}
160 + >
161 + <div class="flex-1">
162 + <span class="label-text font-medium">{{ must_change_password_label }}</span>
163 + <p class="text-sm text-base-content/70">{{ must_change_password_desc }}</p>
164 + </div>
165 + </label>
166 + </div>
167 + {% endif %}
168 +
169 + <!-- Form Actions -->
170 + <div class="flex justify-end gap-4 pt-4 border-t">
171 + <a href="/admin/users" class="btn btn-ghost">{{ cancel }}</a>
172 + <button type="submit" class="btn btn-primary" id="submit-btn">
173 + <span id="form-loading" class="htmx-indicator loading loading-spinner loading-sm mr-2"></span>
174 + {{ submit_label }}
175 + </button>
176 + </div>
177 + </form>
178 + {% if mode != "create" %}
179 + {% endif %}
180 + </div>
181 + </div>
182 +</div>
183 +{% endblock %}
A templates_old/admin/users_list.html
+509 -0
@@ -0,0 +1,509 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block content %}
6 +<div class="container mx-auto px-4 py-8">
7 + <!-- Breadcrumb -->
8 + <div class="text-sm breadcrumbs mb-6">
9 + <ul>
10 + <li><a href="/">{{ breadcrumb_home }}</a></li>
11 + <li><a href="/admin">{{ breadcrumb_admin }}</a></li>
12 + <li>{{ breadcrumb_users }}</li>
13 + </ul>
14 + </div>
15 +
16 + <div class="flex justify-between items-center mb-6">
17 + <h1 class="text-3xl font-bold">{{ title }}</h1>
18 + <a href="/admin/users/create" class="btn btn-primary">
19 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
20 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
21 + </svg>
22 + {{ create_user }}
23 + </a>
24 + </div>
25 +
26 + <!-- Search and Filter Form -->
27 + <div class="card bg-base-100 shadow-xl mb-6">
28 + <div class="card-body">
29 + <form method="GET" action="/admin/users" class="grid grid-cols-1 md:grid-cols-4 gap-4">
30 + <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
31 +
32 + <!-- Search Input -->
33 + <div class="form-control">
34 + <label class="label">
35 + <span class="label-text">{{ search_label }}</span>
36 + </label>
37 + <input
38 + type="text"
39 + name="search"
40 + placeholder="{{ search_placeholder }}"
41 + class="input input-bordered"
42 + value="{{ current_search }}"
43 + >
44 + </div>
45 +
46 + <!-- Role Filter -->
47 + <div class="form-control">
48 + <label class="label">
49 + <span class="label-text">{{ role_filter_label }}</span>
50 + </label>
51 + <select name="role_filter" class="select select-bordered">
52 + <option value="">{{ all_roles }}</option>
53 + <option value="admin" {% if is_admin_filter %}selected{% endif %}>Admin</option>
54 + <option value="user" {% if is_user_filter %}selected{% endif %}>User</option>
55 + </select>
56 + </div>
57 +
58 + <!-- Per Page -->
59 + <div class="form-control">
60 + <label class="label">
61 + <span class="label-text">{{ per_page_label }}</span>
62 + </label>
63 + <select name="per_page" class="select select-bordered">
64 + <option value="25" {% if per_page == 25 %}selected{% endif %}>25</option>
65 + <option value="50" {% if per_page == 50 %}selected{% endif %}>50</option>
66 + <option value="100" {% if per_page == 100 %}selected{% endif %}>100</option>
67 + </select>
68 + </div>
69 +
70 + <!-- Submit Button -->
71 + <div class="form-control">
72 + <label class="label">
73 + <span class="label-text">&nbsp;</span>
74 + </label>
75 + <button type="submit" class="btn btn-primary">{{ search_button }}</button>
76 + </div>
77 + </form>
78 + </div>
79 + </div>
80 +
81 + <!-- Results Summary -->
82 + <div class="mb-4">
83 + <p class="text-sm text-base-content/70">
84 + {{ results_summary }}
85 + {% if total_pages > 1 %}
86 + - {{ page_info }}
87 + {% endif %}
88 + </p>
89 + </div>
90 +
91 + <!-- Users Table -->
92 + <div class="card bg-base-100 shadow-xl">
93 + <div class="card-body">
94 + {% if users.is_empty() %}
95 + <div class="text-center py-8">
96 + <p class="text-lg text-base-content/70">{{ no_users }}</p>
97 + </div>
98 + {% else %}
99 + <div class="overflow-x-auto">
100 + <table class="table table-zebra w-full">
101 + <thead>
102 + <tr>
103 + <th>{{ table_username }}</th>
104 + <th>{{ table_email }}</th>
105 + <th>{{ table_roles }}</th>
106 + <th>{{ table_created_at }}</th>
107 + <th>{{ table_last_login }}</th>
108 + <th>{{ table_actions }}</th>
109 + </tr>
110 + </thead>
111 + <tbody>
112 + {% for user in users %}
113 + <tr>
114 + <td>
115 + <div class="font-bold">{{ user.username }}</div>
116 + </td>
117 + <td>{{ user.email }}</td>
118 + <td>
119 + <div class="flex flex-wrap gap-1">
120 + {% for role in user.roles %}
121 + <span class="badge {% if role.is_admin %}badge-error{% else %}badge-info{% endif %}">
122 + {{ role.name }}
123 + </span>
124 + {% endfor %}
125 + </div>
126 + </td>
127 + <td>
128 + <div class="text-sm">{{ user.created_at }}</div>
129 + </td>
130 + <td>
131 + {% if user.has_last_login %}
132 + <div class="text-sm">{{ user.last_login_at }}</div>
133 + {% if user.has_last_login_ip %}
134 + <div class="text-xs text-base-content/50">{{ user.last_login_ip }}</div>
135 + {% endif %}
136 + {% else %}
137 + <span class="text-sm text-base-content/50">{{ never }}</span>
138 + {% endif %}
139 + </td>
140 + <td>
141 + <div class="flex gap-2">
142 + <a href="/admin/users/{{ user.id }}" class="btn btn-sm btn-ghost">
143 + {{ edit }}
144 + </a>
145 + <button
146 + type="button"
147 + class="btn btn-sm btn-warning"
148 + onclick="openResetPasswordModal('{{ user.id }}', '{{ user.username }}')"
149 + title="{{ reset_password_title }}"
150 + >
151 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
152 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
153 + </svg>
154 + {{ reset_password }}
155 + </button>
156 + <button
157 + type="button"
158 + class="btn btn-sm btn-error"
159 + onclick="openDeleteUserModal('{{ user.id }}', '{{ user.username }}', '{{ user.email }}')"
160 + >
161 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
162 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
163 + </svg>
164 + {{ delete }}
165 + </button>
166 + </div>
167 + </td>
168 + </tr>
169 + {% endfor %}
170 + </tbody>
171 + </table>
172 + </div>
173 + {% endif %}
174 + </div>
175 + </div>
176 +
177 + <!-- Pagination -->
178 + {% if total_pages > 1 %}
179 + <div class="flex justify-center mt-6">
180 + <div class="join">
181 + {% if page > 1 %}
182 + <a href="?page={{ page - 1 }}&per_page={{ per_page }}{% if has_search %}&search={{ current_search }}{% endif %}{% if has_role_filter %}&role_filter={{ current_role_filter }}{% endif %}"
183 + class="join-item btn">«</a>
184 + {% else %}
185 + <button class="join-item btn btn-disabled">«</button>
186 + {% endif %}
187 +
188 + {% for p in 1..=total_pages %}
189 + {% if p == page %}
190 + <button class="join-item btn btn-active">{{ p }}</button>
191 + {% elif p == 1 || p == total_pages %}
192 + <a href="?page={{ p }}&per_page={{ per_page }}{% if has_search %}&search={{ current_search }}{% endif %}{% if has_role_filter %}&role_filter={{ current_role_filter }}{% endif %}"
193 + class="join-item btn">{{ p }}</a>
194 + {% elif p >= page - 2 && p <= page + 2 %}
195 + <a href="?page={{ p }}&per_page={{ per_page }}{% if has_search %}&search={{ current_search }}{% endif %}{% if has_role_filter %}&role_filter={{ current_role_filter }}{% endif %}"
196 + class="join-item btn">{{ p }}</a>
197 + {% elif p == page - 3 || p == page + 3 %}
198 + <button class="join-item btn btn-disabled">...</button>
199 + {% endif %}
200 + {% endfor %}
201 +
202 + {% if page < total_pages %}
203 + <a href="?page={{ page + 1 }}&per_page={{ per_page }}{% if has_search %}&search={{ current_search }}{% endif %}{% if has_role_filter %}&role_filter={{ current_role_filter }}{% endif %}"
204 + class="join-item btn">»</a>
205 + {% else %}
206 + <button class="join-item btn btn-disabled">»</button>
207 + {% endif %}
208 + </div>
209 + </div>
210 + {% endif %}
211 +</div>
212 +
213 +<!-- Modal Reset Password (T105) -->
214 +<dialog id="resetPasswordModal" class="modal">
215 + <div class="modal-box">
216 + <h3 class="font-bold text-lg mb-4">{{ reset_password_title }}</h3>
217 + <form id="resetPasswordForm" method="POST" class="space-y-4">
218 + <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
219 + <input type="hidden" id="resetUserId" name="user_id" value="">
220 +
221 + <!-- Reset Option -->
222 + <div class="form-control">
223 + <label class="label cursor-pointer">
224 + <span class="label-text">{{ reset_generate }}</span>
225 + <input type="radio" name="reset_type" value="generate" class="radio radio-primary" checked>
226 + </label>
227 + </div>
228 +
229 + <div class="form-control">
230 + <label class="label cursor-pointer">
231 + <span class="label-text">{{ reset_custom }}</span>
232 + <input type="radio" name="reset_type" value="custom" class="radio radio-primary">
233 + </label>
234 + </div>
235 +
236 + <div class="form-control">
237 + <label class="label cursor-pointer">
238 + <span class="label-text">{{ reset_email }}</span>
239 + <input type="radio" name="reset_type" value="email" class="radio radio-primary">
240 + </label>
241 + </div>
242 +
243 + <!-- Custom Password Field (hidden by default) -->
244 + <div id="customPasswordField" class="form-control hidden">
245 + <label class="label">
246 + <span class="label-text">{{ reset_new_password_label }}</span>
247 + </label>
248 + <input
249 + type="password"
250 + name="new_password"
251 + id="newPassword"
252 + placeholder="{{ reset_new_password_placeholder }}"
253 + class="input input-bordered w-full"
254 + minlength="{{ pw_min_length }}"
255 + >
256 + <label class="label">
257 + <span class="label-text-alt text-warning">
258 + {{ reset_password_requirements }}
259 + </span>
260 + </label>
261 + </div>
262 +
263 + <!-- Invalidate Sessions Option -->
264 + <div class="form-control">
265 + <label class="label cursor-pointer">
266 + <span class="label-text">{{ reset_invalidate_sessions }}</span>
267 + <input type="checkbox" name="invalidate_sessions" value="true" class="checkbox checkbox-secondary" checked>
268 + </label>
269 + <label class="label">
270 + <span class="label-text-alt">{{ reset_invalidate_sessions_desc }}</span>
271 + </label>
272 + </div>
273 +
274 + <div class="modal-action">
275 + <button type="button" class="btn" onclick="document.getElementById('resetPasswordModal').close()">{{ cancel }}</button>
276 + <button type="submit" class="btn btn-error">{{ reset_button }}</button>
277 + </div>
278 + </form>
279 + </div>
280 + <form method="dialog" class="modal-backdrop">
281 + <button>close</button>
282 + </form>
283 +</dialog>
284 +
285 +<!-- Modal Delete Confirmation (T093) -->
286 +<dialog id="deleteUserModal" class="modal">
287 + <div class="modal-box">
288 + <h3 class="font-bold text-lg text-error mb-4">{{ delete_confirm_title }}</h3>
289 + <p class="mb-4">{{ delete_confirm_message }}</p>
290 + <p class="text-sm text-base-content/70 mb-4">
291 + <strong>{{ delete_confirm_username }}</strong> <span id="deleteUsername"></span><br>
292 + <strong>{{ delete_confirm_email }}</strong> <span id="deleteEmail"></span>
293 + </p>
294 + <div class="alert alert-warning mb-4">
295 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
296 + <span>{{ delete_irreversible }}</span>
297 + </div>
298 + <form id="deleteUserForm" method="POST" class="modal-action">
299 + <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
300 + <input type="hidden" id="deleteUserId" name="user_id" value="">
301 + <button type="button" class="btn" onclick="document.getElementById('deleteUserModal').close()">{{ cancel }}</button>
302 + <button type="submit" class="btn btn-error">{{ delete_button }}</button>
303 + </form>
304 + </div>
305 + <form method="dialog" class="modal-backdrop">
306 + <button>close</button>
307 + </form>
308 +</dialog>
309 +
310 +<script>
311 +// Reset Password Modal Logic
312 +document.querySelectorAll('input[name="reset_type"]').forEach(radio => {
313 + radio.addEventListener('change', function() {
314 + const customField = document.getElementById('customPasswordField');
315 + const newPasswordInput = document.getElementById('newPassword');
316 + const form = document.getElementById('resetPasswordForm');
317 + const userId = document.getElementById('resetUserId').value;
318 +
319 + if (this.value === 'custom') {
320 + customField.classList.remove('hidden');
321 + newPasswordInput.setAttribute('required', 'required');
322 + form.action = `/admin/users/${userId}/reset-password`;
323 + } else if (this.value === 'email') {
324 + customField.classList.add('hidden');
325 + newPasswordInput.removeAttribute('required');
326 + form.action = `/admin/users/${userId}/reset-password-email`;
327 + } else {
328 + customField.classList.add('hidden');
329 + newPasswordInput.removeAttribute('required');
330 + form.action = `/admin/users/${userId}/reset-password`;
331 + }
332 + });
333 +});
334 +
335 +// Open Reset Password Modal
336 +function openResetPasswordModal(userId, username) {
337 + document.getElementById('resetUserId').value = userId;
338 + document.getElementById('resetPasswordForm').action = `/admin/users/${userId}/reset-password`;
339 + // Reset form state
340 + document.querySelector('input[name="reset_type"][value="generate"]').checked = true;
341 + document.getElementById('customPasswordField').classList.add('hidden');
342 + document.getElementById('newPassword').value = '';
343 + document.getElementById('newPassword').removeAttribute('required');
344 + document.getElementById('resetPasswordModal').showModal();
345 +}
346 +
347 +// Handle Reset Password Form Submission
348 +document.getElementById('resetPasswordForm').addEventListener('submit', async function(e) {
349 + e.preventDefault();
350 + const formData = new FormData(this);
351 + const resetType = formData.get('reset_type');
352 + const newPassword = formData.get('new_password');
353 + const userId = formData.get('user_id');
354 +
355 + const submitBtn = this.querySelector('button[type="submit"]');
356 + const originalBtnText = submitBtn.innerHTML;
357 + submitBtn.disabled = true;
358 + submitBtn.innerHTML = '<span class="loading loading-spinner"></span> {{ resetting }}';
359 +
360 + try {
361 + let response;
362 +
363 + if (resetType === 'email') {
364 + // For email reset, send empty body (no JSON needed)
365 + response = await fetch(this.action, {
366 + method: 'POST',
367 + headers: {
368 + 'Accept': 'text/html', // Expect HTML fragment response
369 + },
370 + credentials: 'include',
371 + });
372 + } else {
373 + // For generate/custom, send JSON with password
374 + const requestBody = {
375 + new_password: resetType === 'custom' && newPassword ? newPassword.toString() : null
376 + };
377 +
378 + response = await fetch(this.action, {
379 + method: 'POST',
380 + headers: {
381 + 'Content-Type': 'application/json',
382 + 'Accept': 'application/json'
383 + },
384 + credentials: 'include',
385 + body: JSON.stringify(requestBody)
386 + });
387 + }
388 +
389 + if (response.ok) {
390 + // Close the reset password dialog first so success UI is visible
391 + document.getElementById('resetPasswordModal').close();
392 + submitBtn.disabled = false;
393 + submitBtn.innerHTML = originalBtnText;
394 +
395 + if (resetType === 'email') {
396 + var htmlFragment = await response.text();
397 + var messageDiv = document.createElement('div');
398 + messageDiv.innerHTML = htmlFragment;
399 + var message = messageDiv.querySelector('.alert') || messageDiv;
400 +
401 + var successModal = document.createElement('div');
402 + successModal.className = 'modal modal-open';
403 + successModal.innerHTML = '<div class="modal-box">'
404 + + '<h3 class="font-bold text-lg mb-4">{{ reset_by_email_title }}</h3>'
405 + + '<div class="mb-4">' + message.innerHTML + '</div>'
406 + + '<div class="modal-action">'
407 + + '<button type="button" class="btn btn-primary" onclick="this.closest(\'.modal\').remove();">{{ close }}</button>'
408 + + '</div></div>'
409 + + '<form method="dialog" class="modal-backdrop"><button>close</button></form>';
410 + document.body.appendChild(successModal);
411 + return;
412 + }
413 +
414 + var result = await response.json();
415 +
416 + if (resetType === 'custom') {
417 + alert('{{ password_set_success }}');
418 + window.location.reload();
419 + } else if (result.password) {
420 + var passwordDisplay = document.createElement('div');
421 + passwordDisplay.className = 'modal modal-open';
422 + passwordDisplay.innerHTML = '<div class="modal-box">'
423 + + '<h3 class="font-bold text-lg mb-4">{{ password_reset_success_title }}</h3>'
424 + + '<div class="alert alert-warning mb-4">'
425 + + '<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>'
426 + + '<span>{{ password_warning_once }}</span></div>'
427 + + '<div class="form-control mb-4"><label class="label"><span class="label-text font-semibold">{{ new_password_label }}</span></label>'
428 + + '<div class="join"><input type="text" id="displayPassword" value="' + result.password.replace(/"/g, '&quot;') + '" class="input input-bordered join-item w-full font-mono text-lg" readonly>'
429 + + '<button type="button" class="btn btn-primary join-item" onclick="copyPassword()"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> {{ copy }}</button>'
430 + + '</div></div>'
431 + + '<div class="modal-action"><button type="button" class="btn btn-primary" onclick="this.closest(\'.modal\').remove(); window.location.reload();">{{ close }}</button></div>'
432 + + '</div>';
433 + document.body.appendChild(passwordDisplay);
434 +
435 + window.copyPassword = function() {
436 + var passwordInput = document.getElementById('displayPassword');
437 + navigator.clipboard.writeText(passwordInput.value).then(function() {
438 + alert('{{ password_copied }}');
439 + }).catch(function() {
440 + passwordInput.select();
441 + document.execCommand('copy');
442 + alert('{{ password_copied }}');
443 + });
444 + };
445 + } else {
446 + alert('{{ password_set_success }}');
447 + window.location.reload();
448 + }
449 + } else {
450 + const errorText = await response.text();
451 + console.error('Error:', errorText);
452 + let errorMessage = '❌ {{ reset_error }}';
453 + try {
454 + const errorJson = JSON.parse(errorText);
455 + if (errorJson.message) {
456 + errorMessage = '❌ ' + errorJson.message;
457 + }
458 + } catch (e) {
459 + // Not JSON, use default message
460 + }
461 + alert(errorMessage);
462 + submitBtn.disabled = false;
463 + submitBtn.innerHTML = originalBtnText;
464 + }
465 + } catch (error) {
466 + console.error('Error:', error);
467 + alert('{{ network_error }}');
468 + submitBtn.disabled = false;
469 + submitBtn.innerHTML = originalBtnText;
470 + }
471 +});
472 +
473 +// Open Delete User Modal
474 +function openDeleteUserModal(userId, username, email) {
475 + document.getElementById('deleteUserId').value = userId;
476 + document.getElementById('deleteUsername').textContent = username;
477 + document.getElementById('deleteEmail').textContent = email;
478 + document.getElementById('deleteUserForm').action = `/admin/users/${userId}`;
479 + document.getElementById('deleteUserModal').showModal();
480 +}
481 +
482 +// Handle form submissions
483 +document.getElementById('deleteUserForm').addEventListener('submit', async function(e) {
484 + e.preventDefault();
485 + const formData = new FormData(this);
486 + const userId = formData.get('user_id');
487 +
488 + try {
489 + const response = await fetch(this.action, {
490 + method: 'DELETE',
491 + headers: {
492 + 'Content-Type': 'application/x-www-form-urlencoded',
493 + },
494 + body: new URLSearchParams(formData)
495 + });
496 +
497 + if (response.ok) {
498 + window.location.reload();
499 + } else {
500 + alert('{{ delete_error }}');
501 + }
502 + } catch (error) {
503 + console.error('Error:', error);
504 + alert('{{ network_error }}');
505 + }
506 +});
507 +</script>
508 +
509 +{% endblock %}
A templates_old/base.html
+175 -0
@@ -0,0 +1,175 @@
1 +<!DOCTYPE html>
2 +<html lang="fr" data-theme="light">
3 +<head>
4 + <script>
5 + (function() {
6 + var lm = document.cookie.match(/(?:^|;\s*)locale=([^;]*)/);
7 + if (lm && lm[1]) document.documentElement.lang = lm[1];
8 + var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
9 + var t = m ? m[1] : 'auto';
10 + if (t === 'dark') {
11 + document.documentElement.setAttribute('data-theme', 'dark');
12 + } else if (t === 'light') {
13 + document.documentElement.setAttribute('data-theme', 'light');
14 + } else {
15 + var d = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
16 + document.documentElement.setAttribute('data-theme', d);
17 + }
18 + })();
19 + </script>
20 + <meta charset="UTF-8">
21 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
22 + <meta name="description" content="{% if app_description != "" %}{{ app_description }}{% else %}{{ app_name }}{% endif %}">
23 + <title>{% block title %}{{ page_title }} - {{ app_name }}{% endblock %}</title>
24 +
25 + <!-- Favicon -->
26 + <link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg">
27 + <link rel="icon" type="image/png" href="/static/images/favicon.png">
28 + <link rel="apple-touch-icon" href="/static/images/apple-touch-icon.png">
29 +
30 + <!-- Tailwind CSS + DaisyUI -->
31 + <link href="/static/css/tailwind.css" rel="stylesheet">
32 +
33 + <!-- Custom CSS -->
34 + <link href="/static/css/custom.css" rel="stylesheet">
35 +
36 + <!-- HTMX (local) -->
37 + <script src="/static/js/htmx.min.js"></script>
38 +
39 + <!-- Alpine.js (local) -->
40 + <script src="/static/js/alpine.min.js" defer></script>
41 +
42 + {% block head_extra %}{% endblock %}
43 +</head>
44 +<body class="bg-base-100 min-h-screen flex flex-col">
45 + <!-- Navbar (fixe en haut) - Include in child templates that need it -->
46 + {% block navbar %}{% endblock %}
47 +
48 + <!-- Breadcrumb -->
49 + {% block breadcrumb %}
50 + {% endblock %}
51 +
52 + <!-- Main Layout: Sidebar + Content -->
53 + <div class="flex flex-1 overflow-hidden">
54 + <!-- Sidebar (gauche) -->
55 + {% block sidebar %}
56 + {% endblock %}
57 +
58 + <!-- Content Area (principal) -->
59 + <main class="flex-1 overflow-y-auto">
60 + <div class="container mx-auto px-4 py-6">
61 + {% block content %}
62 + <!-- Contenu spécifique de la page -->
63 + {% endblock %}
64 + </div>
65 + </main>
66 + </div>
67 +
68 + <!-- Footer (optionnel) -->
69 + {% block footer %}
70 + <footer class="bg-base-200 border-t border-base-300 py-4 mt-8">
71 + <div class="container mx-auto px-4 text-center text-base-content text-sm">
72 + <p>{{ app_name }} {{ app_version }}{% if app_domain != "" %} - {{ app_domain }}{% endif %}</p>
73 + {% if app_description != "" %}
74 + <p class="text-base-content/70 mt-1">{{ app_description }}</p>
75 + {% endif %}
76 + </div>
77 + </footer>
78 + {% endblock %}
79 +
80 + <!-- Scripts additionnels -->
81 + {% block scripts %}{% endblock %}
82 +
83 + <!-- Alpine.js initialization check and fallback -->
84 + <script>
85 + // Vérifier que Alpine.js est chargé
86 + document.addEventListener('DOMContentLoaded', function() {
87 + // Attendre un peu pour qu'Alpine.js s'initialise
88 + setTimeout(function() {
89 + if (typeof Alpine === 'undefined') {
90 + console.warn('Alpine.js n\'est pas chargé. Le dropdown utilisateur pourrait ne pas fonctionner.');
91 + }
92 + }, 100);
93 + });
94 +
95 + // Language selector functions (Feature 014)
96 + function getCookie(name) {
97 + const value = `; ${document.cookie}`;
98 + const parts = value.split(`; ${name}=`);
99 + if (parts.length === 2) return parts.pop().split(';').shift();
100 + return null;
101 + }
102 +
103 + function updateLanguageDisplay() {
104 + const locale = getCookie('locale') || 'fr';
105 + const langNames = {
106 + 'fr': 'FR',
107 + 'en': 'EN',
108 + 'de': 'DE',
109 + 'es': 'ES'
110 + };
111 + const displayName = langNames[locale] || 'FR';
112 +
113 + // Update desktop display
114 + const desktopDisplay = document.getElementById('navbar-lang-display');
115 + if (desktopDisplay) {
116 + desktopDisplay.textContent = displayName;
117 + }
118 +
119 + // Update mobile display
120 + const mobileDisplay = document.getElementById('navbar-lang-display-mobile');
121 + if (mobileDisplay) {
122 + mobileDisplay.textContent = displayName;
123 + }
124 +
125 + // Update active states
126 + ['fr', 'en', 'de', 'es'].forEach(code => {
127 + const desktopLink = document.getElementById('lang-' + code);
128 + const mobileLink = document.getElementById('lang-' + code + '-mobile');
129 +
130 + if (desktopLink) {
131 + if (code === locale) {
132 + desktopLink.classList.add('active');
133 + } else {
134 + desktopLink.classList.remove('active');
135 + }
136 + }
137 +
138 + if (mobileLink) {
139 + if (code === locale) {
140 + mobileLink.classList.add('active');
141 + } else {
142 + mobileLink.classList.remove('active');
143 + }
144 + }
145 + });
146 + }
147 +
148 + window.changeLanguage = function(locale) {
149 + if (!['fr', 'en', 'de', 'es'].includes(locale)) {
150 + return false;
151 + }
152 +
153 + document.cookie = 'locale=' + locale + '; path=/; max-age=31536000; SameSite=Lax';
154 +
155 + fetch('/settings/preferences', {
156 + method: 'PUT',
157 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
158 + credentials: 'include',
159 + body: 'language=' + encodeURIComponent(locale)
160 + }).then(function() {
161 + window.location.reload();
162 + }).catch(function() {
163 + window.location.reload();
164 + });
165 +
166 + return false; // Prevent default link behavior
167 + };
168 +
169 + // Initialize language display on page load
170 + document.addEventListener('DOMContentLoaded', function() {
171 + updateLanguageDisplay();
172 + });
173 + </script>
174 +</body>
175 +</html>
A templates_old/base_with_theme.html
+10 -0
@@ -0,0 +1,10 @@
1 +<!-- T155: Theme application in base.html -->
2 +<!-- Note: This requires modifying base.html to apply data-theme attribute -->
3 +<!-- Add to <html> tag: data-theme="{{ user_theme | default(value='light') }}" -->
4 +<!-- DaisyUI will automatically apply the theme based on this attribute -->
5 +
6 +<!-- Example integration:
7 +<html lang="fr" data-theme="{% if user_theme %}{{ user_theme }}{% else %}light{% endif %}">
8 +
9 +This allows dynamic theme switching based on user preferences.
10 +-->
A templates_old/components/breadcrumb.html
+37 -0
@@ -0,0 +1,37 @@
1 +<!-- Breadcrumb Component for Feature 007 -->
2 +<!-- Uses DaisyUI breadcrumbs classes -->
3 +
4 +<div class="container mx-auto px-4 py-2 bg-base-200 border-b border-base-300">
5 + <ul class="breadcrumbs text-sm">
6 + {% for item in breadcrumb_items %}
7 + <li>
8 + {% if item.is_current %}
9 + <span class="text-base-content font-semibold">{{ item.label }}</span>
10 + {% else %}
11 + <a href="{{ item.url }}" class="text-primary hover:text-primary-focus">
12 + {{ item.label }}
13 + </a>
14 + {% endif %}
15 + </li>
16 + {% endfor %}
17 + </ul>
18 +</div>
19 +
20 +
21 +
22 +
23 +
24 +
25 +
26 +
27 +
28 +
29 +
30 +
31 +
32 +
33 +
34 +
35 +
36 +
37 +
A templates_old/components/header.html
+54 -0
@@ -0,0 +1,54 @@
1 +<header class="navbar bg-base-100 border-b border-base-300 shadow-sm">
2 + <div class="container mx-auto px-4">
3 + <div class="flex items-center justify-between h-16">
4 + <!-- Logo -->
5 + <div class="flex items-center">
6 + <a href="/home" class="flex items-center space-x-2 hover:opacity-80 transition">
7 + <img src="/static/images/logo.svg" alt="{{ app_name }}" class="h-8 w-8">
8 + <span class="text-xl font-bold text-primary">{{ app_name }}</span>
9 + </a>
10 + </div>
11 +
12 + <!-- Desktop Navigation -->
13 + <div class="hidden md:flex items-center flex-1 justify-center">
14 + {% include "components/navigation.html" %}
15 + </div>
16 +
17 + <!-- User Menu (Desktop) -->
18 + <div class="hidden md:block">
19 + {% include "components/user_menu.html" %}
20 + </div>
21 +
22 + <!-- Mobile Menu Button -->
23 + <div class="md:hidden" x-data="{ mobileMenuOpen: false }">
24 + <button @click="mobileMenuOpen = !mobileMenuOpen"
25 + class="btn btn-ghost btn-sm">
26 + <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
27 + <path x-show="!mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
28 + <path x-show="mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
29 + </svg>
30 + </button>
31 +
32 + <!-- Mobile Menu -->
33 + <div x-show="mobileMenuOpen"
34 + @click.away="mobileMenuOpen = false"
35 + x-transition:enter="transition ease-out duration-200"
36 + x-transition:enter-start="opacity-0 scale-95"
37 + x-transition:enter-end="opacity-100 scale-100"
38 + x-transition:leave="transition ease-in duration-150"
39 + x-transition:leave-start="opacity-100 scale-100"
40 + x-transition:leave-end="opacity-0 scale-95"
41 + class="absolute top-16 right-0 mt-2 w-64 card bg-base-100 shadow-xl border border-base-300 py-2 z-50">
42 + <!-- Mobile Navigation -->
43 + <div class="px-4 py-2">
44 + {% include "components/navigation.html" %}
45 + </div>
46 + <!-- Mobile User Info -->
47 + <div class="border-t border-base-300 mt-2 pt-2 px-4 py-2">
48 + {% include "fragments/user_menu_dropdown.html" %}
49 + </div>
50 + </div>
51 + </div>
52 + </div>
53 + </div>
54 +</header>
A templates_old/components/navbar.html
+151 -0
@@ -0,0 +1,151 @@
1 +<!-- Navbar Component for Feature 007 -->
2 +<!-- Uses DaisyUI navbar classes -->
3 +
4 +<nav class="navbar bg-base-200 border-b border-base-300 shadow-sm">
5 + <div class="container mx-auto px-4">
6 + <div class="navbar-start">
7 + <!-- Logo -->
8 + <a href="/home" class="btn btn-ghost normal-case text-xl">
9 + <img src="/static/images/logo.svg" alt="{{ app_name }}" class="h-8 w-8 mr-2">
10 + <span class="text-primary font-bold">{{ app_name }}</span>
11 + </a>
12 + </div>
13 +
14 + <!-- Desktop Navigation (centered) -->
15 + <div class="navbar-center hidden md:flex">
16 + {% include "components/navigation.html" %}
17 + </div>
18 +
19 + <!-- User Menu (right) -->
20 + <div class="navbar-end">
21 + <!-- Language Selector (Feature 014) -->
22 + <div class="hidden md:block mr-2">
23 + <div class="dropdown dropdown-end">
24 + <label tabindex="0" class="btn btn-ghost btn-sm gap-2">
25 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
26 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
27 + </svg>
28 + <span id="navbar-lang-display">FR</span>
29 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
30 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
31 + </svg>
32 + </label>
33 + <ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow">
34 + <li>
35 + <a href="#" onclick="return window.changeLanguage('fr');" id="lang-fr">
36 + 🇫🇷 Français
37 + </a>
38 + </li>
39 + <li>
40 + <a href="#" onclick="return window.changeLanguage('en');" id="lang-en">
41 + 🇬🇧 English
42 + </a>
43 + </li>
44 + <li>
45 + <a href="#" onclick="return window.changeLanguage('de');" id="lang-de">
46 + 🇩🇪 Deutsch
47 + </a>
48 + </li>
49 + <li>
50 + <a href="#" onclick="return window.changeLanguage('es');" id="lang-es">
51 + 🇪🇸 Español
52 + </a>
53 + </li>
54 + </ul>
55 + </div>
56 + </div>
57 +
58 + <!-- Desktop User Menu -->
59 + <div class="hidden md:block">
60 + {% include "components/user_menu.html" %}
61 + </div>
62 +
63 + <!-- Mobile Menu Button -->
64 + <div class="md:hidden" x-data="{ mobileMenuOpen: false }">
65 + <button @click="mobileMenuOpen = !mobileMenuOpen"
66 + class="btn btn-ghost btn-circle">
67 + <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
68 + <path x-show="!mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
69 + <path x-show="mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
70 + </svg>
71 + </button>
72 +
73 + <!-- Mobile Menu Dropdown -->
74 + <div x-show="mobileMenuOpen"
75 + @click.away="mobileMenuOpen = false"
76 + x-transition:enter="transition ease-out duration-200"
77 + x-transition:enter-start="opacity-0 scale-95"
78 + x-transition:enter-end="opacity-100 scale-100"
79 + x-transition:leave="transition ease-in duration-150"
80 + x-transition:leave-start="opacity-100 scale-100"
81 + x-transition:leave-end="opacity-0 scale-95"
82 + class="absolute top-16 right-4 mt-2 w-64 bg-base-100 rounded-lg shadow-lg border border-base-300 py-2 z-50">
83 + <!-- Mobile Navigation -->
84 + <div class="px-4 py-2">
85 + {% include "components/navigation.html" %}
86 + </div>
87 + <!-- Mobile Language Selector -->
88 + <div class="border-t border-base-300 mt-2 pt-2 px-4">
89 + <div class="dropdown dropdown-top">
90 + <label tabindex="0" class="btn btn-ghost btn-sm w-full justify-between">
91 + <span class="flex items-center gap-2">
92 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
93 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
94 + </svg>
95 + <span id="navbar-lang-display-mobile">FR</span>
96 + </span>
97 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
98 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
99 + </svg>
100 + </label>
101 + <ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[1] w-full p-2 shadow">
102 + <li>
103 + <a href="#" onclick="return window.changeLanguage('fr');" id="lang-fr-mobile">
104 + 🇫🇷 Français
105 + </a>
106 + </li>
107 + <li>
108 + <a href="#" onclick="return window.changeLanguage('en');" id="lang-en-mobile">
109 + 🇬🇧 English
110 + </a>
111 + </li>
112 + <li>
113 + <a href="#" onclick="return window.changeLanguage('de');" id="lang-de-mobile">
114 + 🇩🇪 Deutsch
115 + </a>
116 + </li>
117 + <li>
118 + <a href="#" onclick="return window.changeLanguage('es');" id="lang-es-mobile">
119 + 🇪🇸 Español
120 + </a>
121 + </li>
122 + </ul>
123 + </div>
124 + </div>
125 +
126 + <!-- Mobile User Info -->
127 + <div class="border-t border-base-300 mt-2 pt-2">
128 + <ul class="menu menu-vertical w-full">
129 + {% include "fragments/user_menu_dropdown.html" %}
130 + </ul>
131 + </div>
132 + </div>
133 + </div>
134 + </div>
135 + </div>
136 +</nav>
137 +
138 +
139 +
140 +
141 +
142 +
143 +
144 +
145 +
146 +
147 +
148 +
149 +
150 +
151 +
A templates_old/components/navigation.html
+68 -0
@@ -0,0 +1,68 @@
1 +<!-- Navigation Items -->
2 +<nav class="flex items-center space-x-1">
3 + {% for item in nav_items %}
4 + <div class="relative group">
5 + {% if item.enabled %}
6 + <!-- Enabled Item -->
7 + <a href="{{ item.url }}"
8 + class="btn btn-ghost btn-sm
9 + {% if item.active %}
10 + btn-active
11 + {% endif %}">
12 + <!-- Icon -->
13 + <span class="text-base">
14 + {% if item.icon == "home" %}🏠
15 + {% elif item.icon == "folder" %}📁
16 + {% elif item.icon == "settings" %}⚙️
17 + {% elif item.icon == "check-square" %}☑️
18 + {% elif item.icon == "shield" %}🛡️
19 + {% else %}📄{% endif %}
20 + </span>
21 + <span class="whitespace-nowrap">{{ item.label }}</span>
22 + {% if item.has_badge %}
23 + <span class="badge badge-sm
24 + {% if item.active %}
25 + badge-primary
26 + {% else %}
27 + badge-ghost
28 + {% endif %}">
29 + {{ item.badge }}
30 + </span>
31 + {% endif %}
32 + {% if item.id == "settings" %}
33 + {% if has_pending_password_change %}
34 + <span class="badge badge-sm badge-warning" title="Changement de mot de passe en attente de confirmation">
35 + ⚠️
36 + </span>
37 + {% endif %}
38 + {% endif %}
39 + </a>
40 + {% else %}
41 + <!-- Disabled Item -->
42 + <div class="btn btn-ghost btn-sm btn-disabled relative"
43 + {% if item.has_disabled_message %}title="{{ item.disabled_message }}"{% endif %}>
44 + <span class="text-base">
45 + {% if item.icon == "home" %}🏠
46 + {% elif item.icon == "folder" %}📁
47 + {% elif item.icon == "settings" %}⚙️
48 + {% elif item.icon == "check-square" %}☑️
49 + {% elif item.icon == "shield" %}🛡️
50 + {% else %}📄{% endif %}
51 + </span>
52 + <span class="whitespace-nowrap">{{ item.label }}</span>
53 + {% if item.has_badge %}
54 + <span class="badge badge-sm badge-warning">
55 + {{ item.badge }}
56 + </span>
57 + {% endif %}
58 + </div>
59 +
60 + <!-- Tooltip on hover -->
61 + {% if item.has_disabled_message %}
62 + <div class="tooltip tooltip-bottom absolute top-full left-0 mt-2 z-50 whitespace-nowrap" data-tip="{{ item.disabled_message }}">
63 + </div>
64 + {% endif %}
65 + {% endif %}
66 + </div>
67 + {% endfor %}
68 +</nav>
A templates_old/components/password_change_badge.html
+6 -0
@@ -0,0 +1,6 @@
1 +{% if has_pending_password_change %}
2 +<span class="badge badge-sm badge-warning" title="Changement de mot de passe en attente de confirmation">
3 + ⚠️
4 +</span>
5 +{% endif %}
6 +
A templates_old/components/sidebar.html
+155 -0
@@ -0,0 +1,155 @@
1 +<!-- Sidebar Component for Feature 007 -->
2 +<!-- Uses DaisyUI sidebar and menu classes -->
3 +
4 +<aside class="w-64 bg-base-200 border-r border-base-300 hidden lg:block overflow-y-auto"
5 + x-data="{ sidebarOpen: false }">
6 + <div class="p-4">
7 + {% for section in sidebar_sections %}
8 + <div class="mb-6">
9 + <h3 class="text-sm font-semibold text-base-content uppercase tracking-wider mb-2">
10 + {{ section.title }}
11 + </h3>
12 + <ul class="menu menu-vertical w-full">
13 + {% for item in section.items %}
14 + <li>
15 + {% if item.enabled %}
16 + {% if item.has_url %}
17 + <a href="{{ item.url }}"
18 + class="flex items-center gap-2 hover:bg-base-300">
19 + <span>{{ item.icon }}</span>
20 + <span>{{ item.label }}</span>
21 + {% if !item.badge.is_empty() %}
22 + <span class="badge badge-primary badge-sm ml-auto">{{ item.badge }}</span>
23 + {% endif %}
24 + </a>
25 + {% else %}
26 + <div class="flex items-center gap-2 text-base-content/60 cursor-default">
27 + <span>{{ item.icon }}</span>
28 + <span>{{ item.label }}</span>
29 + {% if !item.badge.is_empty() %}
30 + <span class="badge badge-ghost badge-sm ml-auto">{{ item.badge }}</span>
31 + {% endif %}
32 + </div>
33 + {% endif %}
34 + {% else %}
35 + <div class="flex items-center gap-2 text-base-content/60 cursor-default">
36 + <span>{{ item.icon }}</span>
37 + <span>{{ item.label }}</span>
38 + {% if !item.badge.is_empty() %}
39 + <span class="badge badge-ghost badge-sm ml-auto">{{ item.badge }}</span>
40 + {% endif %}
41 + </div>
42 + {% endif %}
43 + </li>
44 + {% endfor %}
45 + </ul>
46 + </div>
47 + {% endfor %}
48 + </div>
49 +</aside>
50 +
51 +<!-- Mobile Sidebar Toggle Button (visible only on mobile) -->
52 +<div class="lg:hidden fixed bottom-4 right-4 z-50" x-data="{ sidebarOpen: false }">
53 + <button @click="sidebarOpen = !sidebarOpen"
54 + class="btn btn-primary btn-circle shadow-lg">
55 + <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
56 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
57 + </svg>
58 + </button>
59 +
60 + <!-- Mobile Sidebar Overlay -->
61 + <div x-show="sidebarOpen"
62 + @click.away="sidebarOpen = false"
63 + x-transition:enter="transition ease-out duration-300"
64 + x-transition:enter-start="opacity-0"
65 + x-transition:enter-end="opacity-100"
66 + x-transition:leave="transition ease-in duration-200"
67 + x-transition:leave-start="opacity-100"
68 + x-transition:leave-end="opacity-0"
69 + class="fixed inset-0 bg-black bg-opacity-50 z-40"
70 + @click="sidebarOpen = false">
71 + </div>
72 +
73 + <!-- Mobile Sidebar Panel -->
74 + <div x-show="sidebarOpen"
75 + x-transition:enter="transition ease-out duration-300 transform"
76 + x-transition:enter-start="translate-x-full"
77 + x-transition:enter-end="translate-x-0"
78 + x-transition:leave="transition ease-in duration-200 transform"
79 + x-transition:leave-start="translate-x-0"
80 + x-transition:leave-end="translate-x-full"
81 + class="fixed top-0 right-0 h-full w-64 bg-base-200 border-l border-base-300 shadow-xl z-50 overflow-y-auto">
82 + <div class="p-4">
83 + <div class="flex justify-between items-center mb-4">
84 + <h2 class="text-lg font-bold text-base-content">Menu</h2>
85 + <button @click="sidebarOpen = false" class="btn btn-ghost btn-sm btn-circle">
86 + <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
87 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
88 + </svg>
89 + </button>
90 + </div>
91 +
92 + {% for section in sidebar_sections %}
93 + <div class="mb-6">
94 + <h3 class="text-sm font-semibold text-base-content uppercase tracking-wider mb-2">
95 + {{ section.title }}
96 + </h3>
97 + <ul class="menu menu-vertical w-full">
98 + {% for item in section.items %}
99 + <li>
100 + {% if item.enabled %}
101 + {% if item.has_url %}
102 + <a href="{{ item.url }}"
103 + class="flex items-center gap-2 hover:bg-base-300"
104 + @click="sidebarOpen = false">
105 + <span>{{ item.icon }}</span>
106 + <span>{{ item.label }}</span>
107 + {% if !item.badge.is_empty() %}
108 + <span class="badge badge-primary badge-sm ml-auto">{{ item.badge }}</span>
109 + {% endif %}
110 + </a>
111 + {% else %}
112 + <div class="flex items-center gap-2 text-base-content/60 cursor-default">
113 + <span>{{ item.icon }}</span>
114 + <span>{{ item.label }}</span>
115 + {% if !item.badge.is_empty() %}
116 + <span class="badge badge-ghost badge-sm ml-auto">{{ item.badge }}</span>
117 + {% endif %}
118 + </div>
119 + {% endif %}
120 + {% else %}
121 + <div class="flex items-center gap-2 text-base-content/60 cursor-default">
122 + <span>{{ item.icon }}</span>
123 + <span>{{ item.label }}</span>
124 + {% if !item.badge.is_empty() %}
125 + <span class="badge badge-ghost badge-sm ml-auto">{{ item.badge }}</span>
126 + {% endif %}
127 + </div>
128 + {% endif %}
129 + </li>
130 + {% endfor %}
131 + </ul>
132 + </div>
133 + {% endfor %}
134 + </div>
135 + </div>
136 +</div>
137 +
138 +
139 +
140 +
141 +
142 +
143 +
144 +
145 +
146 +
147 +
148 +
149 +
150 +
151 +
152 +
153 +
154 +
155 +
A templates_old/components/user_menu.html
+43 -0
@@ -0,0 +1,43 @@
1 +<!-- User Menu with DaisyUI and Alpine.js -->
2 +<div class="dropdown dropdown-end"
3 + x-data="{ open: false }"
4 + @click.away="open = false">
5 + <!-- User Menu Button -->
6 + <button type="button"
7 + @click.stop="open = !open"
8 + class="btn btn-ghost flex items-center gap-3 normal-case cursor-pointer">
9 + <!-- Avatar with initials -->
10 + <div class="avatar placeholder">
11 + <div class="bg-primary text-primary-content rounded-full w-10 h-10"
12 + style="background-color: {{ avatar_color }} !important;">
13 + <span class="text-sm font-semibold">{{ initials }}</span>
14 + </div>
15 + </div>
16 +
17 + <!-- Username (hidden on small screens) -->
18 + <div class="hidden lg:block text-left">
19 + <div class="text-sm font-medium text-base-content">{{ username }}</div>
20 + <div class="text-xs text-base-content/60">{{ email }}</div>
21 + </div>
22 +
23 + <!-- Dropdown Arrow -->
24 + <svg class="w-4 h-4 text-base-content transition-transform duration-200"
25 + :class="{ 'rotate-180': open }"
26 + fill="none" stroke="currentColor" viewBox="0 0 24 24">
27 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
28 + </svg>
29 + </button>
30 +
31 + <!-- Dropdown Menu -->
32 + <ul x-show="open"
33 + x-cloak
34 + x-transition:enter="transition ease-out duration-100"
35 + x-transition:enter-start="opacity-0 scale-95"
36 + x-transition:enter-end="opacity-100 scale-100"
37 + x-transition:leave="transition ease-in duration-75"
38 + x-transition:leave-start="opacity-100 scale-100"
39 + x-transition:leave-end="opacity-0 scale-95"
40 + class="dropdown-content menu bg-base-100 rounded-box z-[1] w-64 p-2 shadow-lg border border-base-300 mt-2">
41 + {% include "fragments/user_menu_dropdown.html" %}
42 + </ul>
43 +</div>
A templates_old/email_verified.html
+119 -0
@@ -0,0 +1,119 @@
1 +<!DOCTYPE html>
2 +<html lang="fr" data-theme="dark">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + {% if success %}
7 + <meta http-equiv="refresh" content="5;url=/login">
8 + {% endif %}
9 + <title>{% if success %}Email vérifié{% else %}Erreur{% endif %}</title>
10 + <link href="/static/css/output.css" rel="stylesheet">
11 + <style>
12 + @keyframes spin {
13 + from { transform: rotate(0deg); }
14 + to { transform: rotate(360deg); }
15 + }
16 + .spinner {
17 + animation: spin 1s linear infinite;
18 + display: inline-block;
19 + }
20 + @keyframes fadeIn {
21 + from { opacity: 0; transform: translateY(-20px); }
22 + to { opacity: 1; transform: translateY(0); }
23 + }
24 + .fade-in {
25 + animation: fadeIn 0.5s ease-out;
26 + }
27 + .countdown {
28 + font-variant-numeric: tabular-nums;
29 + }
30 + </style>
31 +</head>
32 +<body class="min-h-screen bg-gradient-to-br from-base-300 via-base-200 to-base-300 flex items-center justify-center p-4">
33 + <div class="card bg-base-100 shadow-2xl w-full max-w-md fade-in">
34 + <div class="card-body text-center space-y-6">
35 + {% if success %}
36 + <!-- Success Icon -->
37 + <div class="flex justify-center">
38 + <div class="w-20 h-20 rounded-full bg-success/20 flex items-center justify-center">
39 + <svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
40 + <path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
41 + </svg>
42 + </div>
43 + </div>
44 +
45 + <!-- Title -->
46 + <h1 class="text-2xl font-bold text-success">Email vérifié !</h1>
47 +
48 + <!-- Message -->
49 + <p class="text-base-content/80">
50 + {{ message }}
51 + </p>
52 +
53 + <!-- Redirect Info -->
54 + <div class="bg-base-200 rounded-lg p-4 space-y-3">
55 + <div class="flex items-center justify-center gap-2 text-base-content/60">
56 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 spinner" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
57 + <path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
58 + </svg>
59 + <span>Redirection automatique dans <span id="countdown" class="countdown font-bold text-primary">5</span> secondes...</span>
60 + </div>
61 + </div>
62 +
63 + <!-- Manual Link -->
64 + <a href="/login" class="btn btn-primary btn-block">
65 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
66 + <path stroke-linecap="round" stroke-linejoin="round" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
67 + </svg>
68 + Se connecter maintenant
69 + </a>
70 + {% else %}
71 + <!-- Error Icon -->
72 + <div class="flex justify-center">
73 + <div class="w-20 h-20 rounded-full bg-error/20 flex items-center justify-center">
74 + <svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-error" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
75 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
76 + </svg>
77 + </div>
78 + </div>
79 +
80 + <!-- Title -->
81 + <h1 class="text-2xl font-bold text-error">Erreur de vérification</h1>
82 +
83 + <!-- Message -->
84 + <p class="text-base-content/80">
85 + {{ message }}
86 + </p>
87 +
88 + <!-- Links -->
89 + <div class="space-y-2">
90 + <a href="/resend-verification" class="btn btn-outline btn-primary btn-block">
91 + Renvoyer l'email de vérification
92 + </a>
93 + <a href="/login" class="btn btn-ghost btn-block">
94 + Retour à la connexion
95 + </a>
96 + </div>
97 + {% endif %}
98 + </div>
99 + </div>
100 +
101 + {% if success %}
102 + <script>
103 + // Countdown timer
104 + let seconds = 5;
105 + const countdownEl = document.getElementById('countdown');
106 +
107 + const timer = setInterval(() => {
108 + seconds--;
109 + countdownEl.textContent = seconds;
110 +
111 + if (seconds <= 0) {
112 + clearInterval(timer);
113 + window.location.href = '/login';
114 + }
115 + }, 1000);
116 + </script>
117 + {% endif %}
118 +</body>
119 +</html>
A templates_old/emails/account_modified.html
+165 -0
@@ -0,0 +1,165 @@
1 +<!DOCTYPE html>
2 +<html lang="{{ locale }}">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>{% if locale == "fr" %}Compte modifié{% else %}Account Modified{% endif %}</title>
7 + <style>
8 + body {
9 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 + line-height: 1.6;
11 + color: #333;
12 + max-width: 600px;
13 + margin: 0 auto;
14 + padding: 20px;
15 + background-color: #f4f4f4;
16 + }
17 + .email-container {
18 + background-color: #ffffff;
19 + border-radius: 8px;
20 + padding: 30px;
21 + box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 + }
23 + .header {
24 + text-align: center;
25 + margin-bottom: 30px;
26 + }
27 + .logo {
28 + font-size: 24px;
29 + font-weight: bold;
30 + color: #2563eb;
31 + margin-bottom: 10px;
32 + }
33 + h1 {
34 + color: #1f2937;
35 + font-size: 24px;
36 + margin-bottom: 20px;
37 + }
38 + .content {
39 + margin-bottom: 30px;
40 + }
41 + .changes-list {
42 + margin: 20px 0;
43 + padding: 15px;
44 + background-color: #f3f4f6;
45 + border-radius: 6px;
46 + }
47 + .change-item {
48 + margin: 10px 0;
49 + padding: 10px;
50 + background-color: #ffffff;
51 + border-left: 3px solid #3b82f6;
52 + border-radius: 4px;
53 + }
54 + .change-label {
55 + font-weight: 600;
56 + color: #1f2937;
57 + }
58 + .change-value {
59 + color: #6b7280;
60 + margin-top: 5px;
61 + }
62 + .info-box {
63 + margin-top: 20px;
64 + padding: 15px;
65 + background-color: #fee2e2;
66 + border-left: 4px solid #ef4444;
67 + border-radius: 4px;
68 + font-size: 14px;
69 + }
70 + .footer {
71 + margin-top: 30px;
72 + padding-top: 20px;
73 + border-top: 1px solid #e5e7eb;
74 + font-size: 14px;
75 + color: #6b7280;
76 + text-align: center;
77 + }
78 + </style>
79 +</head>
80 +<body>
81 + <div class="email-container">
82 + <div class="header">
83 + <div class="logo">{{ app_name }}</div>
84 + </div>
85 +
86 + <h1>
87 + {% if locale == "fr" %}
88 + Compte modifié
89 + {% else %}
90 + Account Modified
91 + {% endif %}
92 + </h1>
93 +
94 + <div class="content">
95 + {% if locale == "fr" %}
96 + <p>Bonjour {{ username }},</p>
97 + <p>Nous vous informons que les informations de votre compte <strong>{{ app_name }}</strong> ont été modifiées.</p>
98 + <p><strong>Date et heure de modification :</strong> {{ modified_at }}</p>
99 + {% else %}
100 + <p>Hello {{ username }},</p>
101 + <p>We are informing you that your <strong>{{ app_name }}</strong> account information has been modified.</p>
102 + <p><strong>Date and time of modification:</strong> {{ modified_at }}</p>
103 + {% endif %}
104 + </div>
105 +
106 + {% if !changes.is_empty() %}
107 + <div class="changes-list">
108 + {% if locale == "fr" %}
109 + <p style="font-weight: 600; margin-bottom: 15px;">Modifications effectuées :</p>
110 + {% else %}
111 + <p style="font-weight: 600; margin-bottom: 15px;">Changes made:</p>
112 + {% endif %}
113 + {% for change in changes %}
114 + <div class="change-item">
115 + <div class="change-label">{{ change.field }}</div>
116 + {% if let Some(old_val) = change.old_value %}
117 + <div class="change-value">
118 + {% if locale == "fr" %}
119 + Ancien : {{ old_val }} → Nouveau : {{ change.new_value }}
120 + {% else %}
121 + Old: {{ old_val }} → New: {{ change.new_value }}
122 + {% endif %}
123 + </div>
124 + {% else %}
125 + <div class="change-value">{{ change.new_value }}</div>
126 + {% endif %}
127 + </div>
128 + {% endfor %}
129 + </div>
130 + {% endif %}
131 +
132 + <div class="info-box">
133 + {% if locale == "fr" %}
134 + <p><strong>⚠️ Sécurité :</strong></p>
135 + <ul style="margin: 10px 0; padding-left: 20px;">
136 + <li>Si vous n'avez pas effectué ces modifications, contactez immédiatement le support technique</li>
137 + <li>Vérifiez que vous pouvez toujours accéder à votre compte</li>
138 + <li>Si votre email a été modifié, vérifiez que vous recevez bien les emails à la nouvelle adresse</li>
139 + </ul>
140 + {% else %}
141 + <p><strong>⚠️ Security:</strong></p>
142 + <ul style="margin: 10px 0; padding-left: 20px;">
143 + <li>If you did not make these changes, contact technical support immediately</li>
144 + <li>Verify that you can still access your account</li>
145 + <li>If your email was changed, verify that you receive emails at the new address</li>
146 + </ul>
147 + {% endif %}
148 + </div>
149 +
150 + <div class="footer">
151 + {% if locale == "fr" %}
152 + <p>Cet email a été envoyé automatiquement pour votre sécurité.</p>
153 + <p>Si vous avez des questions, contactez le support technique.</p>
154 + {% else %}
155 + <p>This email was sent automatically for your security.</p>
156 + <p>If you have any questions, please contact technical support.</p>
157 + {% endif %}
158 + <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
159 + © {{ current_year }} {{ app_name }}. Tous droits réservés.
160 + </p>
161 + </div>
162 + </div>
163 +</body>
164 +</html>
165 +
A templates_old/emails/admin_new_user.html
+151 -0
@@ -0,0 +1,151 @@
1 +<!DOCTYPE html>
2 +<html lang="{{ locale }}">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>{% if locale == "fr" %}Nouvel utilisateur inscrit{% else %}New User Registered{% endif %}</title>
7 + <style>
8 + body {
9 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 + line-height: 1.6;
11 + color: #333;
12 + max-width: 600px;
13 + margin: 0 auto;
14 + padding: 20px;
15 + background-color: #f4f4f4;
16 + }
17 + .email-container {
18 + background-color: #ffffff;
19 + border-radius: 8px;
20 + padding: 30px;
21 + box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 + }
23 + .header {
24 + text-align: center;
25 + margin-bottom: 30px;
26 + }
27 + .logo {
28 + font-size: 24px;
29 + font-weight: bold;
30 + color: #2563eb;
31 + margin-bottom: 10px;
32 + }
33 + h1 {
34 + color: #1f2937;
35 + font-size: 24px;
36 + margin-bottom: 20px;
37 + }
38 + .content {
39 + margin-bottom: 30px;
40 + }
41 + .user-info {
42 + margin: 20px 0;
43 + padding: 15px;
44 + background-color: #f3f4f6;
45 + border-radius: 6px;
46 + }
47 + .info-row {
48 + margin: 10px 0;
49 + padding: 8px 0;
50 + border-bottom: 1px solid #e5e7eb;
51 + }
52 + .info-row:last-child {
53 + border-bottom: none;
54 + }
55 + .info-label {
56 + font-weight: 600;
57 + color: #1f2937;
58 + display: inline-block;
59 + width: 120px;
60 + }
61 + .info-value {
62 + color: #6b7280;
63 + }
64 + .action-box {
65 + margin-top: 20px;
66 + padding: 15px;
67 + background-color: #dbeafe;
68 + border-left: 4px solid #3b82f6;
69 + border-radius: 4px;
70 + font-size: 14px;
71 + }
72 + .footer {
73 + margin-top: 30px;
74 + padding-top: 20px;
75 + border-top: 1px solid #e5e7eb;
76 + font-size: 14px;
77 + color: #6b7280;
78 + text-align: center;
79 + }
80 + </style>
81 +</head>
82 +<body>
83 + <div class="email-container">
84 + <div class="header">
85 + <div class="logo">{{ app_name }}</div>
86 + </div>
87 +
88 + <h1>
89 + {% if locale == "fr" %}
90 + Nouvel utilisateur inscrit
91 + {% else %}
92 + New User Registered
93 + {% endif %}
94 + </h1>
95 +
96 + <div class="content">
97 + {% if locale == "fr" %}
98 + <p>Bonjour,</p>
99 + <p>Un nouvel utilisateur s'est inscrit sur <strong>{{ app_name }}</strong>.</p>
100 + {% else %}
101 + <p>Hello,</p>
102 + <p>A new user has registered on <strong>{{ app_name }}</strong>.</p>
103 + {% endif %}
104 + </div>
105 +
106 + <div class="user-info">
107 + {% if locale == "fr" %}
108 + <p style="font-weight: 600; margin-bottom: 15px;">Informations de l'utilisateur :</p>
109 + {% else %}
110 + <p style="font-weight: 600; margin-bottom: 15px;">User Information:</p>
111 + {% endif %}
112 + <div class="info-row">
113 + <span class="info-label">{% if locale == "fr" %}Nom d'utilisateur{% else %}Username{% endif %}:</span>
114 + <span class="info-value">{{ username }}</span>
115 + </div>
116 + <div class="info-row">
117 + <span class="info-label">Email:</span>
118 + <span class="info-value">{{ email }}</span>
119 + </div>
120 + <div class="info-row">
121 + <span class="info-label">{% if locale == "fr" %}Date d'inscription{% else %}Registration Date{% endif %}:</span>
122 + <span class="info-value">{{ registered_at }}</span>
123 + </div>
124 + </div>
125 +
126 + <div class="action-box">
127 + {% if locale == "fr" %}
128 + <p><strong>ℹ️ Action requise :</strong></p>
129 + <p>Vous pouvez consulter et gérer cet utilisateur depuis le panneau d'administration.</p>
130 + {% else %}
131 + <p><strong>ℹ️ Action Required:</strong></p>
132 + <p>You can view and manage this user from the admin panel.</p>
133 + {% endif %}
134 + </div>
135 +
136 + <div class="footer">
137 + {% if locale == "fr" %}
138 + <p>Cet email a été envoyé automatiquement aux administrateurs.</p>
139 + <p>Si vous avez des questions, contactez le support technique.</p>
140 + {% else %}
141 + <p>This email was sent automatically to administrators.</p>
142 + <p>If you have any questions, please contact technical support.</p>
143 + {% endif %}
144 + <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
145 + © {{ current_year }} {{ app_name }}. Tous droits réservés.
146 + </p>
147 + </div>
148 + </div>
149 +</body>
150 +</html>
151 +
A templates_old/emails/email_validation.html
+164 -0
@@ -0,0 +1,164 @@
1 +<!DOCTYPE html>
2 +<html lang="{{ locale }}">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>{% if locale == "fr" %}Vérification de votre adresse email{% else %}Verify Your Email Address{% endif %}</title>
7 + <style>
8 + body {
9 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 + line-height: 1.6;
11 + color: #333;
12 + max-width: 600px;
13 + margin: 0 auto;
14 + padding: 20px;
15 + background-color: #f4f4f4;
16 + }
17 + .email-container {
18 + background-color: #ffffff;
19 + border-radius: 8px;
20 + padding: 30px;
21 + box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 + }
23 + .header {
24 + text-align: center;
25 + margin-bottom: 30px;
26 + }
27 + .logo {
28 + font-size: 24px;
29 + font-weight: bold;
30 + color: #2563eb;
31 + margin-bottom: 10px;
32 + }
33 + h1 {
34 + color: #1f2937;
35 + font-size: 24px;
36 + margin-bottom: 20px;
37 + }
38 + .content {
39 + margin-bottom: 30px;
40 + }
41 + .button-container {
42 + text-align: center;
43 + margin: 30px 0;
44 + }
45 + .button {
46 + display: inline-block;
47 + padding: 12px 30px;
48 + background-color: #10b981;
49 + color: #ffffff !important;
50 + text-decoration: none;
51 + border-radius: 6px;
52 + font-weight: 600;
53 + font-size: 16px;
54 + }
55 + .button:hover {
56 + background-color: #059669;
57 + }
58 + .link-fallback {
59 + margin-top: 20px;
60 + padding: 15px;
61 + background-color: #f3f4f6;
62 + border-radius: 6px;
63 + word-break: break-all;
64 + font-size: 14px;
65 + color: #6b7280;
66 + }
67 + .footer {
68 + margin-top: 30px;
69 + padding-top: 20px;
70 + border-top: 1px solid #e5e7eb;
71 + font-size: 14px;
72 + color: #6b7280;
73 + text-align: center;
74 + }
75 + .info {
76 + margin-top: 20px;
77 + padding: 15px;
78 + background-color: #dbeafe;
79 + border-left: 4px solid #3b82f6;
80 + border-radius: 4px;
81 + font-size: 14px;
82 + }
83 + </style>
84 +</head>
85 +<body>
86 + <div class="email-container">
87 + <div class="header">
88 + <div class="logo">{{ app_name }}</div>
89 + </div>
90 +
91 + <h1>
92 + {% if locale == "fr" %}
93 + Vérification de votre adresse email
94 + {% else %}
95 + Verify Your Email Address
96 + {% endif %}
97 + </h1>
98 +
99 + <div class="content">
100 + {% if locale == "fr" %}
101 + <p>Bonjour {{ username }},</p>
102 + <p>Merci de vous être inscrit sur <strong>{{ app_name }}</strong>.</p>
103 + <p>Pour activer votre compte, veuillez vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
104 + {% else %}
105 + <p>Hello {{ username }},</p>
106 + <p>Thank you for signing up for <strong>{{ app_name }}</strong>.</p>
107 + <p>To activate your account, please verify your email address by clicking the button below:</p>
108 + {% endif %}
109 + </div>
110 +
111 + <div class="button-container">
112 + <a href="{{ validation_url }}" class="button">
113 + {% if locale == "fr" %}
114 + Vérifier mon email
115 + {% else %}
116 + Verify My Email
117 + {% endif %}
118 + </a>
119 + </div>
120 +
121 + <div class="link-fallback">
122 + {% if locale == "fr" %}
123 + <p><strong>Le bouton ne fonctionne pas ?</strong></p>
124 + <p>Copiez et collez ce lien dans votre navigateur :</p>
125 + {% else %}
126 + <p><strong>Button not working?</strong></p>
127 + <p>Copy and paste this link into your browser:</p>
128 + {% endif %}
129 + <p style="margin-top: 10px; word-break: break-all;">{{ validation_url }}</p>
130 + </div>
131 +
132 + <div class="info">
133 + {% if locale == "fr" %}
134 + <p><strong>ℹ️ Important :</strong></p>
135 + <ul style="margin: 10px 0; padding-left: 20px;">
136 + <li>Ce lien est valide pendant <strong>24 heures</strong></li>
137 + <li>Si vous n'avez pas créé de compte, ignorez cet email</li>
138 + <li>Vous devez vérifier votre email avant de pouvoir vous connecter</li>
139 + </ul>
140 + {% else %}
141 + <p><strong>ℹ️ Important:</strong></p>
142 + <ul style="margin: 10px 0; padding-left: 20px;">
143 + <li>This link is valid for <strong>24 hours</strong></li>
144 + <li>If you did not create an account, please ignore this email</li>
145 + <li>You must verify your email before you can log in</li>
146 + </ul>
147 + {% endif %}
148 + </div>
149 +
150 + <div class="footer">
151 + {% if locale == "fr" %}
152 + <p>Cet email a été envoyé automatiquement, merci de ne pas y répondre.</p>
153 + <p>Si vous avez des questions, contactez le support technique.</p>
154 + {% else %}
155 + <p>This email was sent automatically, please do not reply.</p>
156 + <p>If you have any questions, please contact technical support.</p>
157 + {% endif %}
158 + <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
159 + © {{ current_year }} {{ app_name }}. Tous droits réservés.
160 + </p>
161 + </div>
162 + </div>
163 +</body>
164 +</html>
A templates_old/emails/password_change_confirmation.html
+167 -0
@@ -0,0 +1,167 @@
1 +<!DOCTYPE html>
2 +<html lang="{{ locale }}">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>{% if locale == "fr" %}Confirmation de changement de mot de passe{% else %}Password Change Confirmation{% endif %}</title>
7 + <style>
8 + body {
9 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 + line-height: 1.6;
11 + color: #333;
12 + max-width: 600px;
13 + margin: 0 auto;
14 + padding: 20px;
15 + background-color: #f4f4f4;
16 + }
17 + .email-container {
18 + background-color: #ffffff;
19 + border-radius: 8px;
20 + padding: 30px;
21 + box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 + }
23 + .header {
24 + text-align: center;
25 + margin-bottom: 30px;
26 + }
27 + .logo {
28 + font-size: 24px;
29 + font-weight: bold;
30 + color: #2563eb;
31 + margin-bottom: 10px;
32 + }
33 + h1 {
34 + color: #1f2937;
35 + font-size: 24px;
36 + margin-bottom: 20px;
37 + }
38 + .content {
39 + margin-bottom: 30px;
40 + }
41 + .button-container {
42 + text-align: center;
43 + margin: 30px 0;
44 + }
45 + .button {
46 + display: inline-block;
47 + padding: 12px 30px;
48 + background-color: #2563eb;
49 + color: #ffffff !important;
50 + text-decoration: none;
51 + border-radius: 6px;
52 + font-weight: 600;
53 + font-size: 16px;
54 + }
55 + .button:hover {
56 + background-color: #1d4ed8;
57 + }
58 + .link-fallback {
59 + margin-top: 20px;
60 + padding: 15px;
61 + background-color: #f3f4f6;
62 + border-radius: 6px;
63 + word-break: break-all;
64 + font-size: 14px;
65 + color: #6b7280;
66 + }
67 + .footer {
68 + margin-top: 30px;
69 + padding-top: 20px;
70 + border-top: 1px solid #e5e7eb;
71 + font-size: 14px;
72 + color: #6b7280;
73 + text-align: center;
74 + }
75 + .warning {
76 + margin-top: 20px;
77 + padding: 15px;
78 + background-color: #fef3c7;
79 + border-left: 4px solid #f59e0b;
80 + border-radius: 4px;
81 + font-size: 14px;
82 + }
83 + </style>
84 +</head>
85 +<body>
86 + <div class="email-container">
87 + <div class="header">
88 + <div class="logo">{{ app_name }}</div>
89 + </div>
90 +
91 + <h1>
92 + {% if locale == "fr" %}
93 + Confirmation de changement de mot de passe
94 + {% else %}
95 + Password Change Confirmation
96 + {% endif %}
97 + </h1>
98 +
99 + <div class="content">
100 + {% if locale == "fr" %}
101 + <p>Bonjour {{ username }},</p>
102 + <p>Vous avez demandé à changer votre mot de passe pour votre compte {{ app_name }}.</p>
103 + <p>Pour confirmer ce changement, cliquez sur le bouton ci-dessous :</p>
104 + {% else %}
105 + <p>Hello {{ username }},</p>
106 + <p>You have requested to change your password for your {{ app_name }} account.</p>
107 + <p>To confirm this change, click the button below:</p>
108 + {% endif %}
109 + </div>
110 +
111 + <div class="button-container">
112 + <a href="{{ confirmation_url }}" class="button">
113 + {% if locale == "fr" %}
114 + Confirmer le changement de mot de passe
115 + {% else %}
116 + Confirm Password Change
117 + {% endif %}
118 + </a>
119 + </div>
120 +
121 + <div class="link-fallback">
122 + {% if locale == "fr" %}
123 + <p><strong>Le bouton ne fonctionne pas ?</strong></p>
124 + <p>Copiez et collez ce lien dans votre navigateur :</p>
125 + {% else %}
126 + <p><strong>Button not working?</strong></p>
127 + <p>Copy and paste this link into your browser:</p>
128 + {% endif %}
129 + <p style="margin-top: 10px; word-break: break-all;">{{ confirmation_url }}</p>
130 + </div>
131 +
132 + <div class="warning">
133 + {% if locale == "fr" %}
134 + <p><strong>⚠️ Important :</strong></p>
135 + <ul style="margin: 10px 0; padding-left: 20px;">
136 + <li>Ce lien est valide pendant <strong>{{ expires_in_hours }} heures</strong> uniquement</li>
137 + <li>Si vous n'avez pas demandé ce changement, ignorez cet email</li>
138 + <li>Votre mot de passe actuel restera inchangé si vous n'utilisez pas ce lien</li>
139 + <li>Ce lien est à <strong>usage unique</strong> et ne peut être utilisé qu'une seule fois</li>
140 + </ul>
141 + {% else %}
142 + <p><strong>⚠️ Important:</strong></p>
143 + <ul style="margin: 10px 0; padding-left: 20px;">
144 + <li>This link is valid for <strong>{{ expires_in_hours }} hours</strong> only</li>
145 + <li>If you did not request this change, please ignore this email</li>
146 + <li>Your current password will remain unchanged if you don't use this link</li>
147 + <li>This link is <strong>single-use</strong> and can only be used once</li>
148 + </ul>
149 + {% endif %}
150 + </div>
151 +
152 + <div class="footer">
153 + {% if locale == "fr" %}
154 + <p>Cet email a été envoyé automatiquement, merci de ne pas y répondre.</p>
155 + <p>Si vous avez des questions, contactez le support technique.</p>
156 + {% else %}
157 + <p>This email was sent automatically, please do not reply.</p>
158 + <p>If you have any questions, please contact technical support.</p>
159 + {% endif %}
160 + <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
161 + © {{ current_year }} {{ app_name }}. {% if locale == "fr" %}Tous droits réservés.{% else %}All rights reserved.{% endif %}
162 + </p>
163 + </div>
164 + </div>
165 +</body>
166 +</html>
167 +
A templates_old/emails/password_changed.html
+135 -0
@@ -0,0 +1,135 @@
1 +<!DOCTYPE html>
2 +<html lang="{{ locale }}">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>{% if locale == "fr" %}Mot de passe modifié{% else %}Password Changed{% endif %}</title>
7 + <style>
8 + body {
9 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 + line-height: 1.6;
11 + color: #333;
12 + max-width: 600px;
13 + margin: 0 auto;
14 + padding: 20px;
15 + background-color: #f4f4f4;
16 + }
17 + .email-container {
18 + background-color: #ffffff;
19 + border-radius: 8px;
20 + padding: 30px;
21 + box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 + }
23 + .header {
24 + text-align: center;
25 + margin-bottom: 30px;
26 + }
27 + .logo {
28 + font-size: 24px;
29 + font-weight: bold;
30 + color: #2563eb;
31 + margin-bottom: 10px;
32 + }
33 + h1 {
34 + color: #1f2937;
35 + font-size: 24px;
36 + margin-bottom: 20px;
37 + }
38 + .content {
39 + margin-bottom: 30px;
40 + }
41 + .info-box {
42 + margin-top: 20px;
43 + padding: 15px;
44 + background-color: #fef3c7;
45 + border-left: 4px solid #f59e0b;
46 + border-radius: 4px;
47 + font-size: 14px;
48 + }
49 + .warning-box {
50 + margin-top: 20px;
51 + padding: 15px;
52 + background-color: #fee2e2;
53 + border-left: 4px solid #ef4444;
54 + border-radius: 4px;
55 + font-size: 14px;
56 + }
57 + .footer {
58 + margin-top: 30px;
59 + padding-top: 20px;
60 + border-top: 1px solid #e5e7eb;
61 + font-size: 14px;
62 + color: #6b7280;
63 + text-align: center;
64 + }
65 + </style>
66 +</head>
67 +<body>
68 + <div class="email-container">
69 + <div class="header">
70 + <div class="logo">{{ app_name }}</div>
71 + </div>
72 +
73 + <h1>
74 + {% if locale == "fr" %}
75 + Mot de passe modifié
76 + {% else %}
77 + Password Changed
78 + {% endif %}
79 + </h1>
80 +
81 + <div class="content">
82 + {% if locale == "fr" %}
83 + <p>Bonjour {{ username }},</p>
84 + <p>Nous vous informons que le mot de passe de votre compte <strong>{{ app_name }}</strong> a été modifié avec succès.</p>
85 + <p><strong>Date et heure de modification :</strong> {{ changed_at }}</p>
86 + {% else %}
87 + <p>Hello {{ username }},</p>
88 + <p>We are informing you that the password for your <strong>{{ app_name }}</strong> account has been successfully changed.</p>
89 + <p><strong>Date and time of change:</strong> {{ changed_at }}</p>
90 + {% endif %}
91 + </div>
92 +
93 + <div class="info-box">
94 + {% if locale == "fr" %}
95 + <p><strong>ℹ️ Information :</strong></p>
96 + <p>Si vous n'avez pas effectué cette modification, veuillez contacter immédiatement le support technique et réinitialiser votre mot de passe.</p>
97 + {% else %}
98 + <p><strong>ℹ️ Information:</strong></p>
99 + <p>If you did not make this change, please contact technical support immediately and reset your password.</p>
100 + {% endif %}
101 + </div>
102 +
103 + <div class="warning-box">
104 + {% if locale == "fr" %}
105 + <p><strong>⚠️ Sécurité :</strong></p>
106 + <ul style="margin: 10px 0; padding-left: 20px;">
107 + <li>Ne partagez jamais votre mot de passe avec qui que ce soit</li>
108 + <li>Utilisez un mot de passe unique et fort</li>
109 + <li>Si vous suspectez une activité suspecte, changez votre mot de passe immédiatement</li>
110 + </ul>
111 + {% else %}
112 + <p><strong>⚠️ Security:</strong></p>
113 + <ul style="margin: 10px 0; padding-left: 20px;">
114 + <li>Never share your password with anyone</li>
115 + <li>Use a unique and strong password</li>
116 + <li>If you suspect suspicious activity, change your password immediately</li>
117 + </ul>
118 + {% endif %}
119 + </div>
120 +
121 + <div class="footer">
122 + {% if locale == "fr" %}
123 + <p>Cet email a été envoyé automatiquement pour votre sécurité.</p>
124 + <p>Si vous avez des questions, contactez le support technique.</p>
125 + {% else %}
126 + <p>This email was sent automatically for your security.</p>
127 + <p>If you have any questions, please contact technical support.</p>
128 + {% endif %}
129 + <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
130 + © {{ current_year }} {{ app_name }}. Tous droits réservés.
131 + </p>
132 + </div>
133 + </div>
134 +</body>
135 +</html>
A templates_old/emails/password_reset.html
+164 -0
@@ -0,0 +1,164 @@
1 +<!DOCTYPE html>
2 +<html lang="{{ locale }}">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>{% if locale == "fr" %}Réinitialisation de mot de passe{% else %}Password Reset{% endif %}</title>
7 + <style>
8 + body {
9 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 + line-height: 1.6;
11 + color: #333;
12 + max-width: 600px;
13 + margin: 0 auto;
14 + padding: 20px;
15 + background-color: #f4f4f4;
16 + }
17 + .email-container {
18 + background-color: #ffffff;
19 + border-radius: 8px;
20 + padding: 30px;
21 + box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 + }
23 + .header {
24 + text-align: center;
25 + margin-bottom: 30px;
26 + }
27 + .logo {
28 + font-size: 24px;
29 + font-weight: bold;
30 + color: #2563eb;
31 + margin-bottom: 10px;
32 + }
33 + h1 {
34 + color: #1f2937;
35 + font-size: 24px;
36 + margin-bottom: 20px;
37 + }
38 + .content {
39 + margin-bottom: 30px;
40 + }
41 + .button-container {
42 + text-align: center;
43 + margin: 30px 0;
44 + }
45 + .button {
46 + display: inline-block;
47 + padding: 12px 30px;
48 + background-color: #2563eb;
49 + color: #ffffff !important;
50 + text-decoration: none;
51 + border-radius: 6px;
52 + font-weight: 600;
53 + font-size: 16px;
54 + }
55 + .button:hover {
56 + background-color: #1d4ed8;
57 + }
58 + .link-fallback {
59 + margin-top: 20px;
60 + padding: 15px;
61 + background-color: #f3f4f6;
62 + border-radius: 6px;
63 + word-break: break-all;
64 + font-size: 14px;
65 + color: #6b7280;
66 + }
67 + .footer {
68 + margin-top: 30px;
69 + padding-top: 20px;
70 + border-top: 1px solid #e5e7eb;
71 + font-size: 14px;
72 + color: #6b7280;
73 + text-align: center;
74 + }
75 + .warning {
76 + margin-top: 20px;
77 + padding: 15px;
78 + background-color: #fef3c7;
79 + border-left: 4px solid #f59e0b;
80 + border-radius: 4px;
81 + font-size: 14px;
82 + }
83 + </style>
84 +</head>
85 +<body>
86 + <div class="email-container">
87 + <div class="header">
88 + <div class="logo">{{ app_name }}</div>
89 + </div>
90 +
91 + <h1>
92 + {% if locale == "fr" %}
93 + Réinitialisation de votre mot de passe
94 + {% else %}
95 + Reset Your Password
96 + {% endif %}
97 + </h1>
98 +
99 + <div class="content">
100 + {% if locale == "fr" %}
101 + <p>Bonjour {{ username }},</p>
102 + <p>Vous avez demandé à réinitialiser votre mot de passe pour votre compte {{ app_name }}.</p>
103 + <p>Cliquez sur le bouton ci-dessous pour réinitialiser votre mot de passe :</p>
104 + {% else %}
105 + <p>Hello {{ username }},</p>
106 + <p>You have requested to reset your password for your {{ app_name }} account.</p>
107 + <p>Click the button below to reset your password:</p>
108 + {% endif %}
109 + </div>
110 +
111 + <div class="button-container">
112 + <a href="{{ reset_url }}" class="button">
113 + {% if locale == "fr" %}
114 + Réinitialiser mon mot de passe
115 + {% else %}
116 + Reset My Password
117 + {% endif %}
118 + </a>
119 + </div>
120 +
121 + <div class="link-fallback">
122 + {% if locale == "fr" %}
123 + <p><strong>Le bouton ne fonctionne pas ?</strong></p>
124 + <p>Copiez et collez ce lien dans votre navigateur :</p>
125 + {% else %}
126 + <p><strong>Button not working?</strong></p>
127 + <p>Copy and paste this link into your browser:</p>
128 + {% endif %}
129 + <p style="margin-top: 10px; word-break: break-all;">{{ reset_url }}</p>
130 + </div>
131 +
132 + <div class="warning">
133 + {% if locale == "fr" %}
134 + <p><strong>⚠️ Important :</strong></p>
135 + <ul style="margin: 10px 0; padding-left: 20px;">
136 + <li>Ce lien est valide pendant <strong>1 heure</strong> uniquement</li>
137 + <li>Si vous n'avez pas demandé cette réinitialisation, ignorez cet email</li>
138 + <li>Votre mot de passe actuel restera inchangé si vous n'utilisez pas ce lien</li>
139 + </ul>
140 + {% else %}
141 + <p><strong>⚠️ Important:</strong></p>
142 + <ul style="margin: 10px 0; padding-left: 20px;">
143 + <li>This link is valid for <strong>1 hour</strong> only</li>
144 + <li>If you did not request this reset, please ignore this email</li>
145 + <li>Your current password will remain unchanged if you don't use this link</li>
146 + </ul>
147 + {% endif %}
148 + </div>
149 +
150 + <div class="footer">
151 + {% if locale == "fr" %}
152 + <p>Cet email a été envoyé automatiquement, merci de ne pas y répondre.</p>
153 + <p>Si vous avez des questions, contactez le support technique.</p>
154 + {% else %}
155 + <p>This email was sent automatically, please do not reply.</p>
156 + <p>If you have any questions, please contact technical support.</p>
157 + {% endif %}
158 + <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
159 + © {{ current_year }} {{ app_name }}. Tous droits réservés.
160 + </p>
161 + </div>
162 + </div>
163 +</body>
164 +</html>
A templates_old/emails/welcome.html
+176 -0
@@ -0,0 +1,176 @@
1 +<!DOCTYPE html>
2 +<html lang="{{ locale }}">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>{% if locale == "fr" %}Bienvenue{% else %}Welcome{% endif %}</title>
7 + <style>
8 + body {
9 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10 + line-height: 1.6;
11 + color: #333;
12 + max-width: 600px;
13 + margin: 0 auto;
14 + padding: 20px;
15 + background-color: #f4f4f4;
16 + }
17 + .email-container {
18 + background-color: #ffffff;
19 + border-radius: 8px;
20 + padding: 30px;
21 + box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22 + }
23 + .header {
24 + text-align: center;
25 + margin-bottom: 30px;
26 + }
27 + .logo {
28 + font-size: 28px;
29 + font-weight: bold;
30 + color: #2563eb;
31 + margin-bottom: 10px;
32 + }
33 + .welcome-icon {
34 + font-size: 48px;
35 + margin-bottom: 20px;
36 + }
37 + h1 {
38 + color: #1f2937;
39 + font-size: 28px;
40 + margin-bottom: 20px;
41 + text-align: center;
42 + }
43 + .content {
44 + margin-bottom: 30px;
45 + }
46 + .button-container {
47 + text-align: center;
48 + margin: 30px 0;
49 + }
50 + .button {
51 + display: inline-block;
52 + padding: 14px 32px;
53 + background-color: #10b981;
54 + color: #ffffff !important;
55 + text-decoration: none;
56 + border-radius: 6px;
57 + font-weight: 600;
58 + font-size: 16px;
59 + }
60 + .button:hover {
61 + background-color: #059669;
62 + }
63 + .features-list {
64 + margin: 20px 0;
65 + padding: 20px;
66 + background-color: #f3f4f6;
67 + border-radius: 6px;
68 + }
69 + .feature-item {
70 + margin: 12px 0;
71 + padding-left: 25px;
72 + position: relative;
73 + }
74 + .feature-item::before {
75 + content: "✓";
76 + position: absolute;
77 + left: 0;
78 + color: #10b981;
79 + font-weight: bold;
80 + font-size: 18px;
81 + }
82 + .info-box {
83 + margin-top: 20px;
84 + padding: 15px;
85 + background-color: #dbeafe;
86 + border-left: 4px solid #3b82f6;
87 + border-radius: 4px;
88 + font-size: 14px;
89 + }
90 + .footer {
91 + margin-top: 30px;
92 + padding-top: 20px;
93 + border-top: 1px solid #e5e7eb;
94 + font-size: 14px;
95 + color: #6b7280;
96 + text-align: center;
97 + }
98 + </style>
99 +</head>
100 +<body>
101 + <div class="email-container">
102 + <div class="header">
103 + <div class="logo">{{ app_name }}</div>
104 + <div class="welcome-icon">🎉</div>
105 + </div>
106 +
107 + <h1>
108 + {% if locale == "fr" %}
109 + Bienvenue sur {{ app_name }} !
110 + {% else %}
111 + Welcome to {{ app_name }}!
112 + {% endif %}
113 + </h1>
114 +
115 + <div class="content">
116 + {% if locale == "fr" %}
117 + <p>Bonjour {{ username }},</p>
118 + <p>Nous sommes ravis de vous accueillir sur <strong>{{ app_name }}</strong> ! Votre compte a été activé avec succès.</p>
119 + <p>Vous pouvez maintenant profiter de toutes les fonctionnalités de la plateforme.</p>
120 + {% else %}
121 + <p>Hello {{ username }},</p>
122 + <p>We are delighted to welcome you to <strong>{{ app_name }}</strong>! Your account has been successfully activated.</p>
123 + <p>You can now enjoy all the platform's features.</p>
124 + {% endif %}
125 + </div>
126 +
127 + <div class="button-container">
128 + <a href="{{ login_url }}" class="button">
129 + {% if locale == "fr" %}
130 + Se connecter
131 + {% else %}
132 + Log In
133 + {% endif %}
134 + </a>
135 + </div>
136 +
137 + <div class="features-list">
138 + {% if locale == "fr" %}
139 + <p style="font-weight: 600; margin-bottom: 15px;">Ce que vous pouvez faire :</p>
140 + <div class="feature-item">Accéder à votre tableau de bord personnalisé</div>
141 + <div class="feature-item">Gérer votre profil et vos préférences</div>
142 + <div class="feature-item">Profiter de toutes les fonctionnalités disponibles</div>
143 + {% else %}
144 + <p style="font-weight: 600; margin-bottom: 15px;">What you can do:</p>
145 + <div class="feature-item">Access your personalized dashboard</div>
146 + <div class="feature-item">Manage your profile and preferences</div>
147 + <div class="feature-item">Enjoy all available features</div>
148 + {% endif %}
149 + </div>
150 +
151 + <div class="info-box">
152 + {% if locale == "fr" %}
153 + <p><strong>ℹ️ Besoin d'aide ?</strong></p>
154 + <p>Si vous avez des questions ou besoin d'assistance, n'hésitez pas à consulter notre documentation ou à contacter le support technique.</p>
155 + {% else %}
156 + <p><strong>ℹ️ Need Help?</strong></p>
157 + <p>If you have any questions or need assistance, feel free to check our documentation or contact technical support.</p>
158 + {% endif %}
159 + </div>
160 +
161 + <div class="footer">
162 + {% if locale == "fr" %}
163 + <p>Merci de nous faire confiance et bonne utilisation de {{ app_name }} !</p>
164 + <p>Si vous avez des questions, contactez le support technique.</p>
165 + {% else %}
166 + <p>Thank you for trusting us and enjoy using {{ app_name }}!</p>
167 + <p>If you have any questions, please contact technical support.</p>
168 + {% endif %}
169 + <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">
170 + © {{ current_year }} {{ app_name }}. Tous droits réservés.
171 + </p>
172 + </div>
173 + </div>
174 +</body>
175 +</html>
176 +
A templates_old/errors/401.html
+36 -0
@@ -0,0 +1,36 @@
1 +<!DOCTYPE html>
2 +<html lang="fr">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>401 - Non Authentifié</title>
7 + <link href="/static/css/tailwind.css" rel="stylesheet">
8 +</head>
9 +<body class="bg-base-200 min-h-screen flex items-center justify-center px-4">
10 + <div class="max-w-md w-full text-center">
11 + <!-- Error Icon -->
12 + <div class="mb-8">
13 + <div class="inline-flex items-center justify-center w-24 h-24 rounded-full bg-error/20">
14 + <span class="text-6xl">🔒</span>
15 + </div>
16 + </div>
17 +
18 + <!-- Error Message -->
19 + <h1 class="text-4xl font-bold text-base-content mb-4">401</h1>
20 + <h2 class="text-2xl font-semibold text-base-content mb-4">Non Authentifié</h2>
21 + <p class="text-base-content/70 mb-8">
22 + Vous devez vous connecter pour accéder à cette page.
23 + </p>
24 +
25 + <!-- Actions -->
26 + <div class="space-y-3">
27 + <a href="/login" class="btn btn-primary w-full">
28 + Se Connecter
29 + </a>
30 + <a href="/" class="btn btn-ghost w-full">
31 + Retour à l'Accueil
32 + </a>
33 + </div>
34 + </div>
35 +</body>
36 +</html>
A templates_old/errors/403.html
+36 -0
@@ -0,0 +1,36 @@
1 +<!DOCTYPE html>
2 +<html lang="fr">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>403 - Accès Refusé</title>
7 + <link href="/static/css/tailwind.css" rel="stylesheet">
8 +</head>
9 +<body class="bg-base-200 min-h-screen flex items-center justify-center px-4">
10 + <div class="max-w-md w-full text-center">
11 + <!-- Error Icon -->
12 + <div class="mb-8">
13 + <div class="inline-flex items-center justify-center w-24 h-24 rounded-full bg-warning/20">
14 + <span class="text-6xl">⛔</span>
15 + </div>
16 + </div>
17 +
18 + <!-- Error Message -->
19 + <h1 class="text-4xl font-bold text-base-content mb-4">403</h1>
20 + <h2 class="text-2xl font-semibold text-base-content mb-4">Accès Refusé</h2>
21 + <p class="text-base-content/70 mb-8">
22 + Vous n'avez pas les permissions nécessaires pour accéder à cette page.
23 + </p>
24 +
25 + <!-- Actions -->
26 + <div class="space-y-3">
27 + <a href="/home" class="btn btn-primary w-full">
28 + Retour à l'Accueil
29 + </a>
30 + <a href="/profile" class="btn btn-ghost w-full">
31 + Mon profil
32 + </a>
33 + </div>
34 + </div>
35 +</body>
36 +</html>
A templates_old/errors/500.html
+41 -0
@@ -0,0 +1,41 @@
1 +<!DOCTYPE html>
2 +<html lang="fr">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>500 - Erreur Serveur</title>
7 + <link href="/static/css/tailwind.css" rel="stylesheet">
8 +</head>
9 +<body class="bg-base-200 min-h-screen flex items-center justify-center px-4">
10 + <div class="max-w-md w-full text-center">
11 + <!-- Error Icon -->
12 + <div class="mb-8">
13 + <div class="inline-flex items-center justify-center w-24 h-24 rounded-full bg-error/20">
14 + <span class="text-6xl">⚠️</span>
15 + </div>
16 + </div>
17 +
18 + <!-- Error Message -->
19 + <h1 class="text-4xl font-bold text-base-content mb-4">500</h1>
20 + <h2 class="text-2xl font-semibold text-base-content mb-4">Erreur Serveur</h2>
21 + <p class="text-base-content/70 mb-8">
22 + Une erreur interne est survenue. Nos équipes ont été notifiées et travaillent sur le problème.
23 + </p>
24 +
25 + <!-- Actions -->
26 + <div class="space-y-3">
27 + <button onclick="window.location.reload()" class="btn btn-primary w-full">
28 + Réessayer
29 + </button>
30 + <a href="/home" class="btn btn-ghost w-full">
31 + Retour à l'Accueil
32 + </a>
33 + </div>
34 +
35 + <!-- Support Info -->
36 + <div class="mt-8 text-sm text-base-content/50">
37 + <p>Si le problème persiste, contactez le support.</p>
38 + </div>
39 + </div>
40 +</body>
41 +</html>
A templates_old/fragments/form_message.html
+23 -0
@@ -0,0 +1,23 @@
1 +{% if message_type == "success" %}
2 +<div class="alert alert-success mb-4" role="alert">
3 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
4 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
5 + </svg>
6 + <span>{{ message }}</span>
7 +</div>
8 +{% else %}
9 +<div class="alert alert-error mb-4" role="alert">
10 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
11 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
12 + </svg>
13 + <span>{{ message }}</span>
14 +</div>
15 +{% endif %}
16 +
17 +
18 +
19 +
20 +
21 +
22 +
23 +
A templates_old/fragments/user_menu_dropdown.html
+85 -0
@@ -0,0 +1,85 @@
1 +<!-- User Information -->
2 +<li class="menu-title">
3 + <div class="flex items-center gap-3 px-2 py-2">
4 + <!-- Avatar -->
5 + <div class="avatar placeholder">
6 + <div class="bg-primary text-primary-content rounded-full w-10 h-10"
7 + style="background-color: {{ avatar_color }} !important;">
8 + <span class="text-sm font-semibold">{{ initials }}</span>
9 + </div>
10 + </div>
11 +
12 + <!-- User Info -->
13 + <div class="flex-1 min-w-0">
14 + <div class="text-sm font-medium text-base-content truncate">{{ username }}</div>
15 + <div class="text-xs text-base-content/60 truncate">{{ email }}</div>
16 + </div>
17 + </div>
18 +
19 + <!-- Roles -->
20 + {% if !roles_text.is_empty() %}
21 + <div class="px-2 mt-1">
22 + <span class="badge badge-primary badge-sm">{{ roles_text }}</span>
23 + </div>
24 + {% endif %}
25 +</li>
26 +
27 +<!-- Menu Items -->
28 +<li>
29 + <a href="/profile" class="gap-2">
30 + <span>👤</span>
31 + <span>{{ user_menu_profile }}</span>
32 + </a>
33 +</li>
34 +
35 +<!-- Divider -->
36 +<li><hr class="my-2"></li>
37 +
38 +<!-- Logout -->
39 +<li>
40 + <button
41 + type="button"
42 + onclick="handleLogout()"
43 + class="w-full text-left text-error hover:bg-error/10 gap-2"
44 + >
45 + <span>🚪</span>
46 + <span>{{ user_menu_logout }}</span>
47 + </button>
48 +</li>
49 +
50 +<script>
51 +function handleLogout() {
52 + // Send logout request with credentials (cookies)
53 + fetch('/api/v1/auth/logout', {
54 + method: 'POST',
55 + headers: {
56 + 'Content-Type': 'application/json',
57 + 'Accept': 'text/html,application/json'
58 + },
59 + credentials: 'include' // Include cookies
60 + })
61 + .then(response => {
62 + if (response.ok) {
63 + // Check if response is a redirect
64 + if (response.redirected || response.status === 200) {
65 + // Redirect to login page
66 + window.location.href = '/login';
67 + } else {
68 + return response.json().then(data => {
69 + // JSON response from API
70 + window.location.href = '/login';
71 + });
72 + }
73 + } else {
74 + // Error - still redirect to login
75 + console.error('Logout error:', response.status);
76 + window.location.href = '/login';
77 + }
78 + })
79 + .catch(error => {
80 + console.error('Logout error:', error);
81 + // Even on error, redirect to login
82 + window.location.href = '/login';
83 + });
84 +}
85 +</script>
A templates_old/home.html
+37 -0
@@ -0,0 +1,37 @@
1 +{% extends "base.html" %}
2 +
3 +{% block navbar %}
4 +{% include "components/navbar.html" %}
5 +{% endblock %}
6 +
7 +{% block content %}
8 +<!-- Welcome Section -->
9 +<div class="mb-8">
10 + <h1 class="text-3xl font-bold text-base-content mb-2">{{ welcome_message }}</h1>
11 + <p class="text-base-content/70">{{ home_subtitle }}</p>
12 +</div>
13 +
14 +<!-- Account Stats -->
15 +<div class="mb-12">
16 + <h2 class="text-xl font-semibold text-base-content mb-4">{{ home_my_account }}</h2>
17 + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
18 + {% for stat in account_stats %}
19 + <div class="card bg-base-100 shadow-md hover:shadow-xl transition-shadow">
20 + <div class="card-body p-6">
21 + <div class="flex items-center justify-between mb-2">
22 + <span class="text-2xl">
23 + {% if stat.icon == "user" %}👤
24 + {% elif stat.icon == "mail" %}✉️
25 + {% elif stat.icon == "calendar" %}📅
26 + {% elif stat.icon == "shield" %}🛡️
27 + {% else %}📊{% endif %}
28 + </span>
29 + </div>
30 + <div class="text-sm text-base-content/70 mb-1">{{ stat.label }}</div>
31 + <div class="text-lg font-semibold text-base-content">{{ stat.value }}</div>
32 + </div>
33 + </div>
34 + {% endfor %}
35 + </div>
36 +</div>
37 +{% endblock %}
A templates_old/login.html
+624 -0
@@ -0,0 +1,624 @@
1 +<!DOCTYPE html>
2 +<html lang="{{ current_locale }}">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <meta name="description" content="{{ page_description }}">
7 + <title>{{ page_title }}</title>
8 +
9 + <!-- Tailwind CSS (local) -->
10 + <link href="/static/css/tailwind.css" rel="stylesheet">
11 +
12 + <!-- Custom CSS -->
13 + <link href="/static/css/custom.css" rel="stylesheet">
14 +
15 + <!-- HTMX (local) -->
16 + <script src="/static/js/htmx.min.js"></script>
17 +
18 + <!-- Alpine.js (local) -->
19 + <script src="/static/js/alpine.min.js" defer></script>
20 +</head>
21 +<body class="bg-base-200 min-h-screen flex items-center justify-center px-4">
22 + <div class="max-w-md w-full">
23 + <!-- Language Selector (Feature 014 - T065) -->
24 + <div class="flex justify-end mb-4">
25 + <div class="dropdown dropdown-end">
26 + <label tabindex="0" class="btn btn-ghost btn-sm gap-2">
27 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
28 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
29 + </svg>
30 + <span id="current-lang-display">
31 + {% for (code, name, is_current) in supported_languages %}
32 + {% if is_current %}{{ name }}{% endif %}
33 + {% endfor %}
34 + </span>
35 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
36 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
37 + </svg>
38 + </label>
39 + <ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow">
40 + {% for (code, name, is_current) in supported_languages %}
41 + <li>
42 + <a href="#" onclick="return window.changeLanguage('{{ code }}');" class="{% if is_current %}active{% endif %}">
43 + {{ name }}
44 + {% if is_current %}
45 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
46 + <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
47 + </svg>
48 + {% endif %}
49 + </a>
50 + </li>
51 + {% endfor %}
52 + </ul>
53 + </div>
54 + </div>
55 +
56 + <!-- Logo and Title -->
57 + <div class="text-center mb-8">
58 + <div class="inline-flex items-center justify-center w-16 h-16 mb-4">
59 + <img src="/static/images/logo.svg" alt="{{ app_name }}" class="h-16 w-16">
60 + </div>
61 + <h1 class="text-3xl font-bold text-base-content mb-2">{{ app_name }}</h1>
62 + <p class="text-base-content/70">{{ subtitle }}</p>
63 + </div>
64 +
65 + <!-- Login/Register Form Card -->
66 + <div class="card bg-base-100 shadow-xl p-8">
67 + <!-- Tabs for Login/Register -->
68 + <div class="tabs tabs-boxed mb-6">
69 + <a id="tab-login" class="tab tab-active" onclick="showLogin()">{{ tab_login }}</a>
70 + <a id="tab-register" class="tab" onclick="showRegister()">{{ tab_register }}</a>
71 + </div>
72 +
73 + <!-- Login Section -->
74 + <div id="login-section">
75 + <h2 class="text-2xl font-semibold text-base-content mb-6 text-center">{{ login_title }}</h2>
76 +
77 + <!-- Error Message (if any) -->
78 + <div id="error-message" role="alert" class="hidden mb-4 alert alert-error">
79 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
80 + <span id="error-text"></span>
81 + </div>
82 +
83 + <!-- Success Message (if any) -->
84 + <div id="success-message" role="alert" class="hidden mb-4 alert alert-success">
85 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
86 + <span id="success-text"></span>
87 + </div>
88 +
89 + <!-- Resend verification CTA -->
90 + <div id="resend-block" class="hidden mb-4 space-y-2">
91 + <p class="text-sm text-base-content">{{ email_unverified_message }}</p>
92 + <button id="resend-btn" type="button" class="btn btn-outline btn-primary w-full">{{ resend_button }}</button>
93 + <p id="resend-status" class="text-sm text-success hidden"></p>
94 + </div>
95 +
96 + <!-- Login Form -->
97 + <form
98 + id="login-form"
99 + class="space-y-4">
100 +
101 + <!-- Username -->
102 + <div class="form-control">
103 + <label for="username" class="label">
104 + <span class="label-text">{{ username_label }}</span>
105 + </label>
106 + <input
107 + type="text"
108 + id="username"
109 + name="username"
110 + required
111 + autocomplete="username"
112 + class="input input-bordered w-full"
113 + placeholder="{{ username_placeholder }}">
114 + </div>
115 +
116 + <!-- Password -->
117 + <div class="form-control">
118 + <label for="password" class="label">
119 + <span class="label-text">{{ password_label }}</span>
120 + </label>
121 + <input
122 + type="password"
123 + id="password"
124 + name="password"
125 + required
126 + autocomplete="current-password"
127 + class="input input-bordered w-full"
128 + placeholder="{{ password_placeholder }}">
129 + </div>
130 +
131 + <!-- Remember Me (optional) -->
132 + <div class="form-control">
133 + <label class="label cursor-pointer justify-start gap-2">
134 + <input
135 + type="checkbox"
136 + id="remember"
137 + name="remember"
138 + class="checkbox checkbox-primary">
139 + <span class="label-text">{{ remember_me }}</span>
140 + </label>
141 + </div>
142 +
143 + <!-- Submit Button -->
144 + <div class="form-control mt-6">
145 + <button
146 + type="submit"
147 + class="btn btn-primary w-full">
148 + {{ submit_button }}
149 + </button>
150 + </div>
151 + </form>
152 +
153 + <!-- OAuth Buttons (loaded dynamically from /api/v1/auth/oauth/providers) -->
154 + <div id="oauth-buttons" class="hidden mt-4">
155 + <div class="divider text-xs text-base-content/50">OU</div>
156 + <div id="oauth-providers-list" class="space-y-2"></div>
157 + </div>
158 +
159 + <!-- Additional Links -->
160 + <div class="mt-6 text-center space-y-2">
161 + <a href="/forgot-password" class="text-sm text-primary hover:underline">
162 + {{ forgot_password }}
163 + </a>
164 + <p class="text-sm text-base-content/70">
165 + {{ email_not_verified }}
166 + <a href="/resend-verification" class="text-primary hover:underline">
167 + {{ resend_verification }}
168 + </a>
169 + </p>
170 + </div>
171 + </div>
172 +
173 + <!-- Register Section -->
174 + <div id="register-section" class="hidden">
175 + <h2 class="text-2xl font-semibold text-base-content mb-6 text-center">{{ register_title }}</h2>
176 +
177 + <!-- Error Message (if any) -->
178 + <div id="register-error-message" role="alert" class="hidden mb-4 alert alert-error">
179 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
180 + <span id="register-error-text"></span>
181 + </div>
182 +
183 + <!-- Success Message (if any) -->
184 + <div id="register-success-message" role="alert" class="hidden mb-4 alert alert-success">
185 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
186 + <span id="register-success-text"></span>
187 + </div>
188 +
189 + <!-- Register Form -->
190 + <form id="register-form" class="space-y-4">
191 + <!-- Username -->
192 + <div class="form-control">
193 + <label for="register-username" class="label">
194 + <span class="label-text">{{ username_label }}</span>
195 + </label>
196 + <input
197 + type="text"
198 + id="register-username"
199 + name="username"
200 + required
201 + minlength="3"
202 + maxlength="50"
203 + autocomplete="username"
204 + class="input input-bordered w-full"
205 + placeholder="{{ username_placeholder_register }}">
206 + </div>
207 +
208 + <!-- Email -->
209 + <div class="form-control">
210 + <label for="register-email" class="label">
211 + <span class="label-text">{{ email_label }}</span>
212 + </label>
213 + <input
214 + type="email"
215 + id="register-email"
216 + name="email"
217 + required
218 + autocomplete="email"
219 + class="input input-bordered w-full"
220 + placeholder="{{ email_placeholder }}">
221 + </div>
222 +
223 + <!-- Password -->
224 + <div class="form-control">
225 + <label for="register-password" class="label">
226 + <span class="label-text">{{ password_label }}</span>
227 + </label>
228 + <input
229 + type="password"
230 + id="register-password"
231 + name="password"
232 + required
233 + minlength="{{ pw_min_length }}"
234 + autocomplete="new-password"
235 + class="input input-bordered w-full"
236 + placeholder="{{ password_placeholder_register }}">
237 + </div>
238 +
239 + <!-- Submit Button -->
240 + <div class="form-control mt-6">
241 + <button
242 + type="submit"
243 + class="btn btn-primary w-full">
244 + {{ register_submit }}
245 + </button>
246 + </div>
247 + </form>
248 +
249 + <!-- Additional Links -->
250 + <div class="mt-6 text-center">
251 + <p class="text-sm text-base-content/70">
252 + {{ already_have_account }}
253 + <a href="#" onclick="showLogin(); return false;" class="text-primary hover:underline">
254 + {{ sign_in_link }}
255 + </a>
256 + </p>
257 + </div>
258 + </div>
259 + </div>
260 +
261 + <!-- Footer -->
262 + <div class="mt-8 text-center text-sm text-base-content/50">
263 + <p>{{ app_name }} v{{ app_version }} - {{ footer_text }}</p>
264 + </div>
265 + </div>
266 +
267 + <script>
268 + // Translated messages (Feature 014 - i18n login page)
269 + const i18nMessages = {
270 + successMessage: "{{ success_message }}",
271 + errorMessage: "{{ error_message }}",
272 + resendSuccess: "{{ resend_success }}",
273 + resendError: "{{ resend_error }}",
274 + resendPrompt: "{{ resend_prompt }}",
275 + registerSuccess: "{{ register_success }}",
276 + registerError: "{{ register_error }}",
277 + };
278 +
279 + // Tab switching functions
280 + function showLogin() {
281 + document.getElementById('login-section').classList.remove('hidden');
282 + document.getElementById('register-section').classList.add('hidden');
283 + document.getElementById('tab-login').classList.add('tab-active');
284 + document.getElementById('tab-register').classList.remove('tab-active');
285 + }
286 +
287 + function showRegister() {
288 + document.getElementById('login-section').classList.add('hidden');
289 + document.getElementById('register-section').classList.remove('hidden');
290 + document.getElementById('tab-login').classList.remove('tab-active');
291 + document.getElementById('tab-register').classList.add('tab-active');
292 + }
293 +
294 + function setErrorMessage(message) {
295 + const errorDiv = document.getElementById('error-message');
296 + const errorText = document.getElementById('error-text');
297 + if (errorDiv && errorText) {
298 + errorText.textContent = message || i18nMessages.errorMessage;
299 + errorDiv.classList.remove('hidden');
300 + if (isUnverifiedMessage(message)) {
301 + showResendBlock();
302 + }
303 + }
304 + }
305 +
306 + function normalizeText(text) {
307 + return text
308 + .normalize('NFD')
309 + .replace(/\p{Diacritic}/gu, '')
310 + .toLowerCase();
311 + }
312 +
313 + function isUnverifiedMessage(msg) {
314 + if (!msg) return false;
315 + const lower = normalizeText(msg);
316 + return (
317 + lower.includes('non verifie') ||
318 + lower.includes('not verified') ||
319 + (lower.includes('email') && lower.includes('verifi'))
320 + );
321 + }
322 +
323 + // Handle login response
324 + function handleLoginResponse(event) {
325 + const xhr = event.detail.xhr;
326 +
327 + if (xhr.status === 200) {
328 + // Login successful
329 + const response = JSON.parse(xhr.responseText);
330 +
331 + // Hide error message if visible
332 + const errorDiv = document.getElementById('error-message');
333 + if (errorDiv) {
334 + errorDiv.classList.add('hidden');
335 + }
336 +
337 + // Show success message
338 + const successDiv = document.getElementById('success-message');
339 + if (successDiv) {
340 + document.getElementById('success-text').textContent = i18nMessages.successMessage;
341 + successDiv.classList.remove('hidden');
342 + }
343 +
344 + // Redirect to home or redirect parameter
345 + const urlParams = new URLSearchParams(window.location.search);
346 + const redirect = urlParams.get('redirect') || '/home';
347 +
348 + // Small delay for user to see success message
349 + setTimeout(() => {
350 + window.location.href = redirect;
351 + }, 500);
352 + } else {
353 + // Login failed
354 + try {
355 + const errorResponse = JSON.parse(xhr.responseText);
356 + const message =
357 + errorResponse.error ||
358 + errorResponse.message ||
359 + i18nMessages.errorMessage;
360 + setErrorMessage(message);
361 + if (isUnverifiedMessage(message)) {
362 + showResendBlock();
363 + }
364 + } catch (e) {
365 + setErrorMessage(i18nMessages.errorMessage);
366 + }
367 + }
368 + }
369 +
370 + // Handle form submission with JSON
371 + document.getElementById('login-form').addEventListener('submit', function(e) {
372 + e.preventDefault();
373 +
374 + const username = document.getElementById('username').value;
375 + const password = document.getElementById('password').value;
376 +
377 + // Hide previous error
378 + const errorDiv = document.getElementById('error-message');
379 + if (errorDiv) {
380 + errorDiv.classList.add('hidden');
381 + }
382 + hideResendBlock();
383 +
384 + // Create JSON payload
385 + const payload = JSON.stringify({
386 + username: username,
387 + password: password
388 + });
389 +
390 + // Use fetch API to send JSON request
391 + fetch('/api/v1/auth/login', {
392 + method: 'POST',
393 + headers: {
394 + 'Content-Type': 'application/json',
395 + },
396 + body: payload,
397 + credentials: 'include' // Include cookies for session
398 + })
399 + .then(async response => {
400 + if (response.ok) {
401 + return response.json();
402 + } else {
403 + let msg = i18nMessages.errorMessage;
404 + try {
405 + const err = await response.json();
406 + msg = err.error || err.message || msg;
407 + } catch (_) {
408 + // ignore parse errors
409 + }
410 + throw new Error(msg);
411 + }
412 + })
413 + .then(data => {
414 + // Login successful
415 + const successDiv = document.getElementById('success-message');
416 + if (successDiv) {
417 + document.getElementById('success-text').textContent = i18nMessages.successMessage;
418 + successDiv.classList.remove('hidden');
419 + }
420 +
421 + // T130: Check if user must change password
422 + if (data.user && data.user.must_change_password === true) {
423 + // Redirect to password change page with must_change flag
424 + setTimeout(() => {
425 + window.location.href = '/settings/password?must_change=true';
426 + }, 500);
427 + return;
428 + }
429 +
430 + // Redirect to home or redirect parameter
431 + const urlParams = new URLSearchParams(window.location.search);
432 + const redirect = urlParams.get('redirect') || '/home';
433 +
434 + // Small delay for user to see success message
435 + setTimeout(() => {
436 + window.location.href = redirect;
437 + }, 500);
438 + })
439 + .catch(error => {
440 + const msg = error.message || i18nMessages.errorMessage;
441 + setErrorMessage(msg);
442 + if (isUnverifiedMessage(msg)) {
443 + showResendBlock();
444 + }
445 + });
446 + });
447 +
448 + // Register form handler
449 + document.getElementById('register-form').addEventListener('submit', function(e) {
450 + e.preventDefault();
451 +
452 + const username = document.getElementById('register-username').value;
453 + const email = document.getElementById('register-email').value;
454 + const password = document.getElementById('register-password').value;
455 +
456 + // Hide previous messages
457 + const errorDiv = document.getElementById('register-error-message');
458 + const successDiv = document.getElementById('register-success-message');
459 + if (errorDiv) errorDiv.classList.add('hidden');
460 + if (successDiv) successDiv.classList.add('hidden');
461 +
462 + // Create JSON payload
463 + const payload = JSON.stringify({
464 + username: username,
465 + email: email,
466 + password: password
467 + });
468 +
469 + // Send registration request
470 + fetch('/api/v1/auth/register', {
471 + method: 'POST',
472 + headers: {
473 + 'Content-Type': 'application/json',
474 + },
475 + body: payload,
476 + credentials: 'include'
477 + })
478 + .then(async response => {
479 + if (response.ok) {
480 + return response.json();
481 + } else {
482 + let msg = i18nMessages.registerError;
483 + try {
484 + const err = await response.json();
485 + msg = err.error || err.message || msg;
486 + } catch (_) {
487 + // ignore parse errors
488 + }
489 + throw new Error(msg);
490 + }
491 + })
492 + .then(data => {
493 + // Registration successful
494 + if (successDiv) {
495 + document.getElementById('register-success-text').textContent =
496 + data.message || i18nMessages.registerSuccess;
497 + successDiv.classList.remove('hidden');
498 + }
499 +
500 + // Clear form
501 + document.getElementById('register-form').reset();
502 +
503 + // Optionally redirect to login after 3 seconds
504 + setTimeout(() => {
505 + showLogin();
506 + if (successDiv) successDiv.classList.add('hidden');
507 + }, 3000);
508 + })
509 + .catch(error => {
510 + const msg = error.message || i18nMessages.registerError;
511 + if (errorDiv) {
512 + document.getElementById('register-error-text').textContent = msg;
513 + errorDiv.classList.remove('hidden');
514 + }
515 + });
516 + });
517 +
518 + function showResendBlock() {
519 + const block = document.getElementById('resend-block');
520 + if (block) {
521 + block.classList.remove('hidden');
522 + block.style.display = 'block';
523 + }
524 + }
525 +
526 + function hideResendBlock() {
527 + const block = document.getElementById('resend-block');
528 + const status = document.getElementById('resend-status');
529 + if (block) {
530 + block.classList.add('hidden');
531 + block.style.display = 'none';
532 + }
533 + if (status) {
534 + status.textContent = '';
535 + status.classList.add('hidden');
536 + }
537 + }
538 +
539 + document.getElementById('resend-btn')?.addEventListener('click', () => {
540 + const emailOrUsername = document.getElementById('username').value;
541 + const status = document.getElementById('resend-status');
542 + if (!emailOrUsername) {
543 + const errorDiv = document.getElementById('error-message');
544 + if (errorDiv) {
545 + document.getElementById('error-text').textContent = i18nMessages.resendPrompt;
546 + errorDiv.classList.remove('hidden');
547 + }
548 + return;
549 + }
550 + const isEmail = emailOrUsername.includes('@');
551 + const endpoint = isEmail
552 + ? '/api/v1/auth/resend-verification'
553 + : '/api/v1/auth/resend-verification/by-username';
554 + const payload = isEmail
555 + ? { email: emailOrUsername }
556 + : { username: emailOrUsername };
557 + fetch(endpoint, {
558 + method: 'POST',
559 + headers: { 'Content-Type': 'application/json' },
560 + body: JSON.stringify(payload),
561 + credentials: 'include'
562 + })
563 + .then(response => response.json())
564 + .then(data => {
565 + if (status) {
566 + status.textContent = data.message || i18nMessages.resendSuccess;
567 + status.classList.remove('hidden');
568 + }
569 + })
570 + .catch(() => {
571 + if (status) {
572 + status.textContent = i18nMessages.resendError;
573 + status.classList.remove('hidden');
574 + }
575 + });
576 + });
577 +
578 + // Language selector functionality (Feature 014 - T065)
579 + if (typeof window.changeLanguage === 'undefined') {
580 + window.changeLanguage = function(locale) {
581 + if (!['fr', 'en', 'de', 'es'].includes(locale)) return false;
582 + document.cookie = `locale=${locale}; path=/; max-age=31536000; SameSite=Lax`;
583 + window.location.reload();
584 + return false;
585 + };
586 + }
587 +
588 + // OAuth: load providers and display buttons
589 + (async function loadOAuthProviders() {
590 + try {
591 + const resp = await fetch('/api/v1/auth/oauth/providers');
592 + if (!resp.ok) return;
593 + const providers = await resp.json();
594 + if (!providers || providers.length === 0) return;
595 +
596 + const container = document.getElementById('oauth-providers-list');
597 + const wrapper = document.getElementById('oauth-buttons');
598 + wrapper.classList.remove('hidden');
599 +
600 + const icons = {
601 + google: '<svg class="w-5 h-5" viewBox="0 0 24 24"><path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>',
602 + github: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>',
603 + discord: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>',
604 + microsoft: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor"><path d="M11.4 24H0V12.6h11.4V24zM24 24H12.6V12.6H24V24zM11.4 11.4H0V0h11.4v11.4zM24 11.4H12.6V0H24v11.4z"/></svg>'
605 + };
606 +
607 + providers.forEach(function(p) {
608 + const btn = document.createElement('a');
609 + btn.href = '/api/v1/auth/oauth/' + p.name + '/redirect';
610 + btn.className = 'btn btn-outline w-full gap-2';
611 + btn.innerHTML = (icons[p.name] || '') + ' ' + p.display_name;
612 + container.appendChild(btn);
613 + });
614 + } catch (e) {
615 + // OAuth not available, no buttons shown
616 + }
617 + })();
618 + </script>
619 +</body>
620 +</html>
621 +
622 +
623 +
624 +
A templates_old/profile.html
+248 -0
@@ -0,0 +1,248 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block navbar %}
6 +{% include "components/navbar.html" %}
7 +{% endblock %}
8 +
9 +{% block content %}
10 +<div class="container mx-auto px-4 py-8">
11 + <!-- Breadcrumb -->
12 + <div class="text-sm breadcrumbs mb-6">
13 + <ul>
14 + <li><a href="/">{{ breadcrumb_home }}</a></li>
15 + <li><a href="/profile">{{ breadcrumb_settings }}</a></li>
16 + <li>{{ breadcrumb_profile }}</li>
17 + </ul>
18 + </div>
19 +
20 + <!-- Page Header -->
21 + <div class="mb-8">
22 + <h1 class="text-3xl font-bold mb-2">{{ title }}</h1>
23 + <p class="text-base-content/70">{{ subtitle }}</p>
24 + </div>
25 +
26 + <!-- Profile Form Card -->
27 + <div class="card bg-base-100 shadow-xl max-w-2xl">
28 + <div class="card-body">
29 + <h2 class="card-title mb-4">{{ account_info }}</h2>
30 +
31 + <form id="profileForm" method="POST" action="/profile" class="space-y-6">
32 + <!-- CSRF Token -->
33 + <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
34 +
35 + <!-- Username -->
36 + <div class="form-control">
37 + <label class="label">
38 + <span class="label-text font-semibold">{{ username_label }}</span>
39 + <span class="label-text-alt text-warning">{{ username_public }}</span>
40 + </label>
41 + <input
42 + type="text"
43 + name="username"
44 + value="{{ username }}"
45 + placeholder="{{ username_placeholder }}"
46 + class="input input-bordered w-full"
47 + minlength="3"
48 + maxlength="50"
49 + pattern="[a-zA-Z0-9_-]+"
50 + title="{{ username_pattern }}"
51 + required
52 + >
53 + <label class="label">
54 + <span class="label-text-alt">{{ username_help }}</span>
55 + </label>
56 + </div>
57 +
58 + <!-- Email -->
59 + <div class="form-control">
60 + <label class="label">
61 + <span class="label-text font-semibold">{{ email_label }}</span>
62 + <span class="label-text-alt text-info">{{ email_used_for }}</span>
63 + </label>
64 + <input
65 + type="email"
66 + name="email"
67 + value="{{ email }}"
68 + placeholder="{{ email_placeholder }}"
69 + class="input input-bordered w-full"
70 + required
71 + >
72 + <label class="label">
73 + <span class="label-text-alt">{{ email_warning }}</span>
74 + </label>
75 + </div>
76 +
77 + <!-- Alert Info -->
78 + <div class="alert alert-info">
79 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
80 + <span>{{ email_change_info }}</span>
81 + </div>
82 +
83 + <!-- Form Actions -->
84 + <div class="card-actions justify-end">
85 + <a href="/profile" class="btn btn-ghost">{{ cancel }}</a>
86 + <button type="submit" class="btn btn-primary">
87 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
88 + {{ save_button }}
89 + </button>
90 + </div>
91 + </form>
92 + </div>
93 + </div>
94 +
95 + <!-- Additional Info Card -->
96 + <div class="card bg-base-200 shadow-xl max-w-2xl mt-6">
97 + <div class="card-body">
98 + <h3 class="card-title text-base">Informations supplémentaires</h3>
99 + <div class="grid grid-cols-2 gap-4 text-sm">
100 + <div>
101 + <p class="text-base-content/70">{{ account_created }}</p>
102 + <p class="font-medium">{{ created_at }}</p>
103 + </div>
104 + <div>
105 + <p class="text-base-content/70">{{ roles_label }}</p>
106 + <p class="font-medium">{{ roles }}</p>
107 + </div>
108 + <div>
109 + <p class="text-base-content/70">ID Utilisateur</p>
110 + <p class="font-mono text-xs">{{ user_id }}</p>
111 + </div>
112 + </div>
113 + </div>
114 + </div>
115 +
116 + <!-- Linked OAuth Accounts -->
117 + <div id="oauth-accounts-card" class="card bg-base-100 shadow-xl max-w-2xl mt-6 hidden">
118 + <div class="card-body">
119 + <h3 class="card-title text-base">
120 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
121 + {{ linked_accounts_title }}
122 + </h3>
123 + <div id="oauth-accounts-list" class="space-y-3 mt-2"></div>
124 + <p id="oauth-no-accounts" class="text-sm text-base-content/60 hidden">{{ no_linked_accounts }}</p>
125 + </div>
126 + </div>
127 +
128 + <!-- Quick Links -->
129 + <div class="mt-8 flex gap-4 flex-wrap">
130 + <a href="/settings/password" class="btn btn-outline btn-secondary">
131 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
132 + Changer mon mot de passe
133 + </a>
134 + <a href="/settings/sessions" class="btn btn-outline">
135 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
136 + Gérer mes sessions
137 + </a>
138 + <a href="/settings/preferences" class="btn btn-outline">
139 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" /></svg>
140 + Préférences d'affichage
141 + </a>
142 + </div>
143 +</div>
144 +
145 +<script>
146 +// OAuth linked accounts
147 +(async function loadOAuthAccounts() {
148 + try {
149 + const resp = await fetch('/api/v1/auth/oauth/accounts');
150 + if (!resp.ok) return;
151 + const accounts = await resp.json();
152 +
153 + const card = document.getElementById('oauth-accounts-card');
154 + const list = document.getElementById('oauth-accounts-list');
155 + const noMsg = document.getElementById('oauth-no-accounts');
156 + card.classList.remove('hidden');
157 +
158 + if (!accounts || accounts.length === 0) {
159 + noMsg.classList.remove('hidden');
160 + return;
161 + }
162 +
163 + accounts.forEach(function(acct) {
164 + const row = document.createElement('div');
165 + row.className = 'flex items-center justify-between p-3 bg-base-200 rounded-lg';
166 + row.innerHTML =
167 + '<div>' +
168 + '<span class="font-semibold capitalize">' + acct.provider + '</span>' +
169 + (acct.email ? ' <span class="text-sm text-base-content/60">(' + acct.email + ')</span>' : '') +
170 + '<p class="text-xs text-base-content/50">{{ linked_since }} ' + acct.created_at + '</p>' +
171 + '</div>' +
172 + '<button class="btn btn-ghost btn-sm btn-error" data-id="' + acct.id + '">{{ unlink_button }}</button>';
173 + list.appendChild(row);
174 + });
175 +
176 + list.addEventListener('click', async function(e) {
177 + const btn = e.target.closest('button[data-id]');
178 + if (!btn) return;
179 + if (!confirm('{{ unlink_confirm }}')) return;
180 + btn.disabled = true;
181 + try {
182 + const dr = await fetch('/api/v1/auth/oauth/accounts/' + btn.dataset.id, { method: 'DELETE' });
183 + if (dr.ok) {
184 + btn.closest('.flex').remove();
185 + if (list.children.length === 0) noMsg.classList.remove('hidden');
186 + } else {
187 + const err = await dr.json().catch(function() { return {}; });
188 + alert(err.message || '{{ unlink_error }}');
189 + btn.disabled = false;
190 + }
191 + } catch (_) {
192 + alert('{{ unlink_error }}');
193 + btn.disabled = false;
194 + }
195 + });
196 + } catch (_) {
197 + // OAuth feature not enabled, card stays hidden
198 + }
199 +})();
200 +
201 +// Form submission handling
202 +document.getElementById('profileForm').addEventListener('submit', async function(e) {
203 + e.preventDefault();
204 +
205 + const formData = new FormData(this);
206 + const submitBtn = this.querySelector('button[type="submit"]');
207 +
208 + // Disable submit button
209 + submitBtn.disabled = true;
210 + submitBtn.innerHTML = '<span class="loading loading-spinner"></span> Enregistrement...';
211 +
212 + try {
213 + const response = await fetch('/profile', {
214 + method: 'PUT',
215 + headers: {
216 + 'Content-Type': 'application/x-www-form-urlencoded',
217 + },
218 + body: new URLSearchParams(formData)
219 + });
220 +
221 + const result = await response.json();
222 +
223 + if (response.ok) {
224 + // Show success message
225 + alert('✅ Profil mis à jour avec succès !');
226 +
227 + // Reload if email changed (will redirect to login)
228 + const originalEmail = '{{ email }}';
229 + if (formData.get('email') !== originalEmail) {
230 + alert('⚠️ Votre email a été modifié. Vous allez être déconnecté.');
231 + window.location.href = '/login';
232 + } else {
233 + window.location.reload();
234 + }
235 + } else {
236 + alert('❌ Erreur : ' + (result.error || result.message || 'Échec de la mise à jour'));
237 + submitBtn.disabled = false;
238 + submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> Enregistrer les modifications';
239 + }
240 + } catch (error) {
241 + console.error('Error:', error);
242 + alert('❌ Erreur réseau. Veuillez réessayer.');
243 + submitBtn.disabled = false;
244 + submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> Enregistrer les modifications';
245 + }
246 +});
247 +</script>
248 +{% endblock %}
A templates_old/resend_verification.html
+111 -0
@@ -0,0 +1,111 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block content %}
6 +<div class="min-h-screen flex items-center justify-center bg-base-200 px-4">
7 + <div class="max-w-lg w-full space-y-6">
8 + <div class="text-center">
9 + <h1 class="text-3xl font-bold text-base-content mb-2">{{ title }}</h1>
10 + <p class="text-base-content/70">
11 + {{ subtitle }}
12 + </p>
13 + </div>
14 +
15 + <div id="alert-error" class="hidden alert alert-error">
16 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
17 + <span id="alert-error-text"></span>
18 + </div>
19 +
20 + <div id="alert-success" class="hidden alert alert-success">
21 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
22 + <span id="alert-success-text"></span>
23 + </div>
24 +
25 + <div class="card bg-base-100 shadow-xl p-8">
26 + <form id="resend-form" class="space-y-4">
27 + <div class="form-control">
28 + <label for="email" class="label">
29 + <span class="label-text">{{ email_label }}</span>
30 + </label>
31 + <input
32 + type="email"
33 + id="email"
34 + name="email"
35 + required
36 + autocomplete="email"
37 + class="input input-bordered w-full"
38 + placeholder="{{ email_placeholder }}">
39 + </div>
40 +
41 + <div class="form-control mt-6">
42 + <button type="submit" class="btn btn-primary w-full">
43 + {{ submit }}
44 + </button>
45 + </div>
46 + </form>
47 +
48 + <div class="mt-4 text-sm text-base-content/70 text-center space-y-2">
49 + <a href="/login" class="text-primary hover:underline">{{ back_to_login }}</a>
50 + </div>
51 + </div>
52 + </div>
53 +</div>
54 +
55 +<script>
56 + const MSG_ERROR_DEFAULT = "{{ error_default }}";
57 + const MSG_SUCCESS_DEFAULT = "{{ success_default }}";
58 + const MSG_ERROR_MISSING_EMAIL = "{{ error_missing_email }}";
59 + const MSG_ERROR_CANNOT_SEND = "{{ error_cannot_send }}";
60 +
61 + const form = document.getElementById('resend-form');
62 + const alertError = document.getElementById('alert-error');
63 + const alertErrorText = document.getElementById('alert-error-text');
64 + const alertSuccess = document.getElementById('alert-success');
65 + const alertSuccessText = document.getElementById('alert-success-text');
66 +
67 + function showError(msg) {
68 + if (alertError && alertErrorText) {
69 + alertErrorText.textContent = msg || MSG_ERROR_DEFAULT;
70 + alertError.classList.remove('hidden');
71 + }
72 + if (alertSuccess) alertSuccess.classList.add('hidden');
73 + }
74 +
75 + function showSuccess(msg) {
76 + if (alertSuccess && alertSuccessText) {
77 + alertSuccessText.textContent = msg || MSG_SUCCESS_DEFAULT;
78 + alertSuccess.classList.remove('hidden');
79 + }
80 + if (alertError) alertError.classList.add('hidden');
81 + }
82 +
83 + form?.addEventListener('submit', (e) => {
84 + e.preventDefault();
85 + const email = document.getElementById('email')?.value;
86 + if (!email) {
87 + showError(MSG_ERROR_MISSING_EMAIL);
88 + return;
89 + }
90 + fetch('/api/v1/auth/resend-verification', {
91 + method: 'POST',
92 + headers: { 'Content-Type': 'application/json' },
93 + body: JSON.stringify({ email }),
94 + credentials: 'include'
95 + })
96 + .then(async (resp) => {
97 + let msg = '';
98 + try {
99 + const data = await resp.json();
100 + msg = data.message || data.error || msg;
101 + } catch (_) {}
102 + if (resp.ok) {
103 + showSuccess(msg || MSG_SUCCESS_DEFAULT);
104 + } else {
105 + showError(msg || MSG_ERROR_CANNOT_SEND);
106 + }
107 + })
108 + .catch(() => showError(MSG_ERROR_CANNOT_SEND));
109 + });
110 +</script>
111 +{% endblock %}
A templates_old/reset_password.html
+164 -0
@@ -0,0 +1,164 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }} - {{ app_name }}{% endblock %}
4 +
5 +{% block content %}
6 +<div class="min-h-screen flex items-center justify-center bg-base-200 px-4">
7 + <div class="max-w-lg w-full space-y-6">
8 + <div class="text-center">
9 + <div class="inline-flex items-center justify-center w-16 h-16 mb-4">
10 + <img src="/static/images/logo.svg" alt="{{ app_name }} Logo" class="h-16 w-16">
11 + </div>
12 + <h1 class="text-3xl font-bold text-base-content mb-2">{{ reset_password_title }}</h1>
13 + <p class="text-base-content/70">
14 + {{ subtitle }}
15 + </p>
16 + </div>
17 +
18 + <div id="alert-error" class="hidden alert alert-error">
19 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
20 + <span id="alert-error-text"></span>
21 + </div>
22 +
23 + <div id="alert-success" class="hidden alert alert-success">
24 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
25 + <span id="alert-success-text"></span>
26 + </div>
27 +
28 + <div class="card bg-base-100 shadow-xl p-8">
29 + <form id="reset-form" class="space-y-4">
30 + <input type="hidden" id="token" name="token" value="">
31 +
32 + <div class="form-control">
33 + <label for="new_password" class="label">
34 + <span class="label-text">{{ new_password_label }}</span>
35 + </label>
36 + <input
37 + type="password"
38 + id="new_password"
39 + name="new_password"
40 + required
41 + autocomplete="new-password"
42 + class="input input-bordered w-full"
43 + placeholder="{{ new_password_placeholder }}">
44 + <label class="label">
45 + <span class="label-text-alt text-base-content/70">{{ password_requirements }}</span>
46 + </label>
47 + </div>
48 +
49 + <div class="form-control">
50 + <label for="confirm_password" class="label">
51 + <span class="label-text">{{ confirm_password_label }}</span>
52 + </label>
53 + <input
54 + type="password"
55 + id="confirm_password"
56 + name="confirm_password"
57 + required
58 + autocomplete="new-password"
59 + class="input input-bordered w-full"
60 + placeholder="{{ confirm_password_placeholder }}">
61 + </div>
62 +
63 + <div class="form-control mt-6">
64 + <button type="submit" class="btn btn-primary w-full">
65 + {{ submit_button }}
66 + </button>
67 + </div>
68 + </form>
69 +
70 + <div class="mt-4 text-sm text-base-content/70 text-center space-y-2">
71 + <a href="/login" class="text-primary hover:underline">{{ back_to_login }}</a>
72 + </div>
73 + </div>
74 + </div>
75 +</div>
76 +
77 +<script>
78 + const MSG_MISSING_TOKEN_URL = "{{ missing_token_url }}";
79 + const MSG_MISSING_TOKEN = "{{ missing_token }}";
80 + const MSG_MISSING_NEW_PASSWORD = "{{ missing_new_password }}";
81 + const MSG_PASSWORDS_NOT_MATCH = "{{ passwords_not_match }}";
82 + const MSG_RESET_SUCCESS = "{{ reset_success_message }}";
83 + const MSG_ERROR_LINK_INVALID = "{{ error_link_invalid }}";
84 + const MSG_ERROR_GENERIC = "{{ error_generic }}";
85 + const MSG_ERROR_DEFAULT = "{{ error_message }}";
86 + const MSG_SUCCESS_DEFAULT = "{{ success_message }}";
87 +
88 + const urlParams = new URLSearchParams(window.location.search);
89 + const token = urlParams.get('token');
90 +
91 + if (token) {
92 + document.getElementById('token').value = token;
93 + } else {
94 + showError(MSG_MISSING_TOKEN_URL);
95 + }
96 +
97 + const form = document.getElementById('reset-form');
98 + const alertError = document.getElementById('alert-error');
99 + const alertErrorText = document.getElementById('alert-error-text');
100 + const alertSuccess = document.getElementById('alert-success');
101 + const alertSuccessText = document.getElementById('alert-success-text');
102 +
103 + function showError(msg) {
104 + if (alertError && alertErrorText) {
105 + alertErrorText.textContent = msg || MSG_ERROR_DEFAULT;
106 + alertError.classList.remove('hidden');
107 + }
108 + if (alertSuccess) alertSuccess.classList.add('hidden');
109 + }
110 +
111 + function showSuccess(msg) {
112 + if (alertSuccess && alertSuccessText) {
113 + alertSuccessText.textContent = msg || MSG_SUCCESS_DEFAULT;
114 + alertSuccess.classList.remove('hidden');
115 + }
116 + if (alertError) alertError.classList.add('hidden');
117 + }
118 +
119 + form?.addEventListener('submit', (e) => {
120 + e.preventDefault();
121 + const token = document.getElementById('token')?.value;
122 + const newPassword = document.getElementById('new_password')?.value;
123 + const confirmPassword = document.getElementById('confirm_password')?.value;
124 +
125 + if (!token) {
126 + showError(MSG_MISSING_TOKEN);
127 + return;
128 + }
129 +
130 + if (!newPassword) {
131 + showError(MSG_MISSING_NEW_PASSWORD);
132 + return;
133 + }
134 +
135 + if (newPassword !== confirmPassword) {
136 + showError(MSG_PASSWORDS_NOT_MATCH);
137 + return;
138 + }
139 +
140 + fetch('/api/v1/auth/reset-password', {
141 + method: 'POST',
142 + headers: { 'Content-Type': 'application/json' },
143 + body: JSON.stringify({ token, new_password: newPassword }),
144 + credentials: 'include'
145 + })
146 + .then(async (resp) => {
147 + let msg = '';
148 + try {
149 + const data = await resp.json();
150 + msg = data.message || data.error || msg;
151 + } catch (_) {}
152 + if (resp.ok) {
153 + showSuccess(msg || MSG_RESET_SUCCESS);
154 + setTimeout(() => {
155 + window.location.href = '/login';
156 + }, 3000);
157 + } else {
158 + showError(msg || MSG_ERROR_LINK_INVALID);
159 + }
160 + })
161 + .catch(() => showError(MSG_ERROR_GENERIC));
162 + });
163 +</script>
164 +{% endblock %}
A templates_old/settings/password.html
+504 -0
@@ -0,0 +1,504 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block navbar %}
6 +{% if !must_change %}
7 +{% include "components/navbar.html" %}
8 +{% endif %}
9 +{% endblock %}
10 +
11 +{% block content %}
12 +{% if must_change %}
13 +<div class="min-h-screen flex items-center justify-center bg-base-200 px-4 py-12">
14 + <div class="w-full max-w-lg">
15 + <div class="text-center mb-8">
16 + <div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-warning/20 mb-4">
17 + <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
18 + </div>
19 + <h1 class="text-3xl font-bold mb-2">{{ title }}</h1>
20 + <p class="text-base-content/70 text-lg">{{ must_change_message }}</p>
21 + </div>
22 +{% else %}
23 +<div class="container mx-auto px-4 py-8">
24 + <div class="text-sm breadcrumbs mb-6">
25 + <ul>
26 + <li><a href="/">{{ breadcrumb_home }}</a></li>
27 + <li><a href="/profile">{{ breadcrumb_settings }}</a></li>
28 + <li>{{ breadcrumb_password }}</li>
29 + </ul>
30 + </div>
31 +
32 + <div class="mb-8">
33 + <h1 class="text-3xl font-bold mb-2">{{ title }}</h1>
34 + {% if password_change_require_email %}
35 + <p class="text-base-content/70">{{ subtitle }}</p>
36 + {% else %}
37 + <p class="text-base-content/70">{{ subtitle_no_email }}</p>
38 + {% endif %}
39 + </div>
40 +{% endif %}
41 +
42 + <!-- Feature 026: Pending password change request alert -->
43 + {% if has_pending_request %}
44 + <div class="alert alert-info mb-6 max-w-2xl">
45 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
46 + <div class="flex-1">
47 + <h3 class="font-bold">{{ pending_request_message }}</h3>
48 + <div class="text-sm">{{ pending_request_info }}</div>
49 + </div>
50 + <div>
51 + <button
52 + id="cancelPendingRequestBtn"
53 + class="btn btn-sm btn-ghost"
54 + onclick="cancelPendingRequest()"
55 + >
56 + {{ pending_request_cancel }}
57 + </button>
58 + </div>
59 + </div>
60 + {% endif %}
61 +
62 + <!-- Password Change Form Card -->
63 + <div class="card bg-base-100 shadow-xl max-w-2xl">
64 + <div class="card-body">
65 + <h2 class="card-title mb-4">
66 + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
67 + {{ new_password_label }}
68 + </h2>
69 +
70 + <form id="passwordForm" method="POST" action="/settings/password" class="space-y-6">
71 + <!-- CSRF Token -->
72 + <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
73 +
74 + <!-- Old Password -->
75 + <div class="form-control">
76 + <label class="label">
77 + <span class="label-text font-semibold">{{ current_password_label }}</span>
78 + <span class="label-text-alt text-error">{{ required }}</span>
79 + </label>
80 + <input
81 + type="password"
82 + name="old_password"
83 + id="oldPassword"
84 + placeholder="{{ current_password_placeholder }}"
85 + class="input input-bordered w-full"
86 + required
87 + autocomplete="current-password"
88 + >
89 + </div>
90 +
91 + <!-- New Password -->
92 + <div class="form-control">
93 + <label class="label">
94 + <span class="label-text font-semibold">{{ new_password_label }}</span>
95 + <span class="label-text-alt text-error">{{ required }}</span>
96 + </label>
97 + <input
98 + type="password"
99 + name="new_password"
100 + id="newPassword"
101 + placeholder="{{ new_password_placeholder }}"
102 + class="input input-bordered w-full"
103 + required
104 + minlength="{{ pw_min_length }}"
105 + autocomplete="new-password"
106 + >
107 + <label class="label">
108 + <span class="label-text-alt text-base-content/70">
109 + {{ requirements }}
110 + </span>
111 + </label>
112 + <!-- Password Strength Indicator -->
113 + <div class="mt-2">
114 + <div id="strengthBar" class="hidden">
115 + <label class="label">
116 + <span class="label-text-alt">{{ strength_label }}</span>
117 + <span id="strengthText" class="label-text-alt font-semibold"></span>
118 + </label>
119 + <progress id="strengthProgress" class="progress w-full" value="0" max="100"></progress>
120 + </div>
121 + </div>
122 + </div>
123 +
124 + <!-- Confirm Password -->
125 + <div class="form-control">
126 + <label class="label">
127 + <span class="label-text font-semibold">{{ confirm_password_label }}</span>
128 + <span class="label-text-alt text-error">{{ required }}</span>
129 + </label>
130 + <input
131 + type="password"
132 + name="confirm_password"
133 + id="confirmPassword"
134 + placeholder="{{ confirm_password_placeholder }}"
135 + class="input input-bordered w-full"
136 + required
137 + minlength="{{ pw_min_length }}"
138 + autocomplete="new-password"
139 + >
140 + <label class="label">
141 + <span id="matchIndicator" class="label-text-alt text-base-content/70"></span>
142 + </label>
143 + </div>
144 +
145 + <!-- Security Alert -->
146 + <div class="alert alert-warning">
147 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
148 + <div>
149 + <h3 class="font-bold">{{ logout_warning_title }}</h3>
150 + <div class="text-xs">{{ logout_warning_desc }}</div>
151 + </div>
152 + </div>
153 +
154 + <!-- Form Actions -->
155 + <div class="card-actions justify-end">
156 + {% if must_change %}
157 + <a href="/api/v1/auth/logout" class="btn btn-ghost"
158 + onclick="event.preventDefault(); fetch('/api/v1/auth/logout', {method:'POST', credentials:'include', headers:{'Accept':'text/html'}}).then(()=>window.location.href='/login');">
159 + {{ logout_label }}
160 + </a>
161 + {% else %}
162 + <a href="/profile" class="btn btn-ghost">{{ cancel }}</a>
163 + {% endif %}
164 + <button type="submit" class="btn btn-error" id="submitBtn" disabled>
165 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
166 + {{ change_button }}
167 + </button>
168 + </div>
169 + </form>
170 + </div>
171 + </div>
172 +
173 + {% if must_change %}
174 + </div>
175 +</div>
176 + {% endif %}
177 +
178 + <!-- Security Tips Card -->
179 + {% if !must_change %}
180 + <div class="card bg-base-200 shadow-xl max-w-2xl mt-6">
181 + <div class="card-body">
182 + <h3 class="card-title text-base">
183 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
184 + {{ security_tips_title }}
185 + </h3>
186 + <ul class="list-disc list-inside text-sm space-y-2 text-base-content/80">
187 + <li>{{ security_tip_unique }}</li>
188 + <li>{{ security_tip_share }}</li>
189 + <li>{{ security_tip_regular }}</li>
190 + <li>{{ security_tip_2fa }}</li>
191 + <li>{{ security_tip_manager }}</li>
192 + </ul>
193 + </div>
194 + </div>
195 + {% endif %}
196 +
197 +{% if !must_change %}
198 +</div>
199 +{% endif %}
200 +
201 +<script>
202 +const newPasswordInput = document.getElementById('newPassword');
203 +const confirmPasswordInput = document.getElementById('confirmPassword');
204 +const submitBtn = document.getElementById('submitBtn');
205 +const strengthBar = document.getElementById('strengthBar');
206 +const strengthProgress = document.getElementById('strengthProgress');
207 +const strengthText = document.getElementById('strengthText');
208 +const matchIndicator = document.getElementById('matchIndicator');
209 +
210 +// Password rules from app_settings (injected by server)
211 +const PW_MIN_LENGTH = {{ pw_min_length }};
212 +const PW_REQ_UPPER = {{ pw_require_uppercase }};
213 +const PW_REQ_LOWER = {{ pw_require_lowercase }};
214 +const PW_REQ_DIGITS = {{ pw_require_digits }};
215 +const PW_REQ_SPECIAL= {{ pw_require_special }};
216 +
217 +function checkPasswordStrength(password) {
218 + let strength = 0;
219 + let feedback = [];
220 + const totalChecks = 1
221 + + (PW_REQ_UPPER ? 1 : 0)
222 + + (PW_REQ_LOWER ? 1 : 0)
223 + + (PW_REQ_DIGITS ? 1 : 0)
224 + + (PW_REQ_SPECIAL? 1 : 0);
225 + const pointsPerCheck = Math.floor(100 / (totalChecks + 1));
226 +
227 + if (password.length >= PW_MIN_LENGTH) strength += pointsPerCheck;
228 + if (password.length >= PW_MIN_LENGTH + 4) strength += pointsPerCheck;
229 + if (password.length < PW_MIN_LENGTH) feedback.push('{{ strength_too_short }}');
230 +
231 + if (PW_REQ_LOWER) {
232 + if (/[a-z]/.test(password)) strength += pointsPerCheck; else feedback.push('{{ strength_missing_lowercase }}');
233 + }
234 + if (PW_REQ_UPPER) {
235 + if (/[A-Z]/.test(password)) strength += pointsPerCheck; else feedback.push('{{ strength_missing_uppercase }}');
236 + }
237 + if (PW_REQ_DIGITS) {
238 + if (/[0-9]/.test(password)) strength += pointsPerCheck; else feedback.push('{{ strength_missing_digit }}');
239 + }
240 + if (PW_REQ_SPECIAL) {
241 + if (/[^a-zA-Z0-9]/.test(password)) strength += pointsPerCheck; else feedback.push('{{ strength_missing_special }}');
242 + }
243 +
244 + if (!PW_REQ_LOWER && /[a-z]/.test(password)) strength += 5;
245 + if (!PW_REQ_UPPER && /[A-Z]/.test(password)) strength += 5;
246 + if (!PW_REQ_DIGITS && /[0-9]/.test(password)) strength += 5;
247 + if (!PW_REQ_SPECIAL&& /[^a-zA-Z0-9]/.test(password)) strength += 5;
248 +
249 + strength = Math.min(strength, 100);
250 + return { strength, feedback };
251 +}
252 +
253 +// Update password strength indicator
254 +newPasswordInput.addEventListener('input', function() {
255 + const password = this.value;
256 +
257 + if (password.length > 0) {
258 + strengthBar.classList.remove('hidden');
259 + const { strength, feedback } = checkPasswordStrength(password);
260 +
261 + strengthProgress.value = strength;
262 +
263 + if (strength < 40) {
264 + strengthProgress.className = 'progress progress-error w-full';
265 + strengthText.textContent = '{{ strength_weak }} (' + feedback.join(', ') + ')';
266 + strengthText.className = 'label-text-alt font-semibold text-error';
267 + } else if (strength < 70) {
268 + strengthProgress.className = 'progress progress-warning w-full';
269 + strengthText.textContent = '{{ strength_medium }}' + (feedback.length ? ' (' + feedback.join(', ') + ')' : '');
270 + strengthText.className = 'label-text-alt font-semibold text-warning';
271 + } else {
272 + strengthProgress.className = 'progress progress-success w-full';
273 + strengthText.textContent = '{{ strength_strong }}';
274 + strengthText.className = 'label-text-alt font-semibold text-success';
275 + }
276 + } else {
277 + strengthBar.classList.add('hidden');
278 + }
279 +
280 + checkFormValidity();
281 +});
282 +
283 +// Check password match
284 +confirmPasswordInput.addEventListener('input', function() {
285 + if (this.value.length > 0) {
286 + if (this.value === newPasswordInput.value) {
287 + matchIndicator.textContent = '{{ passwords_match }}';
288 + matchIndicator.className = 'label-text-alt text-success font-semibold';
289 + } else {
290 + matchIndicator.textContent = '{{ passwords_not_match }}';
291 + matchIndicator.className = 'label-text-alt text-error font-semibold';
292 + }
293 + } else {
294 + matchIndicator.textContent = '';
295 + }
296 +
297 + checkFormValidity();
298 +});
299 +
300 +// Enable submit button only if form is valid
301 +function checkFormValidity() {
302 + const oldPassword = document.getElementById('oldPassword').value;
303 + const newPassword = newPasswordInput.value;
304 + const confirmPassword = confirmPasswordInput.value;
305 +
306 + const { strength, feedback } = checkPasswordStrength(newPassword);
307 + const isValid = oldPassword.length > 0 &&
308 + newPassword.length >= PW_MIN_LENGTH &&
309 + confirmPassword === newPassword &&
310 + feedback.length === 0;
311 +
312 + submitBtn.disabled = !isValid;
313 +}
314 +
315 +// Form submission
316 +document.getElementById('passwordForm').addEventListener('submit', async function(e) {
317 + e.preventDefault();
318 +
319 + const formData = new FormData(this);
320 + submitBtn.disabled = true;
321 + submitBtn.innerHTML = '<span class="loading loading-spinner"></span> {{ changing }}';
322 +
323 + try {
324 + // Convert FormData to JSON object
325 + const oldPassword = formData.get('old_password');
326 + const newPassword = formData.get('new_password');
327 + const confirmPassword = formData.get('confirm_password');
328 +
329 + // Validate that all fields are present
330 + if (!oldPassword || !newPassword || !confirmPassword) {
331 + alert('❌ {{ error_all_fields_required }}');
332 + submitBtn.disabled = false;
333 + submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> {{ change_button }}';
334 + return;
335 + }
336 +
337 + const jsonData = {
338 + old_password: oldPassword.toString(),
339 + new_password: newPassword.toString(),
340 + confirm_password: confirmPassword.toString()
341 + };
342 +
343 + const jsonBody = JSON.stringify(jsonData);
344 +
345 + // Validate JSON is not empty
346 + if (!jsonBody || jsonBody === '{}' || jsonBody.length === 0) {
347 + alert('❌ {{ error_empty_data }}');
348 + submitBtn.disabled = false;
349 + submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> {{ change_button }}';
350 + return;
351 + }
352 +
353 + console.log('Sending password change request:', {
354 + method: 'PUT',
355 + url: '/settings/password',
356 + headers: { 'Content-Type': 'application/json' },
357 + body: jsonBody,
358 + bodyLength: jsonBody.length
359 + });
360 +
361 + const response = await fetch('/settings/password', {
362 + method: 'PUT',
363 + headers: {
364 + 'Content-Type': 'application/json',
365 + 'Accept': 'application/json'
366 + },
367 + credentials: 'include', // Important: inclure les cookies (JWT token)
368 + body: jsonBody
369 + });
370 +
371 + console.log('Response status:', response.status);
372 + console.log('Response headers:', Object.fromEntries(response.headers.entries()));
373 +
374 + // Clone response to read it multiple times if needed
375 + const responseClone = response.clone();
376 +
377 + let result = {};
378 + let errorText = '';
379 +
380 + // Try to parse as JSON first
381 + try {
382 + const contentType = response.headers.get('content-type') || '';
383 + if (contentType.includes('application/json')) {
384 + result = await response.json();
385 + } else {
386 + // Not JSON, read as text
387 + errorText = await responseClone.text();
388 + console.error('Non-JSON response (status ' + response.status + '):', errorText);
389 + }
390 + } catch (e) {
391 + // Failed to parse JSON, try text
392 + try {
393 + errorText = await responseClone.text();
394 + console.error('Failed to parse response (status ' + response.status + '):', errorText);
395 + } catch (e2) {
396 + console.error('Failed to read response:', e2);
397 + errorText = 'Erreur lors de la lecture de la réponse du serveur';
398 + }
399 + }
400 +
401 + const savedEmailMsg = "{{ saved }}";
402 + const savedDirectMsg = "{{ saved_direct }}";
403 + const errorAllFields = "{{ error_all_fields_required }}";
404 + const errorEmpty = "{{ error_empty_data }}";
405 + const errorInvalid = "{{ error_invalid_data }}";
406 + const errorIncorrect = "{{ error_incorrect_current }}";
407 + const error415 = "{{ error_415 }}";
408 + const networkErr = "{{ network_error }}";
409 + const changeBtnText = "{{ change_button }}";
410 +
411 + if (response.ok) {
412 + const msgKey = result && result.message;
413 + if (msgKey === 'email_confirmation_sent') {
414 + alert('✅ ' + savedEmailMsg);
415 + } else {
416 + alert('✅ ' + savedDirectMsg);
417 + }
418 + window.location.href = '/settings';
419 + } else {
420 + let errorMessage = 'Échec du changement de mot de passe';
421 +
422 + if (response.status === 415) {
423 + errorMessage = error415;
424 + console.error('415 Error details:', errorText || 'No error text available');
425 + } else if (result && result.error) {
426 + errorMessage = result.error;
427 + } else if (result && result.message) {
428 + errorMessage = result.message;
429 + } else if (errorText) {
430 + errorMessage = errorText;
431 + } else if (response.status === 400) {
432 + errorMessage = errorInvalid;
433 + } else if (response.status === 401) {
434 + errorMessage = errorIncorrect;
435 + }
436 +
437 + alert('❌ ' + errorMessage);
438 + submitBtn.disabled = false;
439 + submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> ' + changeBtnText;
440 + }
441 + } catch (error) {
442 + console.error('Error:', error);
443 + alert('❌ ' + networkErr);
444 + submitBtn.disabled = false;
445 + submitBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> ' + changeBtnText;
446 + }
447 +});
448 +
449 +// Initialize form check
450 +document.getElementById('oldPassword').addEventListener('input', checkFormValidity);
451 +
452 +// Feature 026: Cancel pending password change request
453 +async function cancelPendingRequest() {
454 + const cancelBtn = document.getElementById('cancelPendingRequestBtn');
455 + if (!cancelBtn) return;
456 +
457 + if (!confirm('{{ pending_request_cancel }} ?')) {
458 + return;
459 + }
460 +
461 + cancelBtn.disabled = true;
462 + cancelBtn.innerHTML = '<span class="loading loading-spinner"></span>';
463 +
464 + try {
465 + const response = await fetch('/settings/password/pending', {
466 + method: 'DELETE',
467 + headers: {
468 + 'Content-Type': 'application/json',
469 + },
470 + credentials: 'same-origin'
471 + });
472 +
473 + if (response.ok) {
474 + // Reload page to update UI
475 + window.location.reload();
476 + } else {
477 + const result = await response.json().catch(() => ({}));
478 + const errorMessage = result.message || result.error || '{{ network_error }}';
479 + alert('❌ ' + errorMessage);
480 + cancelBtn.disabled = false;
481 + cancelBtn.innerHTML = '{{ pending_request_cancel }}';
482 + }
483 + } catch (error) {
484 + console.error('Error canceling pending request:', error);
485 + alert('❌ {{ network_error }}');
486 + cancelBtn.disabled = false;
487 + cancelBtn.innerHTML = '{{ pending_request_cancel }}';
488 + }
489 +}
490 +</script>
491 +{% endblock %}
492 +
493 +{% block footer %}
494 +{% if !must_change %}
495 +<footer class="bg-base-200 border-t border-base-300 py-4 mt-8">
496 + <div class="container mx-auto px-4 text-center text-base-content text-sm">
497 + <p>{{ app_name }} {{ app_version }}{% if app_domain != "" %} - {{ app_domain }}{% endif %}</p>
498 + {% if app_description != "" %}
499 + <p class="text-base-content/70 mt-1">{{ app_description }}</p>
500 + {% endif %}
501 + </div>
502 +</footer>
503 +{% endif %}
504 +{% endblock %}
A templates_old/settings/preferences.html
+366 -0
@@ -0,0 +1,366 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block navbar %}
6 +{% include "components/navbar.html" %}
7 +{% endblock %}
8 +
9 +{% block content %}
10 +<div class="container mx-auto px-4 py-8">
11 + <!-- Quick Language Selector (Feature 014) -->
12 + <div class="flex justify-end mb-4">
13 + <div class="dropdown dropdown-end">
14 + <label tabindex="0" class="btn btn-ghost btn-sm gap-2">
15 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
16 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
17 + </svg>
18 + <span id="current-lang-display">
19 + {% for (code, name, is_current) in supported_languages %}
20 + {% if is_current %}{{ name }}{% endif %}
21 + {% endfor %}
22 + </span>
23 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
24 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
25 + </svg>
26 + </label>
27 + <ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow">
28 + {% for (code, name, is_current) in supported_languages %}
29 + <li>
30 + <a href="#" onclick="return window.changeLanguage('{{ code }}');" class="{% if is_current %}active{% endif %}">
31 + {{ name }}
32 + {% if is_current %}
33 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
34 + <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
35 + </svg>
36 + {% endif %}
37 + </a>
38 + </li>
39 + {% endfor %}
40 + </ul>
41 + </div>
42 + </div>
43 +
44 + <!-- Breadcrumb -->
45 + <div class="text-sm breadcrumbs mb-6">
46 + <ul>
47 + <li><a href="/">{{ breadcrumb_home }}</a></li>
48 + <li><a href="/profile">{{ breadcrumb_settings }}</a></li>
49 + <li>{{ breadcrumb_preferences }}</li>
50 + </ul>
51 + </div>
52 +
53 + <!-- Page Header -->
54 + <div class="mb-8">
55 + <h1 class="text-3xl font-bold mb-2">{{ title }}</h1>
56 + <p class="text-base-content/70">{{ subtitle }}</p>
57 + </div>
58 +
59 + <!-- Preferences Form -->
60 + <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 max-w-6xl">
61 + <!-- Theme Preferences Card -->
62 + <div class="card bg-base-100 shadow-xl">
63 + <div class="card-body">
64 + <h2 class="card-title mb-4">
65 + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" /></svg>
66 + {{ theme_label }}
67 + </h2>
68 +
69 + <form id="preferencesForm" class="space-y-4">
70 + <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
71 +
72 + <!-- Theme Options -->
73 + <div class="form-control">
74 + <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors">
75 + <input
76 + type="radio"
77 + name="theme"
78 + value="light"
79 + class="radio radio-primary"
80 + {% if is_theme_light %}checked{% endif %}
81 + >
82 + <div class="flex-1">
83 + <div class="flex items-center gap-2">
84 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
85 + <span class="label-text font-semibold">{{ theme_light }}</span>
86 + </div>
87 + <span class="label-text-alt text-base-content/70">{{ theme_light_desc }}</span>
88 + </div>
89 + </label>
90 + </div>
91 +
92 + <div class="form-control">
93 + <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors">
94 + <input
95 + type="radio"
96 + name="theme"
97 + value="dark"
98 + class="radio radio-primary"
99 + {% if is_theme_dark %}checked{% endif %}
100 + >
101 + <div class="flex-1">
102 + <div class="flex items-center gap-2">
103 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
104 + <span class="label-text font-semibold">{{ theme_dark }}</span>
105 + </div>
106 + <span class="label-text-alt text-base-content/70">{{ theme_dark_desc }}</span>
107 + </div>
108 + </label>
109 + </div>
110 +
111 + <div class="form-control">
112 + <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors">
113 + <input
114 + type="radio"
115 + name="theme"
116 + value="auto"
117 + class="radio radio-primary"
118 + {% if is_theme_auto %}checked{% endif %}
119 + >
120 + <div class="flex-1">
121 + <div class="flex items-center gap-2">
122 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" /></svg>
123 + <span class="label-text font-semibold">{{ theme_auto }}</span>
124 + </div>
125 + <span class="label-text-alt text-base-content/70">{{ theme_auto_desc }}</span>
126 + </div>
127 + </label>
128 + </div>
129 +
130 + <!-- Theme Preview -->
131 + <div class="alert" id="themePreview">
132 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
133 + <span>{{ theme_preview }}</span>
134 + </div>
135 + </form>
136 + </div>
137 + </div>
138 +
139 + <!-- Language Preferences Card -->
140 + <div class="card bg-base-100 shadow-xl">
141 + <div class="card-body">
142 + <h2 class="card-title mb-4">
143 + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" /></svg>
144 + {{ language_label }}
145 + </h2>
146 +
147 + <div class="space-y-4">
148 + <!-- Language Options -->
149 + <div class="form-control">
150 + <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors">
151 + <input
152 + type="radio"
153 + name="language"
154 + value="fr"
155 + class="radio radio-primary"
156 + form="preferencesForm"
157 + {% if is_language_fr %}checked{% endif %}
158 + >
159 + <div class="flex-1">
160 + <div class="flex items-center gap-2">
161 + <span class="text-2xl">🇫🇷</span>
162 + <span class="label-text font-semibold">{{ language_fr }}</span>
163 + </div>
164 + <span class="label-text-alt text-base-content/70">{{ language_fr_desc }}</span>
165 + </div>
166 + </label>
167 + </div>
168 +
169 + <div class="form-control">
170 + <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors">
171 + <input
172 + type="radio"
173 + name="language"
174 + value="en"
175 + class="radio radio-primary"
176 + form="preferencesForm"
177 + {% if is_language_en %}checked{% endif %}
178 + >
179 + <div class="flex-1">
180 + <div class="flex items-center gap-2">
181 + <span class="text-2xl">🇬🇧</span>
182 + <span class="label-text font-semibold">{{ language_en }}</span>
183 + </div>
184 + <span class="label-text-alt text-base-content/70">{{ language_en_desc }}</span>
185 + </div>
186 + </label>
187 + </div>
188 +
189 + <div class="form-control">
190 + <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors">
191 + <input
192 + type="radio"
193 + name="language"
194 + value="de"
195 + class="radio radio-primary"
196 + form="preferencesForm"
197 + {% if is_language_de %}checked{% endif %}
198 + >
199 + <div class="flex-1">
200 + <div class="flex items-center gap-2">
201 + <span class="text-2xl">🇩🇪</span>
202 + <span class="label-text font-semibold">{{ language_de }}</span>
203 + </div>
204 + <span class="label-text-alt text-base-content/70">{{ language_de_desc }}</span>
205 + </div>
206 + </label>
207 + </div>
208 +
209 + <div class="form-control">
210 + <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors">
211 + <input
212 + type="radio"
213 + name="language"
214 + value="es"
215 + class="radio radio-primary"
216 + form="preferencesForm"
217 + {% if is_language_es %}checked{% endif %}
218 + >
219 + <div class="flex-1">
220 + <div class="flex items-center gap-2">
221 + <span class="text-2xl">🇪🇸</span>
222 + <span class="label-text font-semibold">{{ language_es }}</span>
223 + </div>
224 + <span class="label-text-alt text-base-content/70">{{ language_es_desc }}</span>
225 + </div>
226 + </label>
227 + </div>
228 +
229 + <div class="alert alert-warning">
230 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
231 + <span class="text-xs">{{ translation_warning }}</span>
232 + </div>
233 + </div>
234 + </div>
235 + </div>
236 + </div>
237 +
238 + <!-- Save Button (Fixed at Bottom) -->
239 + <div class="sticky bottom-4 mt-8 max-w-6xl">
240 + <div class="card bg-base-100 shadow-2xl border-2 border-primary">
241 + <div class="card-body flex-row items-center justify-between p-4">
242 + <div>
243 + <h3 class="font-semibold">Préférences modifiées</h3>
244 + <p class="text-sm text-base-content/70">Enregistrez vos changements pour les appliquer</p>
245 + </div>
246 + <div class="flex gap-2">
247 + <a href="/profile" class="btn btn-ghost">Annuler</a>
248 + <button type="submit" form="preferencesForm" class="btn btn-primary" id="saveBtn">
249 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
250 + Enregistrer les préférences
251 + </button>
252 + </div>
253 + </div>
254 + </div>
255 + </div>
256 +
257 + <!-- Info Card -->
258 + <div class="card bg-info text-info-content shadow-xl max-w-6xl mt-6">
259 + <div class="card-body">
260 + <h3 class="card-title text-base">
261 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
262 + {{ sync_title }}
263 + </h3>
264 + <p class="text-sm">
265 + {{ sync_desc }}
266 + </p>
267 + </div>
268 + </div>
269 +</div>
270 +
271 +<script>
272 +var SAVED_RELOAD_MSG = {{ saved_reload_json|safe }};
273 +var SAVED_SUCCESS_MSG = {{ saved_success_json|safe }};
274 +var ERROR_MSG = {{ error_prefix_json|safe }};
275 +var NETWORK_ERROR_MSG = {{ network_error_json|safe }};
276 +var SAVE_BUTTON_TEXT = {{ save_button_json|safe }};
277 +var SAVE_BUTTON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> ';
278 +
279 +function resetSaveBtn() {
280 + var saveBtn = document.getElementById('saveBtn');
281 + saveBtn.disabled = false;
282 + saveBtn.innerHTML = SAVE_BUTTON_SVG + SAVE_BUTTON_TEXT;
283 +}
284 +
285 +document.getElementById('preferencesForm').addEventListener('submit', async function(e) {
286 + e.preventDefault();
287 +
288 + var formData = new FormData(this);
289 + var saveBtn = document.getElementById('saveBtn');
290 + var theme = formData.get('theme');
291 + var language = formData.get('language');
292 +
293 + saveBtn.disabled = true;
294 + saveBtn.innerHTML = '<span class="loading loading-spinner"></span> ' + "{{ saving }}";
295 +
296 + try {
297 + var params = new URLSearchParams();
298 + if (theme) params.set('theme', theme);
299 + if (language) params.set('language', language);
300 + params.set('csrf_token', formData.get('csrf_token') || '');
301 +
302 + var response = await fetch('/settings/preferences', {
303 + method: 'PUT',
304 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
305 + credentials: 'include',
306 + body: params.toString()
307 + });
308 +
309 + var result = await response.json();
310 +
311 + if (response.ok) {
312 + document.cookie = 'theme=' + (theme || 'auto') + '; path=/; max-age=31536000; SameSite=Lax';
313 +
314 + if (theme === 'dark') {
315 + document.documentElement.setAttribute('data-theme', 'dark');
316 + } else if (theme === 'light') {
317 + document.documentElement.setAttribute('data-theme', 'light');
318 + } else {
319 + var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
320 + document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
321 + }
322 +
323 + if (result.reload_required) {
324 + document.cookie = 'locale=' + language + '; path=/; max-age=31536000; SameSite=Lax';
325 + if (confirm('✅ ' + SAVED_RELOAD_MSG)) {
326 + window.location.reload();
327 + return;
328 + }
329 + } else {
330 + alert('✅ ' + SAVED_SUCCESS_MSG);
331 + }
332 + resetSaveBtn();
333 + } else {
334 + var detail = result.error || result.message || '';
335 + alert('❌ ' + ERROR_MSG.replace('{message}', detail));
336 + resetSaveBtn();
337 + }
338 + } catch (error) {
339 + console.error('Error:', error);
340 + alert('❌ ' + NETWORK_ERROR_MSG);
341 + resetSaveBtn();
342 + }
343 +});
344 +
345 +// Preview theme changes in real-time
346 +document.querySelectorAll('input[name="theme"]').forEach(radio => {
347 + radio.addEventListener('change', function() {
348 + const preview = document.getElementById('themePreview');
349 + const previewDark = "{{ preview_dark }}";
350 + const previewLight = "{{ preview_light }}";
351 + const previewAuto = "{{ preview_auto }}";
352 +
353 + if (this.value === 'dark') {
354 + preview.className = 'alert alert-info';
355 + preview.querySelector('span').textContent = previewDark;
356 + } else if (this.value === 'light') {
357 + preview.className = 'alert alert-success';
358 + preview.querySelector('span').textContent = previewLight;
359 + } else {
360 + preview.className = 'alert';
361 + preview.querySelector('span').textContent = previewAuto;
362 + }
363 + });
364 +});
365 +</script>
366 +{% endblock %}
A templates_old/settings/sessions.html
+224 -0
@@ -0,0 +1,224 @@
1 +{% extends "base.html" %}
2 +
3 +{% block title %}{{ page_title }}{% endblock %}
4 +
5 +{% block navbar %}
6 +{% include "components/navbar.html" %}
7 +{% endblock %}
8 +
9 +{% block content %}
10 +<div class="container mx-auto px-4 py-8">
11 + <!-- Breadcrumb -->
12 + <div class="text-sm breadcrumbs mb-6">
13 + <ul>
14 + <li><a href="/">{{ breadcrumb_home }}</a></li>
15 + <li><a href="/profile">{{ breadcrumb_settings }}</a></li>
16 + <li>{{ breadcrumb_sessions }}</li>
17 + </ul>
18 + </div>
19 +
20 + <!-- Page Header -->
21 + <div class="mb-8">
22 + <h1 class="text-3xl font-bold mb-2">{{ title }}</h1>
23 + <p class="text-base-content/70">{{ subtitle }}</p>
24 + </div>
25 +
26 + <!-- Active Sessions List -->
27 + <div class="space-y-4 max-w-4xl">
28 + {% if sessions|length > 0 %}
29 + {% for session in sessions %}
30 + <div class="card bg-base-100 shadow-xl">
31 + <div class="card-body">
32 + <div class="flex justify-between items-start">
33 + <div class="flex-1">
34 + <h3 class="card-title text-lg">
35 + {% if session.is_current %}
36 + <span class="badge badge-primary badge-sm">{{ current_session }}</span>
37 + {% endif %}
38 + {% if !session.device_info.is_empty() %}
39 + {{ session.device_info }}
40 + {% else %}
41 + {{ unknown_device }}
42 + {% endif %}
43 + </h3>
44 +
45 + <div class="mt-3 space-y-2 text-sm">
46 + <!-- IP Address -->
47 + <div class="flex items-center gap-2">
48 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/60" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
49 + <span class="text-base-content/70">{{ ip_address }}</span>
50 + <span class="font-mono">{{ session.ip_address }}</span>
51 + </div>
52 +
53 + <!-- Created At -->
54 + <div class="flex items-center gap-2">
55 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/60" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
56 + <span class="text-base-content/70">{{ created_at }}</span>
57 + <span>{{ session.created_at }}</span>
58 + </div>
59 +
60 + <!-- Last Activity -->
61 + <div class="flex items-center gap-2">
62 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/60" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
63 + <span class="text-base-content/70">{{ last_activity }}</span>
64 + <span>{{ session.last_activity }}</span>
65 + </div>
66 +
67 + <!-- Session ID (for debugging) -->
68 + <div class="flex items-center gap-2">
69 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/60" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
70 + <span class="text-base-content/70">{{ session_id }}</span>
71 + <span class="font-mono text-xs">{{ session.id }}</span>
72 + </div>
73 + </div>
74 + </div>
75 +
76 + <!-- Revoke Button -->
77 + <div class="card-actions">
78 + {% if !session.is_current %}
79 + <button
80 + class="btn btn-error btn-sm"
81 + onclick="revokeSession('{{ session.id }}', '{{ session.device_info }}')"
82 + >
83 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
84 + {{ revoke }}
85 + </button>
86 + {% else %}
87 + <div class="badge badge-lg badge-success gap-2">
88 + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
89 + {{ current_badge }}
90 + </div>
91 + {% endif %}
92 + </div>
93 + </div>
94 + </div>
95 + </div>
96 + {% endfor %}
97 +
98 + <!-- Revoke All Button -->
99 + {% if sessions|length > 1 %}
100 + <div class="card bg-base-200 shadow-xl">
101 + <div class="card-body">
102 + <h3 class="card-title text-base text-error">
103 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
104 + {{ danger_zone }}
105 + </h3>
106 + <p class="text-sm text-base-content/70">
107 + {{ danger_zone_desc }}
108 + </p>
109 + <div class="card-actions justify-end">
110 + <button class="btn btn-error" onclick="revokeAllSessions()">
111 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
112 + {{ revoke_all }}
113 + </button>
114 + </div>
115 + </div>
116 + </div>
117 + {% endif %}
118 + {% else %}
119 + <!-- No Sessions State -->
120 + <div class="card bg-base-100 shadow-xl">
121 + <div class="card-body items-center text-center">
122 + <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
123 + <h3 class="card-title">{{ no_sessions }}</h3>
124 + <p class="text-base-content/70">{{ no_sessions_desc }}</p>
125 + </div>
126 + </div>
127 + {% endif %}
128 + </div>
129 +
130 + <!-- Info Card -->
131 + <div class="card bg-info text-info-content shadow-xl max-w-4xl mt-8">
132 + <div class="card-body">
133 + <h3 class="card-title text-base">
134 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
135 + {{ about_title }}
136 + </h3>
137 + <p class="text-sm">
138 + {{ about_desc }}
139 + </p>
140 + </div>
141 + </div>
142 +
143 + <!-- Back to Settings -->
144 + <div class="mt-8">
145 + <a href="/profile" class="btn btn-outline">
146 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
147 + {{ back_to_settings }}
148 + </a>
149 + </div>
150 +</div>
151 +
152 +<script>
153 +// Revoke a specific session
154 +async function revokeSession(sessionId, deviceInfo) {
155 + if (!confirm(`Êtes-vous sûr de vouloir révoquer la session pour "${deviceInfo}" ?\n\nCet appareil sera déconnecté immédiatement.`)) {
156 + return;
157 + }
158 +
159 + try {
160 + const response = await fetch(`/settings/sessions/${sessionId}`, {
161 + method: 'DELETE',
162 + headers: {
163 + 'Content-Type': 'application/json',
164 + }
165 + });
166 +
167 + if (response.ok) {
168 + alert('✅ Session révoquée avec succès !');
169 + window.location.reload();
170 + } else {
171 + const result = await response.json();
172 + alert('❌ Erreur : ' + (result.error || result.message || 'Échec de la révocation'));
173 + }
174 + } catch (error) {
175 + console.error('Error:', error);
176 + alert('❌ Erreur réseau. Veuillez réessayer.');
177 + }
178 +}
179 +
180 +// Revoke all other sessions
181 +const revokeAllConfirmMsg = "{{ revoke_all_confirm }}";
182 +const revokeAllSuccessMsg = "{{ revoke_all_success }}";
183 +const revokeAllErrorMsg = "{{ revoke_all_error }}";
184 +
185 +async function revokeAllSessions() {
186 + if (!confirm(revokeAllConfirmMsg)) {
187 + return;
188 + }
189 +
190 + // Get all session IDs except current one
191 + const sessionCards = document.querySelectorAll('.card');
192 + const promises = [];
193 +
194 + sessionCards.forEach(card => {
195 + const revokeBtn = card.querySelector('button.btn-error');
196 + if (revokeBtn) {
197 + const onclick = revokeBtn.getAttribute('onclick');
198 + const match = onclick.match(/revokeSession\('([^']+)'/);
199 + if (match) {
200 + const sessionId = match[1];
201 + promises.push(
202 + fetch(`/settings/sessions/${sessionId}`, {
203 + method: 'DELETE',
204 + headers: {
205 + 'Content-Type': 'application/json',
206 + }
207 + })
208 + );
209 + }
210 + }
211 + });
212 +
213 + try {
214 + await Promise.all(promises);
215 + alert('✅ ' + revokeAllSuccessMsg);
216 + window.location.reload();
217 + } catch (error) {
218 + console.error('Error:', error);
219 + alert('❌ ' + revokeAllErrorMsg);
220 + window.location.reload();
221 + }
222 +}
223 +</script>
224 +{% endblock %}
M tools/rustwarden-cli/Cargo.toml
+1 -1
@@ -12,7 +12,7 @@ path = "src/main.rs"
12 12 clap = { version = "4", features = ["derive"] }
13 13 dialoguer = { version = "0.11", features = ["fuzzy-select"] }
14 14 console = "0.15"
15 -rand = "0.8"
15 +rand = "0.9"
16 16
17 17 [lints.clippy]
18 18 empty_line_after_doc_comments = "allow"
M tools/rustwarden-cli/src/start_project.rs
+95 -5
@@ -5,7 +5,7 @@ use console::style;
5 5 use dialoguer::{Confirm, Input, Select};
6 6 use rand::Rng;
7 7
8 -use crate::templates::{self, ProjectConfig, SmtpConfig};
8 +use crate::templates::{self, OAuthProviderConfig, OAuthWizardConfig, ProjectConfig, SmtpConfig};
9 9
10 10 pub fn run() -> Result<(), Box<dyn std::error::Error>> {
11 11 println!(
@@ -49,6 +49,12 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
49 49 );
50 50 let smtp = ask_smtp_config(&app_display_name)?;
51 51
52 + println!(
53 + "\n{}",
54 + style("── OAuth / SSO ──").cyan().bold()
55 + );
56 + let oauth = ask_oauth_config(port)?;
57 +
52 58 let jwt_secret = generate_jwt_secret();
53 59
54 60 let cfg = ProjectConfig {
@@ -66,6 +72,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
66 72 admin_password,
67 73 jwt_secret,
68 74 smtp,
75 + oauth,
69 76 };
70 77
71 78 println!(
@@ -363,19 +370,88 @@ fn ask_smtp_config(app_display_name: &str) -> Result<Option<SmtpConfig>, Box<dyn
363 370
364 371 fn generate_random_password(len: usize) -> String {
365 372 const CHARSET: &[u8] = b"abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%&*";
366 - let mut rng = rand::thread_rng();
373 + let mut rng = rand::rng();
367 374 (0..len)
368 375 .map(|_| {
369 - let idx = rng.gen_range(0..CHARSET.len());
376 + let idx = rng.random_range(0..CHARSET.len());
370 377 CHARSET[idx] as char
371 378 })
372 379 .collect()
373 380 }
374 381
382 +fn ask_oauth_config(port: u16) -> Result<Option<OAuthWizardConfig>, Box<dyn std::error::Error>> {
383 + let enable = Confirm::new()
384 + .with_prompt(format!(
385 + "{}",
386 + style("Activer OAuth / SSO (Google, GitHub, Discord, Microsoft) ?").cyan()
387 + ))
388 + .default(false)
389 + .interact()?;
390 +
391 + if !enable {
392 + println!(" {} OAuth desactive.", style("→").dim());
393 + return Ok(None);
394 + }
395 +
396 + let redirect_base_url: String = Input::new()
397 + .with_prompt(format!(
398 + "{}",
399 + style("URL de base pour les callbacks OAuth").cyan()
400 + ))
401 + .default(format!("http://localhost:{}", port))
402 + .interact_text()?;
403 +
404 + let provider_names = ["google", "github", "discord", "microsoft"];
405 + let display_names = ["Google", "GitHub", "Discord", "Microsoft"];
406 + let mut providers = Vec::new();
407 +
408 + for (i, name) in provider_names.iter().enumerate() {
409 + let configure = Confirm::new()
410 + .with_prompt(format!(
411 + " {} {}",
412 + style("Configurer").cyan(),
413 + style(display_names[i]).bold()
414 + ))
415 + .default(false)
416 + .interact()?;
417 +
418 + if !configure {
419 + continue;
420 + }
421 +
422 + let client_id: String = Input::new()
423 + .with_prompt(format!(" {} Client ID", style(display_names[i]).cyan()))
424 + .interact_text()?;
425 +
426 + let client_secret: String = Input::new()
427 + .with_prompt(format!(" {} Client Secret", style(display_names[i]).cyan()))
428 + .interact_text()?;
429 +
430 + providers.push(OAuthProviderConfig {
431 + name: name.to_string(),
432 + client_id,
433 + client_secret,
434 + });
435 + }
436 +
437 + if providers.is_empty() {
438 + println!(
439 + " {} OAuth active mais aucun provider configure. Ajoutez-les dans .env plus tard.",
440 + style("⚠").yellow()
441 + );
442 + }
443 +
444 + Ok(Some(OAuthWizardConfig {
445 + enabled: true,
446 + redirect_base_url,
447 + providers,
448 + }))
449 +}
450 +
375 451 fn generate_jwt_secret() -> String {
376 - let mut rng = rand::thread_rng();
452 + let mut rng = rand::rng();
377 453 (0..64)
378 - .map(|_| format!("{:02x}", rng.gen::<u8>()))
454 + .map(|_| format!("{:02x}", rng.random::<u8>()))
379 455 .collect::<String>()
380 456 }
381 457
@@ -619,6 +695,20 @@ fn print_summary(cfg: &ProjectConfig, project_dir: &Path) {
619 695 "desactive".to_string()
620 696 },
621 697 );
698 + println!(
699 + " {} {}",
700 + style("OAuth").bold(),
701 + if let Some(ref oauth) = cfg.oauth {
702 + if oauth.providers.is_empty() {
703 + "active (aucun provider configure)".to_string()
704 + } else {
705 + let names: Vec<&str> = oauth.providers.iter().map(|p| p.name.as_str()).collect();
706 + format!("active ({})", names.join(", "))
707 + }
708 + } else {
709 + "desactive".to_string()
710 + },
711 + );
622 712
623 713 println!("\n{divider}");
624 714 println!(" {}", style("Prochaines etapes :").bold());
M tools/rustwarden-cli/src/templates.rs
+91 -0
@@ -1,3 +1,11 @@
1 +fn generate_hex_key() -> String {
2 + use rand::Rng;
3 + let mut rng = rand::rng();
4 + (0..32)
5 + .map(|_| format!("{:02x}", rng.random::<u8>()))
6 + .collect::<String>()
7 +}
8 +
1 9 pub struct SmtpConfig {
2 10 pub host: String,
3 11 pub port: u16,
@@ -9,6 +17,18 @@ pub struct SmtpConfig {
9 17 pub use_starttls: bool,
10 18 }
11 19
20 +pub struct OAuthProviderConfig {
21 + pub name: String,
22 + pub client_id: String,
23 + pub client_secret: String,
24 +}
25 +
26 +pub struct OAuthWizardConfig {
27 + pub enabled: bool,
28 + pub redirect_base_url: String,
29 + pub providers: Vec<OAuthProviderConfig>,
30 +}
31 +
12 32 pub struct ProjectConfig {
13 33 pub name: String,
14 34 pub app_display_name: String,
@@ -24,6 +44,7 @@ pub struct ProjectConfig {
24 44 pub admin_password: String,
25 45 pub jwt_secret: String,
26 46 pub smtp: Option<SmtpConfig>,
47 + pub oauth: Option<OAuthWizardConfig>,
27 48 }
28 49
29 50 pub fn cargo_toml(cfg: &ProjectConfig) -> String {
@@ -135,6 +156,54 @@ async fn hello() -> &'static str {{
135 156 )
136 157 }
137 158
159 +fn oauth_env_section(cfg: &ProjectConfig) -> String {
160 + match &cfg.oauth {
161 + Some(oauth) if oauth.enabled => {
162 + let encryption_key = generate_hex_key();
163 + let mut lines = format!(
164 + r#"# --- OAuth / SSO ---
165 +OAUTH_ENABLED=true
166 +OAUTH_REDIRECT_BASE_URL={base_url}
167 +# Encryption key for OAuth secrets in DB (generated automatically)
168 +OAUTH_ENCRYPTION_KEY={encryption_key}
169 +"#,
170 + base_url = oauth.redirect_base_url,
171 + encryption_key = encryption_key,
172 + );
173 + for p in &oauth.providers {
174 + let upper = p.name.to_uppercase();
175 + lines.push_str(&format!(
176 + "OAUTH_{name}_ENABLED=true\nOAUTH_{name}_CLIENT_ID={cid}\nOAUTH_{name}_CLIENT_SECRET={cs}\n",
177 + name = upper,
178 + cid = p.client_id,
179 + cs = p.client_secret,
180 + ));
181 + }
182 + lines
183 + }
184 + _ => r#"# --- OAuth / SSO (desactive) ---
185 +# OAUTH_ENABLED=false
186 +# OAUTH_REDIRECT_BASE_URL=http://localhost:3000
187 +# Encryption key for OAuth secrets in DB (generate with: openssl rand -hex 32)
188 +# OAUTH_ENCRYPTION_KEY=
189 +# OAUTH_GOOGLE_ENABLED=false
190 +# OAUTH_GOOGLE_CLIENT_ID=
191 +# OAUTH_GOOGLE_CLIENT_SECRET=
192 +# OAUTH_GITHUB_ENABLED=false
193 +# OAUTH_GITHUB_CLIENT_ID=
194 +# OAUTH_GITHUB_CLIENT_SECRET=
195 +# OAUTH_DISCORD_ENABLED=false
196 +# OAUTH_DISCORD_CLIENT_ID=
197 +# OAUTH_DISCORD_CLIENT_SECRET=
198 +# OAUTH_MICROSOFT_ENABLED=false
199 +# OAUTH_MICROSOFT_CLIENT_ID=
200 +# OAUTH_MICROSOFT_CLIENT_SECRET=
201 +# OAUTH_MICROSOFT_TENANT=common
202 +"#
203 + .to_string(),
204 + }
205 +}
206 +
138 207 pub fn dot_env(cfg: &ProjectConfig) -> String {
139 208 let smtp_section = match &cfg.smtp {
140 209 Some(smtp) => format!(
@@ -247,6 +316,8 @@ DEFAULT_LOCALE=fr
247 316 # IMAP_ACCEPT_INVALID_CERTS=false
248 317 # IMAP_MAILBOX=INBOX
249 318 # IMAP_POLL_INTERVAL_SECONDS=300
319 +
320 +{oauth_section}
250 321 "#,
251 322 name = cfg.name,
252 323 app_display_name = cfg.app_display_name,
@@ -259,6 +330,7 @@ DEFAULT_LOCALE=fr
259 330 admin_email = cfg.admin_email,
260 331 admin_password = cfg.admin_password,
261 332 smtp_section = smtp_section,
333 + oauth_section = oauth_env_section(cfg),
262 334 )
263 335 }
264 336
@@ -339,6 +411,25 @@ DEFAULT_LOCALE=fr
339 411 # IMAP_ACCEPT_INVALID_CERTS=false
340 412 # IMAP_MAILBOX=INBOX
341 413 # IMAP_POLL_INTERVAL_SECONDS=300
414 +
415 +# --- OAuth / SSO (optionnel) ---
416 +# Encryption key for OAuth secrets in DB (generate with: openssl rand -hex 32)
417 +# OAUTH_ENCRYPTION_KEY=
418 +# OAUTH_ENABLED=false
419 +# OAUTH_REDIRECT_BASE_URL=http://localhost:3000
420 +# OAUTH_GOOGLE_ENABLED=false
421 +# OAUTH_GOOGLE_CLIENT_ID=
422 +# OAUTH_GOOGLE_CLIENT_SECRET=
423 +# OAUTH_GITHUB_ENABLED=false
424 +# OAUTH_GITHUB_CLIENT_ID=
425 +# OAUTH_GITHUB_CLIENT_SECRET=
426 +# OAUTH_DISCORD_ENABLED=false
427 +# OAUTH_DISCORD_CLIENT_ID=
428 +# OAUTH_DISCORD_CLIENT_SECRET=
429 +# OAUTH_MICROSOFT_ENABLED=false
430 +# OAUTH_MICROSOFT_CLIENT_ID=
431 +# OAUTH_MICROSOFT_CLIENT_SECRET=
432 +# OAUTH_MICROSOFT_TENANT=common
342 433 "#
343 434 .to_string()
344 435 }