2 Commits

Author SHA1 Message Date
Priec
7da4109584 RBAC via casbin
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-18 18:02:44 +02:00
Priec
ed607e3d27 login register 2026-06-18 17:19:04 +02:00
23 changed files with 959 additions and 129 deletions

356
Cargo.lock generated
View File

@@ -26,6 +26,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"const-random",
"getrandom 0.3.4", "getrandom 0.3.4",
"once_cell", "once_cell",
"version_check", "version_check",
@@ -268,6 +269,18 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "axum-casbin"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "632c40b424a3ee65c18058b23ad1a7c361bf8989fee9d7dbc8347b301b52459e"
dependencies = [
"axum",
"casbin",
"tokio",
"tower 0.5.3",
]
[[package]] [[package]]
name = "axum-core" name = "axum-core"
version = "0.5.6" version = "0.5.6"
@@ -541,6 +554,12 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "bytecount"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
@@ -559,6 +578,66 @@ version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
[[package]]
name = "camino"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
dependencies = [
"serde_core",
]
[[package]]
name = "cargo-platform"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
dependencies = [
"serde",
]
[[package]]
name = "cargo_metadata"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa"
dependencies = [
"camino",
"cargo-platform",
"semver",
"serde",
"serde_json",
]
[[package]]
name = "casbin"
version = "2.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c53f7476c2d0d9cd7ccc88c16ffc5c7889a0497b3462b10b12b5329adde69665"
dependencies = [
"async-trait",
"fixedbitset",
"getrandom 0.3.4",
"hashlink 0.9.1",
"mini-moka",
"once_cell",
"parking_lot",
"petgraph",
"regex",
"rhai",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"wasm-bindgen-test",
]
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.62" version = "1.2.62"
@@ -755,6 +834,26 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
[[package]] [[package]]
name = "cookie" name = "cookie"
version = "0.18.1" version = "0.18.1"
@@ -888,6 +987,12 @@ dependencies = [
"regex", "regex",
] ]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -956,6 +1061,19 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "dashmap"
version = "5.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [
"cfg-if",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]] [[package]]
name = "dashmap" name = "dashmap"
version = "6.1.0" version = "6.1.0"
@@ -1172,6 +1290,15 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "error-chain"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc"
dependencies = [
"version_check",
]
[[package]] [[package]]
name = "etcetera" name = "etcetera"
version = "0.8.0" version = "0.8.0"
@@ -1206,6 +1333,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.1.9" version = "1.1.9"
@@ -1294,7 +1427,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"spin", "spin 0.9.8",
] ]
[[package]] [[package]]
@@ -1581,6 +1714,9 @@ name = "hashbrown"
version = "0.14.5" version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
@@ -1599,6 +1735,15 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]] [[package]]
name = "hashlink" name = "hashlink"
version = "0.10.0" version = "0.10.0"
@@ -2118,6 +2263,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"axum-casbin",
"axum-extra", "axum-extra",
"bytes", "bytes",
"chrono", "chrono",
@@ -2169,7 +2315,7 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [ dependencies = [
"spin", "spin 0.9.8",
] ]
[[package]] [[package]]
@@ -2302,7 +2448,7 @@ dependencies = [
"clap", "clap",
"colored 3.1.1", "colored 3.1.1",
"cruet 0.13.3", "cruet 0.13.3",
"dashmap", "dashmap 6.1.0",
"duct", "duct",
"duct_sh", "duct_sh",
"english-to-cron", "english-to-cron",
@@ -2463,6 +2609,31 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "mini-moka"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c325dfab65f261f386debee8b0969da215b3fa0037e74c8a1234db7ba986d803"
dependencies = [
"crossbeam-channel",
"crossbeam-utils",
"dashmap 5.5.3",
"skeptic",
"smallvec",
"tagptr",
"triomphe",
]
[[package]]
name = "minicov"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
dependencies = [
"cc",
"walkdir",
]
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@@ -2521,7 +2692,7 @@ dependencies = [
"httparse", "httparse",
"memchr", "memchr",
"mime", "mime",
"spin", "spin 0.9.8",
"version_check", "version_check",
] ]
@@ -2544,6 +2715,15 @@ dependencies = [
"memoffset", "memoffset",
] ]
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
dependencies = [
"spin 0.5.2",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -2677,6 +2857,9 @@ name = "once_cell"
version = "1.21.4" version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "once_cell_polyfill" name = "once_cell_polyfill"
@@ -2684,6 +2867,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]] [[package]]
name = "opendal" name = "opendal"
version = "0.54.1" version = "0.54.1"
@@ -2870,6 +3059,16 @@ dependencies = [
"sha2", "sha2",
] ]
[[package]]
name = "petgraph"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
dependencies = [
"fixedbitset",
"indexmap",
]
[[package]] [[package]]
name = "pgvector" name = "pgvector"
version = "0.4.1" version = "0.4.1"
@@ -3105,6 +3304,17 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "pulldown-cmark"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b"
dependencies = [
"bitflags",
"memchr",
"unicase",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@@ -3128,7 +3338,7 @@ dependencies = [
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2 0.5.10", "socket2 0.6.3",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
@@ -3165,9 +3375,9 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"libc", "libc",
"once_cell", "once_cell",
"socket2 0.5.10", "socket2 0.6.3",
"tracing", "tracing",
"windows-sys 0.52.0", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -3413,6 +3623,36 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
[[package]]
name = "rhai"
version = "1.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd4dd0f8c36625202a4ba553c416c19b719947cd2a31d1bda06126e4a5727daf"
dependencies = [
"ahash 0.8.12",
"bitflags",
"no-std-compat",
"num-traits",
"once_cell",
"rhai_codegen",
"serde",
"smallvec",
"smartstring",
"thin-vec",
"web-time",
]
[[package]]
name = "rhai_codegen"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -3881,6 +4121,10 @@ name = "semver"
version = "1.0.28" version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
dependencies = [
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde" name = "serde"
@@ -4174,6 +4418,21 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "skeptic"
version = "0.13.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8"
dependencies = [
"bytecount",
"cargo_metadata",
"error-chain",
"glob",
"pulldown-cmark",
"tempfile",
"walkdir",
]
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -4199,6 +4458,18 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"serde",
"static_assertions",
"version_check",
]
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.5.10" version = "0.5.10"
@@ -4219,6 +4490,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "spin"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]] [[package]]
name = "spin" name = "spin"
version = "0.9.8" version = "0.9.8"
@@ -4270,7 +4547,7 @@ dependencies = [
"futures-io", "futures-io",
"futures-util", "futures-util",
"hashbrown 0.15.5", "hashbrown 0.15.5",
"hashlink", "hashlink 0.10.0",
"indexmap", "indexmap",
"log", "log",
"memchr", "memchr",
@@ -4619,6 +4896,15 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "thin-vec"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -4699,6 +4985,15 @@ dependencies = [
"time-core", "time-core",
] ]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.3" version = "0.8.3"
@@ -5038,6 +5333,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "triomphe"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39"
[[package]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
@@ -5383,6 +5684,45 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "wasm-bindgen-test"
version = "0.3.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af5ec93229ad9ccd0a545a516dec76dc276613f278f6a91aa6b463d5b33d42d0"
dependencies = [
"async-trait",
"cast",
"js-sys",
"libm",
"minicov",
"nu-ansi-term",
"num-traits",
"oorandom",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test-macro",
"wasm-bindgen-test-shared",
]
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c81b9fef827e575e0e54431736d1baa0d700315d8c62cfef1f61fa3aad0cbeb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "wasm-bindgen-test-shared"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f4d8ae7ad5440360e9799dfd42857d126454a88441ddf72d288ef83fa47f527"
[[package]] [[package]]
name = "wasm-encoder" name = "wasm-encoder"
version = "0.244.0" version = "0.244.0"

View File

@@ -45,6 +45,7 @@ unic-langid = { version = "0.9" }
# /view engine # /view engine
axum-extra = { version = "0.10", features = ["form"] } axum-extra = { version = "0.10", features = ["form"] }
bytes = { version = "1" } bytes = { version = "1" }
axum-casbin = "1.3.0"
[[bin]] [[bin]]
name = "kompress-eshop-cli" name = "kompress-eshop-cli"

View File

@@ -57,12 +57,28 @@ album-by = by
album-play-full = Play full album album-play-full = Play full album
album-queue-all = queue all tracks in order album-queue-all = queue all tracks in order
album-no-tracks = no tracks yet album-no-tracks = no tracks yet
login-title = Admin login login-title = Sign in
login-error = Access denied - invalid email or password. login-error = Access denied - invalid email or password.
login-error-unverified = Your account isn't verified yet. Check your email and click the verification link.
login-root = root login-root = root
login-auth = Authenticate login-auth = Sign in
login-email = Email login-email = Email
login-password = Password login-password = Password
login-no-account = Don't have an account?
login-have-account = Already have an account?
nav-login = Sign in
nav-register = Register
register-title = Create account
register-name = Name
register-submit = Create account
register-error-exists = An account with this email already exists.
register-error-invalid = Please check the details you entered and try again.
verify-sent-title = Check your email
verify-sent-body = We've sent a verification link to
verify-ok-title = Account verified
verify-ok-body = Your account is verified. You can now sign in.
verify-fail-title = Verification failed
verify-fail-body = This link is invalid or has expired.
auth = Auth auth = Auth
admin-session = Session admin-session = Session
readonly = readonly readonly = readonly

View File

@@ -57,12 +57,28 @@ album-by = od
album-play-full = Prehrať celý album album-play-full = Prehrať celý album
album-queue-all = zoradiť všetky skladby v poradí album-queue-all = zoradiť všetky skladby v poradí
album-no-tracks = zatiaľ žiadne skladby album-no-tracks = zatiaľ žiadne skladby
login-title = Prihlásenie admina login-title = Prihlásenie
login-error = Prístup odmietnutý - nesprávny e-mail alebo heslo. login-error = Prístup odmietnutý - nesprávny e-mail alebo heslo.
login-error-unverified = Účet ešte nie je overený. Skontrolujte si e-mail a kliknite na overovací odkaz.
login-root = root login-root = root
login-auth = Prihlásiť sa login-auth = Prihlásiť sa
login-email = E-mail login-email = E-mail
login-password = Heslo login-password = Heslo
login-no-account = Nemáte účet?
login-have-account = Už máte účet?
nav-login = Prihlásiť sa
nav-register = Registrácia
register-title = Vytvoriť účet
register-name = Meno
register-submit = Zaregistrovať sa
register-error-exists = Účet s týmto e-mailom už existuje.
register-error-invalid = Skontrolujte zadané údaje a skúste to znova.
verify-sent-title = Skontrolujte si e-mail
verify-sent-body = Poslali sme overovací odkaz na adresu
verify-ok-title = Účet overený
verify-ok-body = Váš účet je overený. Teraz sa môžete prihlásiť.
verify-fail-title = Overenie zlyhalo
verify-fail-body = Tento odkaz je neplatný alebo mu vypršala platnosť.
auth = Overenie auth = Overenie
admin-session = Relácia admin-session = Relácia
readonly = iba na čítanie readonly = iba na čítanie

File diff suppressed because one or more lines are too long

View File

@@ -95,7 +95,7 @@
<a href="/" class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-info underline-offset-2 transition hover:bg-info/5 focus:outline-hidden focus-visible:underline"> <a href="/" class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-info underline-offset-2 transition hover:bg-info/5 focus:outline-hidden focus-visible:underline">
{{ t(key="admin-exit", lang=lang | default(value='sk')) }} {{ t(key="admin-exit", lang=lang | default(value='sk')) }}
</a> </a>
<form method="post" action="/admin/logout"> <form method="post" action="/logout">
<button type="submit" class="flex w-full items-center gap-2 rounded-radius px-2 py-1.5 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-danger/5 focus:outline-hidden focus-visible:underline"> <button type="submit" class="flex w-full items-center gap-2 rounded-radius px-2 py-1.5 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-danger/5 focus:outline-hidden focus-visible:underline">
{{ t(key="logout", lang=lang | default(value='sk')) }} {{ t(key="logout", lang=lang | default(value='sk')) }}
</button> </button>

View File

@@ -10,21 +10,23 @@
<div <div
class="flex items-center justify-between border-b border-outline px-5 py-3 dark:border-outline-dark"> class="flex items-center justify-between border-b border-outline px-5 py-3 dark:border-outline-dark">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong"> <span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="nav-admin", lang=lang | default(value='sk')) }} {{ t(key="brand", lang=lang | default(value='sk')) }}
</span> </span>
{{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="danger") }} {{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="primary") }}
</div> </div>
<div class="p-5"> <div class="p-5">
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong"> <h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="login-auth", lang=lang | default(value='sk')) }} {{ t(key="login-title", lang=lang | default(value='sk')) }}
</h1> </h1>
{% if error %} {% if error == "unverified" %}
{{ ui::alert_danger(message=t(key="login-error-unverified", lang=lang | default(value='sk')), extra="mt-3") }}
{% elif error %}
{{ ui::alert_danger(message=t(key="login-error", lang=lang | default(value='sk')), extra="mt-3") }} {{ ui::alert_danger(message=t(key="login-error", lang=lang | default(value='sk')), extra="mt-3") }}
{% endif %} {% endif %}
<form method="post" action="/admin/login" hx-boost="false" class="mt-4 flex flex-col gap-4"> <form method="post" action="/login" hx-boost="false" class="mt-4 flex flex-col gap-4">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="email" <label for="email"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong"> class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
@@ -43,6 +45,12 @@
{{ ui::button(label=t(key="login-auth", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }} {{ ui::button(label=t(key="login-auth", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
</form> </form>
<p class="mt-4 text-sm text-on-surface dark:text-on-surface-dark">
{{ t(key="login-no-account", lang=lang | default(value='sk')) }}
<a href="/register"
class="font-medium text-primary underline-offset-2 hover:underline dark:text-primary-dark">{{ t(key="nav-register", lang=lang | default(value='sk')) }}</a>
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ t(key="register-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %}
<div class="mx-auto mt-8 max-w-sm">
<div
class="rounded-radius border border-outline bg-surface-alt shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
<div
class="flex items-center justify-between border-b border-outline px-5 py-3 dark:border-outline-dark">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="brand", lang=lang | default(value='sk')) }}
</span>
{{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="primary") }}
</div>
<div class="p-5">
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="register-title", lang=lang | default(value='sk')) }}
</h1>
{% if error == "exists" %}
{{ ui::alert_danger(message=t(key="register-error-exists", lang=lang | default(value='sk')), extra="mt-3") }}
{% elif error %}
{{ ui::alert_danger(message=t(key="register-error-invalid", lang=lang | default(value='sk')), extra="mt-3") }}
{% endif %}
<form method="post" action="/register" hx-boost="false" class="mt-4 flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="name"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="register-name", lang=lang | default(value='sk')) }}
</label>
{{ ui::input(name="name", id="name", required=true, autocomplete="name", attrs="autofocus") }}
</div>
<div class="flex flex-col gap-1">
<label for="email"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="login-email", lang=lang | default(value='sk')) }}
</label>
{{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email") }}
</div>
<div class="flex flex-col gap-1">
<label for="password"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="login-password", lang=lang | default(value='sk')) }}
</label>
{{ ui::input(name="password", id="password", type="password", required=true, autocomplete="new-password") }}
</div>
{{ ui::button(label=t(key="register-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
</form>
<p class="mt-4 text-sm text-on-surface dark:text-on-surface-dark">
{{ t(key="login-have-account", lang=lang | default(value='sk')) }}
<a href="/login"
class="font-medium text-primary underline-offset-2 hover:underline dark:text-primary-dark">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</a>
</p>
</div>
</div>
</div>
{% endblock content %}

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{% if ok %}{{ t(key="verify-ok-title", lang=lang | default(value='sk')) }}{% else %}{{ t(key="verify-fail-title", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
{% block content %}
<div class="mx-auto mt-8 max-w-sm">
<div
class="rounded-radius border border-outline bg-surface-alt p-5 shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
{% if ok %}
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="verify-ok-title", lang=lang | default(value='sk')) }}
</h1>
<p class="mt-3 text-sm text-on-surface dark:text-on-surface-dark">
{{ t(key="verify-ok-body", lang=lang | default(value='sk')) }}
</p>
{{ ui::button(label=t(key="login-auth", lang=lang | default(value='sk')), href="/login", extra="mt-4 w-full") }}
{% else %}
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="verify-fail-title", lang=lang | default(value='sk')) }}
</h1>
{{ ui::alert_danger(message=t(key="verify-fail-body", lang=lang | default(value='sk')), extra="mt-3") }}
{{ ui::button(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", variant="outline-primary", extra="mt-4 w-full") }}
{% endif %}
</div>
</div>
{% endblock content %}

View File

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ t(key="verify-sent-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %}
<div class="mx-auto mt-8 max-w-sm">
<div
class="rounded-radius border border-outline bg-surface-alt p-5 shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="verify-sent-title", lang=lang | default(value='sk')) }}
</h1>
<p class="mt-3 text-sm text-on-surface dark:text-on-surface-dark">
{{ t(key="verify-sent-body", lang=lang | default(value='sk')) }}
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ email }}</span>
</p>
{{ ui::button(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", variant="outline-primary", extra="mt-4 w-full") }}
</div>
</div>
{% endblock content %}

View File

@@ -81,12 +81,13 @@
{% if logged_in_admin %} {% if logged_in_admin %}
<li>{{ ui::nav_link(label=t(key="admin-title", lang=lang | default(value='sk')), href="/admin/dashboard", data_nav="/admin", variant="warning", attrs='hx-boost="false"') }}</li> <li>{{ ui::nav_link(label=t(key="admin-title", lang=lang | default(value='sk')), href="/admin/dashboard", data_nav="/admin", variant="warning", attrs='hx-boost="false"') }}</li>
<li> <li>
<form method="post" action="/admin/logout" hx-boost="false"> <form method="post" action="/logout" hx-boost="false">
<button type="submit" class="text-sm font-medium text-danger underline-offset-2 transition hover:opacity-75 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button> <button type="submit" class="text-sm font-medium text-danger underline-offset-2 transition hover:opacity-75 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form> </form>
</li> </li>
{% else %} {% else %}
<li>{{ ui::nav_link(label=t(key="nav-admin", lang=lang | default(value='sk')), href="/admin/login", data_nav="/admin/login") }}</li> <li>{{ ui::nav_link(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", data_nav="/login") }}</li>
<li>{{ ui::nav_link(label=t(key="nav-register", lang=lang | default(value='sk')), href="/register", data_nav="/register") }}</li>
{% endif %} {% endif %}
</ul> </ul>
@@ -126,12 +127,13 @@
{% if logged_in_admin %} {% if logged_in_admin %}
<li><a href="/admin/dashboard" hx-boost="false" data-nav="/admin" class="block rounded-radius px-3 py-2 text-sm font-medium text-warning underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li> <li><a href="/admin/dashboard" hx-boost="false" data-nav="/admin" class="block rounded-radius px-3 py-2 text-sm font-medium text-warning underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
<li> <li>
<form method="post" action="/admin/logout" hx-boost="false"> <form method="post" action="/logout" hx-boost="false">
<button type="submit" class="block w-full rounded-radius px-3 py-2 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button> <button type="submit" class="block w-full rounded-radius px-3 py-2 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form> </form>
</li> </li>
{% else %} {% else %}
<li><a href="/admin/login" data-nav="/admin/login" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-admin", lang=lang | default(value='sk')) }}</a></li> <li><a href="/login" data-nav="/login" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/register" data-nav="/register" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-register", lang=lang | default(value='sk')) }}</a></li>
{% endif %} {% endif %}
</ul> </ul>
</nav> </nav>

24
config/casbin/model.conf Normal file
View File

@@ -0,0 +1,24 @@
# Casbin access model for the storefront.
#
# Request is (subject, object, action) = (role, request-path, HTTP-method);
# axum-casbin supplies path + method automatically and the subject comes from
# our JWT-derived CasbinVals (see src/shared/rbac.rs).
#
# Deny-override: every request is allowed unless a matching policy line marks it
# `deny`. That keeps the public storefront fully open and lets the policy file
# carve out the protected `/admin/*` subtree for non-admins only.
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act, eft
[role_definition]
g = _, _
[policy_effect]
e = !some(where (p.eft == deny))
[matchers]
m = (r.sub == p.sub || g(r.sub, p.sub)) && keyMatch(r.obj, p.obj) && regexMatch(r.act, p.act)

25
config/casbin/policy.csv Normal file
View File

@@ -0,0 +1,25 @@
# Authorization policy. Format: p, subject(role), object(path-pattern), action(method-regex), effect
#
# DECISION: this app intentionally runs with a SINGLE hardcoded admin (the user
# whose email matches ADMIN_EMAIL in .env, via guard::is_admin / admin_seeder).
# Everyone else is `customer` (logged in) or `anonymous` (not). There is no
# stored `role` column yet. This is a deliberate choice for current scale, not a
# limitation of the wiring — see src/shared/rbac.rs for the upgrade path.
#
# Deny everyone except admins under the admin subtree. `keyMatch` treats the
# trailing `*` as "anything after /admin/", so /admin/dashboard, /admin/orders/5
# etc. are all covered; the bare /admin entry point stays open so it can redirect
# to /login. Admins match no deny rule and so are allowed through.
#
# To grow this: give users a real role, emit it as the subject in
# src/shared/rbac.rs, then add `p` lines (allow/deny) and `g, <user>, <role>`
# mappings here.
p, customer, /admin/*, .*, deny
p, anonymous, /admin/*, .*, deny
# Admin-only endpoints that live outside the /admin/* subtree: the admin JSON
# API and the image upload. Public image serving (/images/{filename}) is GET and
# not matched here, so it stays open.
p, customer, /api/admin/*, .*, deny
p, anonymous, /api/admin/*, .*, deny
p, customer, /images/upload, .*, deny
p, anonymous, /images/upload, .*, deny
Can't render this file because it has a wrong number of fields in line 2.

View File

@@ -17,8 +17,8 @@ use std::{path::Path, sync::Arc};
#[allow(unused_imports)] #[allow(unused_imports)]
use crate::{ use crate::{
controllers::{ controllers::{
admin_categories, admin_dashboard, admin_form, admin_login, admin_orders, admin_categories, admin_dashboard, admin_form, admin_orders,
admin_products, admin_shipping, auth, cart, checkout, home, i18n, media, shop, admin_products, admin_shipping, auth, auth_pages, cart, checkout, home, i18n, media, shop,
}, },
initializers, initializers,
models::_entities::users, models::_entities::users,
@@ -56,6 +56,20 @@ impl Hooks for App {
environment.load() environment.load()
} }
/// Attach the Casbin authorization layer on top of all routes. Order
/// matters: `inject_subject` is the outermost layer so it runs first and
/// stamps the JWT-derived role onto the request before the inner
/// `CasbinAxumLayer` enforces the policy. See `shared::rbac`.
async fn after_routes(router: axum::Router, ctx: &AppContext) -> Result<axum::Router> {
let casbin = crate::shared::rbac::layer().await?;
Ok(router
.layer(casbin)
.layer(axum::middleware::from_fn_with_state(
ctx.clone(),
crate::shared::rbac::inject_subject,
)))
}
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> { async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
Ok(vec![ Ok(vec![
Box::new(initializers::view_engine::ViewEngineInitializer), Box::new(initializers::view_engine::ViewEngineInitializer),
@@ -73,11 +87,11 @@ impl Hooks for App {
.add_route(checkout::routes()) .add_route(checkout::routes())
// cross-cutting // cross-cutting
.add_route(auth::routes()) .add_route(auth::routes())
.add_route(auth_pages::routes())
.add_route(i18n::routes()) .add_route(i18n::routes())
.add_route(media::routes()) .add_route(media::routes())
// admin // admin
.add_route(admin_dashboard::routes()) .add_route(admin_dashboard::routes())
.add_route(admin_login::routes())
.add_route(admin_products::routes()) .add_route(admin_products::routes())
.add_route(admin_categories::routes()) .add_route(admin_categories::routes())
.add_route(admin_orders::routes()) .add_route(admin_orders::routes())

View File

@@ -1,86 +0,0 @@
//! Cookie-based admin login/logout pages (separate from the JSON `/api/auth`
//! flow used by the SPA/API).
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use serde_json::json;
use crate::{
controllers::auth as auth_controller,
models::users::{self, LoginParams},
controllers::i18n::current_lang,
shared::guard,
};
fn login_error(v: &TeraView, jar: &CookieJar) -> Result<Response> {
format::view(
v,
"admin/login.html",
json!({
"error": "Invalid credentials",
"logged_in_admin": false,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn login_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
if guard::logged_in(&ctx, &jar).await {
return format::redirect("/admin/dashboard");
}
format::view(
&v,
"admin/login.html",
json!({
"error": null,
"logged_in_admin": false,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn login(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Form(params): Form<LoginParams>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
return login_error(&v, &jar);
};
if !user.verify_password(&params.password) || !guard::is_admin(&ctx, &user) {
return login_error(&v, &jar);
}
let jwt_secret = ctx.config.get_jwt_config()?;
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::render()
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
.redirect("/admin/dashboard")
}
#[debug_handler]
async fn logout() -> Result<Response> {
format::render()
.cookies(&[auth_controller::clear_auth_cookie()])?
.redirect("/admin/login")
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin", get(login_page))
.add("/admin/login", get(login_page))
.add("/admin/login", post(login))
.add("/admin/logout", post(logout))
}

View File

@@ -0,0 +1,216 @@
//! Cookie-based HTML auth pages (login, registration, email verification) for
//! all users. There is no role column — an "admin" is simply the user whose
//! email matches `settings.admin_email` (see [`guard::is_admin`]). On login,
//! admins are redirected to the admin dashboard and everyone else to the
//! storefront; both share the same `auth_token` cookie that the admin handlers
//! already validate. This is the unified replacement for the former
//! admin-only `/admin/login`. The JSON `/api/auth` flow in `auth.rs` is
//! separate and untouched.
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use serde_json::json;
use crate::{
controllers::auth as auth_controller,
controllers::i18n::current_lang,
mailers::auth::AuthMailer,
models::users::{self, LoginParams, RegisterParams},
shared::guard,
};
/// Where a freshly-authenticated `user` should land.
fn home_for(ctx: &AppContext, user: &users::Model) -> &'static str {
if guard::is_admin(ctx, user) {
"/admin/dashboard"
} else {
"/"
}
}
fn login_view(v: &TeraView, jar: &CookieJar, error: Option<&str>) -> Result<Response> {
format::view(
v,
"auth/login.html",
json!({
"error": error,
"logged_in_admin": false,
"lang": current_lang(jar),
}),
)
}
fn register_view(v: &TeraView, jar: &CookieJar, error: Option<&str>) -> Result<Response> {
format::view(
v,
"auth/register.html",
json!({
"error": error,
"logged_in_admin": false,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn login_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
if let Some(user) = guard::current_user(&ctx, &jar).await {
return format::redirect(home_for(&ctx, &user));
}
login_view(&v, &jar, None)
}
#[debug_handler]
async fn login(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Form(params): Form<LoginParams>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
return login_view(&v, &jar, Some("invalid"));
};
if !user.verify_password(&params.password) {
return login_view(&v, &jar, Some("invalid"));
}
// Registration requires email verification before the account can sign in.
if user.email_verified_at.is_none() {
return login_view(&v, &jar, Some("unverified"));
}
let jwt_secret = ctx.config.get_jwt_config()?;
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::render()
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
.redirect(home_for(&ctx, &user))
}
#[debug_handler]
async fn register_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
if let Some(user) = guard::current_user(&ctx, &jar).await {
return format::redirect(home_for(&ctx, &user));
}
register_view(&v, &jar, None)
}
#[debug_handler]
async fn register(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Form(params): Form<RegisterParams>,
) -> Result<Response> {
let user = match users::Model::create_with_password(&ctx.db, &params).await {
Ok(user) => user,
Err(ModelError::EntityAlreadyExists {}) => {
return register_view(&v, &jar, Some("exists"));
}
Err(err) => {
// Most commonly a validation failure (name too short / invalid email).
tracing::info!(
message = err.to_string(),
user_email = &params.email,
"could not register user",
);
return register_view(&v, &jar, Some("invalid"));
}
};
let user = user
.into_active_model()
.set_email_verification_sent(&ctx.db)
.await?;
// The account already exists; a failed email send shouldn't 500 the page —
// log it and let the user fall back to resend-verification.
if let Err(err) = AuthMailer::send_welcome(&ctx, &user).await {
tracing::error!(
error = err.to_string(),
user_email = &user.email,
"failed to send verification email",
);
}
format::view(
&v,
"auth/verify_sent.html",
json!({
"email": user.email,
"logged_in_admin": false,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn verify(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Path(token): Path<String>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_verification_token(&ctx.db, &token).await else {
return verified_view(&v, &jar, false);
};
if user.email_verified_at.is_none() {
user.into_active_model().verified(&ctx.db).await?;
}
verified_view(&v, &jar, true)
}
fn verified_view(v: &TeraView, jar: &CookieJar, ok: bool) -> Result<Response> {
format::view(
v,
"auth/verified.html",
json!({
"ok": ok,
"logged_in_admin": false,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn logout() -> Result<Response> {
format::render()
.cookies(&[auth_controller::clear_auth_cookie()])?
.redirect("/login")
}
/// Backwards-compatible entry point: `/admin` sends admins to their dashboard
/// and everyone else to the unified login.
#[debug_handler]
async fn admin_entry(jar: CookieJar, State(ctx): State<AppContext>) -> Result<Response> {
if let Some(user) = guard::current_user(&ctx, &jar).await {
if guard::is_admin(&ctx, &user) {
return format::redirect("/admin/dashboard");
}
}
format::redirect("/login")
}
pub fn routes() -> Routes {
Routes::new()
.add("/login", get(login_page))
.add("/login", post(login))
.add("/register", get(register_page))
.add("/register", post(register))
.add("/verify/{token}", get(verify))
.add("/logout", post(logout))
.add("/admin", get(admin_entry))
}

View File

@@ -1,8 +1,8 @@
pub mod auth; pub mod auth;
pub mod auth_pages;
pub mod admin_categories; pub mod admin_categories;
pub mod admin_dashboard; pub mod admin_dashboard;
pub mod admin_form; pub mod admin_form;
pub mod admin_login;
pub mod admin_orders; pub mod admin_orders;
pub mod admin_products; pub mod admin_products;
pub mod admin_shipping; pub mod admin_shipping;

View File

@@ -1,4 +1,5 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::offset::Local;
use loco_rs::prelude::*; use loco_rs::prelude::*;
use loco_rs::hash; use loco_rs::hash;
use sea_orm::{ActiveModelTrait, IntoActiveModel, Set}; use sea_orm::{ActiveModelTrait, IntoActiveModel, Set};
@@ -30,10 +31,13 @@ impl Initializer for AdminSeeder {
let mut am = user.into_active_model(); let mut am = user.into_active_model();
am.password = Set(hash); am.password = Set(hash);
am.name = Set(name); am.name = Set(name);
// The admin signs in through the same /login flow as everyone else,
// which requires a verified email — keep the seeded admin verified.
am.email_verified_at = Set(Some(Local::now().into()));
am.update(&ctx.db).await?; am.update(&ctx.db).await?;
tracing::info!(admin = %email, "admin password synced from .env"); tracing::info!(admin = %email, "admin password synced from .env");
} else { } else {
users::Model::create_with_password( let user = users::Model::create_with_password(
&ctx.db, &ctx.db,
&RegisterParams { &RegisterParams {
email: email.clone(), email: email.clone(),
@@ -42,6 +46,10 @@ impl Initializer for AdminSeeder {
}, },
) )
.await?; .await?;
// Auto-verify so the seeded admin can log in without an email round-trip.
let mut am = user.into_active_model();
am.email_verified_at = Set(Some(Local::now().into()));
am.update(&ctx.db).await?;
tracing::info!(admin = %email, "admin user seeded"); tracing::info!(admin = %email, "admin user seeded");
} }

View File

@@ -4,7 +4,7 @@
Dear {{name}}, Dear {{name}},
Welcome to Loco! You can now log in to your account. Welcome to Loco! You can now log in to your account.
Before you get started, please verify your account by clicking the link below: Before you get started, please verify your account by clicking the link below:
<a href="{{domain}}/api/auth/verify/{{verifyToken}}"> <a href="{{domain}}/verify/{{verifyToken}}">
Verify Your Account Verify Your Account
</a> </a>
<p>Best regards,<br>The Loco Team</p> <p>Best regards,<br>The Loco Team</p>

View File

@@ -1,4 +1,4 @@
Welcome {{name}}, you can now log in. Welcome {{name}}, you can now log in.
Verify your account with the link below: Verify your account with the link below:
{{domain}}/api/auth/verify/{{verifyToken}} {{domain}}/verify/{{verifyToken}}

View File

@@ -23,21 +23,25 @@ pub async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result<users::M
Ok(user) Ok(user)
} }
/// Soft auth for public pages: returns the user behind a valid auth cookie, or
/// `None`. Never errors — used to decide chrome and post-login redirects, not
/// to gate protected handlers (use [`current_admin`] for that).
pub async fn current_user(ctx: &AppContext, jar: &CookieJar) -> Option<users::Model> {
let cookie = jar.get(AUTH_COOKIE)?;
let jwt_config = ctx.config.get_jwt_config().ok()?;
let claims = loco_rs::auth::jwt::JWT::new(&jwt_config.secret)
.validate(cookie.value())
.ok()?;
users::Model::find_by_pid(&ctx.db, &claims.claims.pid)
.await
.ok()
}
/// Soft check for public pages: does the request carry a valid admin auth /// Soft check for public pages: does the request carry a valid admin auth
/// cookie? Never errors — used only to decide whether to show admin chrome. /// cookie? Never errors — used only to decide whether to show admin chrome.
pub async fn logged_in(ctx: &AppContext, jar: &CookieJar) -> bool { pub async fn logged_in(ctx: &AppContext, jar: &CookieJar) -> bool {
let Some(cookie) = jar.get(AUTH_COOKIE) else { match current_user(ctx, jar).await {
return false; Some(user) => is_admin(ctx, &user),
}; None => false,
let Ok(jwt_config) = ctx.config.get_jwt_config() else { }
return false;
};
let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value())
else {
return false;
};
let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else {
return false;
};
is_admin(ctx, &user)
} }

View File

@@ -2,5 +2,6 @@
pub mod guard; pub mod guard;
pub mod money; pub mod money;
pub mod rbac;
pub mod settings; pub mod settings;
pub mod slug; pub mod slug;

129
src/shared/rbac.rs Normal file
View File

@@ -0,0 +1,129 @@
//! Casbin-based authorization layer.
//!
//! Authentication stays with Loco's JWT cookie (see [`crate::shared::guard`]);
//! Casbin only answers "is this subject allowed on this path?". At request time
//! [`inject_subject`] resolves the caller's role from the JWT and stamps it onto
//! the request as `CasbinVals`, then [`CasbinAxumLayer`] enforces the policy in
//! `config/casbin/{model.conf,policy.csv}`.
//!
//! The model is deny-override: everything is allowed by default and the policy
//! only *denies* non-admins under `/admin/*`. The per-handler
//! [`guard::current_admin`] checks stay in place as defense in depth.
//!
//! DECISION — single hardcoded admin: there is intentionally no stored `role`.
//! The one admin is the user whose email matches `ADMIN_EMAIL` in `.env`
//! (granted in `admin_seeder`, detected by [`guard::is_admin`]); every other
//! authenticated user is `customer` and the rest are `anonymous`. This is a
//! deliberate fit for the current scale, not a constraint of the design.
//!
//! Upgrade path when more roles are needed (each step is localized): add a
//! `role` column to `users` (migration), set it on register/seed, read
//! `user.role` in [`inject_subject`] instead of the `is_admin` check below, and
//! add `p`/`g` lines to `config/casbin/policy.csv`. The enforcement layer,
//! `after_routes` wiring, and tests are unaffected by that change.
use axum::{
extract::{Request, State},
middleware::Next,
response::Response,
};
use axum_casbin::{
casbin::{DefaultModel, FileAdapter},
CasbinAxumLayer, CasbinVals,
};
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use crate::shared::guard;
const MODEL_PATH: &str = "config/casbin/model.conf";
const POLICY_PATH: &str = "config/casbin/policy.csv";
/// Build the Casbin enforcement layer from the on-disk model + policy.
pub async fn layer() -> Result<CasbinAxumLayer> {
let model = DefaultModel::from_file(MODEL_PATH)
.await
.map_err(|e| Error::Message(format!("casbin model load failed: {e}")))?;
let adapter = FileAdapter::new(POLICY_PATH);
CasbinAxumLayer::new(model, adapter)
.await
.map_err(|e| Error::Message(format!("casbin enforcer init failed: {e}")))
}
/// Resolve the caller's role from the Loco JWT cookie and attach it as the
/// Casbin subject. Always sets *some* subject (anonymous requests included) so
/// the enforcer never sees an empty value.
pub async fn inject_subject(
State(ctx): State<AppContext>,
jar: CookieJar,
mut req: Request,
next: Next,
) -> Response {
let subject = match guard::current_user(&ctx, &jar).await {
Some(user) if guard::is_admin(&ctx, &user) => "admin",
Some(_) => "customer",
None => "anonymous",
};
req.extensions_mut().insert(CasbinVals {
subject: subject.to_string(),
domain: None,
});
next.run(req).await
}
#[cfg(test)]
mod tests {
use axum_casbin::casbin::{CoreApi, DefaultModel, Enforcer, FileAdapter};
async fn enforcer() -> Enforcer {
let model = DefaultModel::from_file(super::MODEL_PATH).await.unwrap();
let adapter = FileAdapter::new(super::POLICY_PATH);
Enforcer::new(model, adapter).await.unwrap()
}
async fn allowed(e: &Enforcer, sub: &str, obj: &str, act: &str) -> bool {
e.enforce((sub, obj, act)).unwrap()
}
#[tokio::test]
async fn admin_subtree_is_admin_only() {
let e = enforcer().await;
// Admins reach the protected subtree (any method, nested paths).
assert!(allowed(&e, "admin", "/admin/dashboard", "GET").await);
assert!(allowed(&e, "admin", "/admin/orders/5", "POST").await);
// Customers and anonymous are denied there.
assert!(!allowed(&e, "customer", "/admin/dashboard", "GET").await);
assert!(!allowed(&e, "customer", "/admin/orders/5", "POST").await);
assert!(!allowed(&e, "anonymous", "/admin/products", "GET").await);
}
#[tokio::test]
async fn admin_only_endpoints_outside_admin_subtree() {
let e = enforcer().await;
// Admin JSON API and image upload are admin-only.
assert!(allowed(&e, "admin", "/api/admin/dashboard", "GET").await);
assert!(allowed(&e, "admin", "/images/upload", "POST").await);
assert!(!allowed(&e, "customer", "/api/admin/dashboard", "GET").await);
assert!(!allowed(&e, "anonymous", "/images/upload", "POST").await);
// Public image serving stays open for everyone.
assert!(allowed(&e, "anonymous", "/images/logo.png", "GET").await);
assert!(allowed(&e, "customer", "/images/logo.png", "GET").await);
}
#[tokio::test]
async fn storefront_is_open_to_everyone() {
let e = enforcer().await;
for sub in ["admin", "customer", "anonymous"] {
assert!(allowed(&e, sub, "/", "GET").await);
assert!(allowed(&e, sub, "/shop", "GET").await);
assert!(allowed(&e, sub, "/login", "POST").await);
// The bare /admin entry stays open so it can redirect to /login.
assert!(allowed(&e, sub, "/admin", "GET").await);
}
}
}