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 | |
| 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"> </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, '"') + '" 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"> </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, '"') + '" 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 | } |
GitRust