diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..f232759 --- /dev/null +++ b/.env.template @@ -0,0 +1,4 @@ +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" +TS_RS_EXPORT_DIR="./frontend/src/types" + + diff --git a/.gitignore b/.gitignore index 87a257a..4da5950 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target .DS_Store .env +.envrc diff --git a/Cargo.lock b/Cargo.lock index 1c1fd14..2e016b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,12 +66,35 @@ version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arraydeque" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atoi" version = "2.0.0" @@ -157,28 +180,55 @@ dependencies = [ "sha1", ] +[[package]] +name = "axum-login" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0da8e8e4cf127a9b71b578e9a8fa9833e70f893e428ed5453c85e44bf0fd8eb" +dependencies = [ + "async-trait", + "axum", + "form_urlencoded", + "serde", + "subtle", + "thiserror", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions", + "tracing", + "urlencoding", +] + [[package]] name = "axum-sqlx-template" version = "0.3.0" dependencies = [ "anyhow", + "async-trait", "axum", "axum-inertia", + "axum-login", "chrono", "config", "http", "log", + "password-auth", "secrecy", "serde", "serde-aux", "serde_json", "sqlx", + "thiserror", "tokio", "tower-http", + "tower-sessions", "tracing", "tracing-bunyan-formatter", "tracing-log 0.2.0", "tracing-subscriber", + "ts-rs", + "uuid", ] [[package]] @@ -217,6 +267,15 @@ dependencies = [ "serde", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -301,6 +360,17 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -374,6 +444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28cfac68e08048ae1883171632c2aef3ebc555621ae56fbccce1cbf22dd7f058" dependencies = [ "powerfmt", + "serde", ] [[package]] @@ -499,6 +570,20 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -543,6 +628,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -563,6 +659,7 @@ checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -598,8 +695,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1012,6 +1111,7 @@ checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", + "serde", ] [[package]] @@ -1231,6 +1331,29 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "password-auth" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2a4764cc1f8d961d802af27193c6f4f0124bd0e76e8393cf818e18880f0524" +dependencies = [ + "argon2", + "getrandom 0.2.15", + "password-hash", + "rand_core", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -1978,6 +2101,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "2.0.12" @@ -2131,6 +2263,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-cookies" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" +dependencies = [ + "axum-core", + "cookie", + "futures-util", + "http", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-http" version = "0.6.2" @@ -2168,6 +2316,57 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tower-sessions" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3" +dependencies = [ + "async-trait", + "http", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tower-sessions-memory-store", + "tracing", +] + +[[package]] +name = "tower-sessions-core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b" +dependencies = [ + "async-trait", + "axum-core", + "base64", + "futures", + "http", + "parking_lot", + "rand", + "serde", + "serde_json", + "thiserror", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tower-sessions-memory-store" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb05909f2e1420135a831dd5df9f5596d69196d0a64c3499ca474c4bd3d33242" +dependencies = [ + "async-trait", + "time", + "tokio", + "tower-sessions-core", +] + [[package]] name = "tracing" version = "0.1.41" @@ -2259,6 +2458,32 @@ dependencies = [ "tracing-log 0.2.0", ] +[[package]] +name = "ts-rs" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" +dependencies = [ + "chrono", + "lazy_static", + "serde_json", + "thiserror", + "ts-rs-macros", + "uuid", +] + +[[package]] +name = "ts-rs-macros" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9d8656589772eeec2cf7a8264d9cda40fb28b9bc53118ceb9e8c07f8f38730" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "termcolor", +] + [[package]] name = "typenum" version = "1.18.0" @@ -2315,6 +2540,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf16_iter" version = "1.0.5" @@ -2332,6 +2563,10 @@ name = "uuid" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +dependencies = [ + "getrandom 0.3.2", + "serde", +] [[package]] name = "valuable" @@ -2465,6 +2700,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 1fa5690..eea6c97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ sqlx = { version = "0.8.3", default-features = false, features = [ "chrono", "migrate", ] } +uuid = { version = "1.10.0", features = ["serde", "v4"] } chrono = { version = "0.4.40", features = ["serde"] } secrecy = { version = "0.8", features = ["serde"] } config = { version = "0.15.11", default-features = false, features = ["yaml"] } @@ -38,3 +39,14 @@ axum = { version = "0.8.1", features = ["tracing"] } tower-http = { version = "0.6.2", features = ["trace", "cors", "fs"] } http = "1.3.1" anyhow = "1.0" +axum-login = "0.17.0" +tower-sessions = "0.14.0" +ts-rs = { version = "10.1.0", features = [ + "uuid-impl", + "chrono-impl", + "serde-json-impl", +] } +password-auth = "1.0.0" +thiserror = "2.0.12" +async-trait = "0.1.88" + diff --git a/bacon.toml b/bacon.toml new file mode 100644 index 0000000..9b173c0 --- /dev/null +++ b/bacon.toml @@ -0,0 +1,109 @@ +# This is a configuration file for the bacon tool +# +# Complete help on configuration: https://dystroy.org/bacon/config/ +# +# You may check the current default at +# https://github.com/Canop/bacon/blob/main/defaults/default-bacon.toml + +default_job = "check" +env.CARGO_TERM_COLOR = "always" + +[jobs.check] +command = ["cargo", "check"] +need_stdout = false + +[jobs.check-all] +command = ["cargo", "check", "--all-targets"] +need_stdout = false + +# Run clippy on the default target +[jobs.clippy] +command = ["cargo", "clippy"] +need_stdout = false + +# Run clippy on all targets +# To disable some lints, you may change the job this way: +# [jobs.clippy-all] +# command = [ +# "cargo", "clippy", +# "--all-targets", +# "--", +# "-A", "clippy::bool_to_int_with_if", +# "-A", "clippy::collapsible_if", +# "-A", "clippy::derive_partial_eq_without_eq", +# ] +# need_stdout = false +[jobs.clippy-all] +command = ["cargo", "clippy", "--all-targets"] +need_stdout = false + +# This job lets you run +# - all tests: bacon test +# - a specific test: bacon test -- config::test_default_files +# - the tests of a package: bacon test -- -- -p config +[jobs.test] +command = ["cargo", "test"] +need_stdout = true + +[jobs.nextest] +command = [ + "cargo", "nextest", "run", + "--hide-progress-bar", "--failure-output", "final" +] +need_stdout = true +analyzer = "nextest" + +[jobs.doc] +command = ["cargo", "doc", "--no-deps"] +need_stdout = false + +# If the doc compiles, then it opens in your browser and bacon switches +# to the previous job +[jobs.doc-open] +command = ["cargo", "doc", "--no-deps", "--open"] +need_stdout = false +on_success = "back" # so that we don't open the browser at each change + +# You can run your application and have the result displayed in bacon, +# if it makes sense for this crate. +[jobs.run] +command = [ + "cargo", "run", + # put launch parameters for your program behind a `--` separator +] +need_stdout = true +allow_warnings = true + +# Run your long-running application (eg server) and have the result displayed in bacon. +# For programs that never stop (eg a server), `background` is set to false +# to have the cargo run output immediately displayed instead of waiting for +# program's end. +# 'on_change_strategy' is set to `kill_then_restart` to have your program restart +# on every change (an alternative would be to use the 'F5' key manually in bacon). +# If you often use this job, it makes sense to override the 'r' key by adding +# a binding `r = job:run-long` at the end of this file . +[jobs.run-long] +command = ["sh", "-c", "cargo run | bunyan", "--color always"] +kill = ["pkill", "-TERM", "-P"] +need_stdout = true +allow_warnings = true +background = false +on_change_strategy = "kill_then_restart" + +# This parameterized job runs the example of your choice, as soon +# as the code compiles. +# Call it as +# bacon ex -- my-example +[jobs.ex] +command = ["cargo", "run", "--example"] +need_stdout = true +allow_warnings = true + +# You may define here keybindings that would be specific to +# a project, for example a shortcut to launch a specific job. +# Shortcuts to internal functions (scrolling, toggling, etc.) +# should go in your personal global prefs.toml file instead. +[keybindings] +# alt-m = "job:my-job" +c = "job:clippy-all" # comment this to have 'c' run clippy on only the default target + diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..7dfce35 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 546d16e..c58f419 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,10 +9,17 @@ "version": "0.0.0", "dependencies": { "@inertiajs/react": "^2.0.5", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-slot": "^1.1.2", "@tailwindcss/vite": "^4.0.17", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.484.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.17", + "tw-animate-css": "^1.2.5", "vite-plugin-compression2": "^1.3.3" }, "devDependencies": { @@ -1058,6 +1065,85 @@ "node": ">= 8" } }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz", + "integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rollup/pluginutils": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", @@ -1648,7 +1734,7 @@ "version": "19.0.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.12.tgz", "integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1658,7 +1744,7 @@ "version": "19.0.4", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz", "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -2124,6 +2210,27 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2189,7 +2296,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -3345,6 +3452,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.484.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.484.0.tgz", + "integrity": "sha512-oZy8coK9kZzvqhSgfbGkPtTgyjpBvs3ukLgDPv14dSOZtBtboryWF5o8i3qen7QbGg7JhiJBz5mK1p8YoMZTLQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3922,6 +4038,16 @@ "node": ">=8" } }, + "node_modules/tailwind-merge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.2.tgz", + "integrity": "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.0.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.17.tgz", @@ -3969,6 +4095,15 @@ "typescript": ">=4.8.4" } }, + "node_modules/tw-animate-css": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.5.tgz", + "integrity": "sha512-ABzjfgVo+fDbhRREGL4KQZUqqdPgvc5zVrLyeW9/6mVqvaDepXc7EvedA+pYmMnIOsUAQMwcWzNvom26J2qYvQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6b6f863..56a5245 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,10 +11,17 @@ }, "dependencies": { "@inertiajs/react": "^2.0.5", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-slot": "^1.1.2", "@tailwindcss/vite": "^4.0.17", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.484.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.17", + "tw-animate-css": "^1.2.5", "vite-plugin-compression2": "^1.3.3" }, "devDependencies": { diff --git a/frontend/src/Pages/Login.tsx b/frontend/src/Pages/Login.tsx new file mode 100644 index 0000000..4f40ea8 --- /dev/null +++ b/frontend/src/Pages/Login.tsx @@ -0,0 +1,11 @@ +import { LoginForm } from "@/components/login-form"; + +export default function Login() { + return ( +
+
+ +
+
+ ); +} diff --git a/frontend/src/Pages/Register.tsx b/frontend/src/Pages/Register.tsx new file mode 100644 index 0000000..af4d0cd --- /dev/null +++ b/frontend/src/Pages/Register.tsx @@ -0,0 +1,11 @@ +import { RegisterForm } from "@/components/register-form"; + +export default function Login() { + return ( +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/login-form.tsx b/frontend/src/components/login-form.tsx new file mode 100644 index 0000000..32bfb0d --- /dev/null +++ b/frontend/src/components/login-form.tsx @@ -0,0 +1,86 @@ +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Link, useForm } from "@inertiajs/react"; +import { Credentials } from "@/types/Credentials"; + +export function LoginForm({ + className, + ...props +}: React.ComponentProps<"div">) { + const { data, setData, post, reset } = useForm(); + + function login() { + post(`/login`); + reset(); + } + + return ( +
+ + +
{ + e.preventDefault(); + login(); + }} + > +
+
+

Welcome back

+

+ Login to your account +

+
+
+ + setData("username", e.currentTarget.value)} + /> +
+
+ + setData("password", e.currentTarget.value)} + /> +
+ +
+
+ Don't have an account?{" "} + + Sign up + +
+
+
+
+ Image +
+
+
+
+ By clicking continue, you agree to our Terms of Service{" "} + and Privacy Policy. +
+
+ ); +} diff --git a/frontend/src/components/register-form.tsx b/frontend/src/components/register-form.tsx new file mode 100644 index 0000000..5501291 --- /dev/null +++ b/frontend/src/components/register-form.tsx @@ -0,0 +1,86 @@ +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Link, useForm } from "@inertiajs/react"; +import { NewUser } from "@/types/NewUser"; + +export function RegisterForm({ + className, + ...props +}: React.ComponentProps<"div">) { + const { data, setData, post, reset } = useForm(); + + function register() { + post(`/register`); + reset(); + } + + return ( +
+ + +
{ + e.preventDefault(); + register(); + }} + > +
+
+

Register

+

+ Create your new account +

+
+
+ + setData("username", e.currentTarget.value)} + /> +
+
+ + setData("password", e.currentTarget.value)} + /> +
+ +
+
+ You have an account?{" "} + + Login + +
+
+
+
+ Image +
+
+
+
+ By clicking continue, you agree to our Terms of Service{" "} + and Privacy Policy. +
+
+ ); +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..a2df8dc --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..d05bbc6 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..03295ca --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..ef7133a --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,22 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/frontend/src/index.css b/frontend/src/index.css index 2a1e3e3..fca0644 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "tw-animate-css"; @custom-variant dark (&:where(.dark, .dark *)); html { @@ -19,3 +20,119 @@ input::-webkit-inner-spin-button { input[type="number"] { -moz-appearance: textfield; } + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.129 0.042 264.695); + --card: oklch(1 0 0); + --card-foreground: oklch(0.129 0.042 264.695); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.129 0.042 264.695); + --primary: oklch(0.208 0.042 265.755); + --primary-foreground: oklch(0.984 0.003 247.858); + --secondary: oklch(0.968 0.007 247.896); + --secondary-foreground: oklch(0.208 0.042 265.755); + --muted: oklch(0.968 0.007 247.896); + --muted-foreground: oklch(0.554 0.046 257.417); + --accent: oklch(0.968 0.007 247.896); + --accent-foreground: oklch(0.208 0.042 265.755); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.929 0.013 255.508); + --input: oklch(0.929 0.013 255.508); + --ring: oklch(0.704 0.04 256.788); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.984 0.003 247.858); + --sidebar-foreground: oklch(0.129 0.042 264.695); + --sidebar-primary: oklch(0.208 0.042 265.755); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.968 0.007 247.896); + --sidebar-accent-foreground: oklch(0.208 0.042 265.755); + --sidebar-border: oklch(0.929 0.013 255.508); + --sidebar-ring: oklch(0.704 0.04 256.788); +} + +.dark { + --background: oklch(0.129 0.042 264.695); + --foreground: oklch(0.984 0.003 247.858); + --card: oklch(0.208 0.042 265.755); + --card-foreground: oklch(0.984 0.003 247.858); + --popover: oklch(0.208 0.042 265.755); + --popover-foreground: oklch(0.984 0.003 247.858); + --primary: oklch(0.929 0.013 255.508); + --primary-foreground: oklch(0.208 0.042 265.755); + --secondary: oklch(0.279 0.041 260.031); + --secondary-foreground: oklch(0.984 0.003 247.858); + --muted: oklch(0.279 0.041 260.031); + --muted-foreground: oklch(0.704 0.04 256.788); + --accent: oklch(0.279 0.041 260.031); + --accent-foreground: oklch(0.984 0.003 247.858); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.551 0.027 264.364); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.208 0.042 265.755); + --sidebar-foreground: oklch(0.984 0.003 247.858); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.279 0.041 260.031); + --sidebar-accent-foreground: oklch(0.984 0.003 247.858); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.551 0.027 264.364); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/frontend/src/types/Credentials.ts b/frontend/src/types/Credentials.ts new file mode 100644 index 0000000..efec7b3 --- /dev/null +++ b/frontend/src/types/Credentials.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Credentials = { username: string, password: string, next: string | null, }; diff --git a/frontend/src/types/NewUser.ts b/frontend/src/types/NewUser.ts new file mode 100644 index 0000000..4cd2697 --- /dev/null +++ b/frontend/src/types/NewUser.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NewUser = { username: string, password: string, }; diff --git a/frontend/src/types/User.ts b/frontend/src/types/User.ts new file mode 100644 index 0000000..57366e6 --- /dev/null +++ b/frontend/src/types/User.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type User = { id: string, username: string, createdAt: string, updatedAt: string, }; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 358ca9b..0a345fb 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -3,10 +3,13 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2020", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], "module": "ESNext", "skipLibCheck": true, - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, @@ -14,13 +17,20 @@ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", - /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + } }, - "include": ["src"] + "include": [ + "src" + ] } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 1ffef60..696df04 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,7 +1,19 @@ { "files": [], "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + } + } } diff --git a/migrations/20250328091229_add_user_table.down.sql b/migrations/20250328091229_add_user_table.down.sql new file mode 100644 index 0000000..83c54ce --- /dev/null +++ b/migrations/20250328091229_add_user_table.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +drop table users; diff --git a/migrations/20250328091229_add_user_table.up.sql b/migrations/20250328091229_add_user_table.up.sql new file mode 100644 index 0000000..6145d68 --- /dev/null +++ b/migrations/20250328091229_add_user_table.up.sql @@ -0,0 +1,12 @@ +-- Add up migration script here +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +create table users ( + id uuid not null primary key default uuid_generate_v4(), + username varchar(255) not null unique, + password varchar(255) not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +select manage_updated_at('users'); diff --git a/src/api/mod.rs b/src/api/mod.rs index 4b8df8b..86cd728 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -9,14 +9,11 @@ async fn ping() -> StatusCode { StatusCode::OK } -pub fn routes(api_context: ApiContext) -> Router { +pub fn routes() -> Router { let cors = CorsLayer::new() // allow `GET` and `POST` when accessing the resource .allow_methods([Method::GET, Method::POST]) // allow requests from any origin .allow_origin(Any); - Router::new() - .route("/ping", get(ping)) - .layer(cors) - .with_state(api_context) + Router::new().route("/ping", get(ping)).layer(cors) } diff --git a/src/config/auth.rs b/src/config/auth.rs new file mode 100644 index 0000000..d605ed5 --- /dev/null +++ b/src/config/auth.rs @@ -0,0 +1,93 @@ +use axum::{ + Extension, RequestPartsExt, + body::Body, + extract::{FromRequestParts, Request}, + middleware::Next, + response::{IntoResponse, Redirect, Response}, +}; +use axum_login::{AuthnBackend, UserId}; +use http::request::Parts; +use password_auth::verify_password; +use serde::Deserialize; +use tokio::task; +use ts_rs::TS; + +use crate::models::{InertiaError, user::User}; + +use super::ApiContext; + +// This allows us to extract the authentication fields from forms. We use this +// to authenticate requests with the backend. +#[derive(Debug, Clone, Deserialize, TS)] +#[ts(export)] +pub struct Credentials { + pub username: String, + pub password: String, + pub next: Option, +} + +#[async_trait::async_trait] +impl AuthnBackend for ApiContext { + type User = User; + type Credentials = Credentials; + type Error = InertiaError; + + async fn authenticate( + &self, + creds: Self::Credentials, + ) -> Result, Self::Error> { + let user = sqlx::query_as!( + User, + "select * from users where username = $1", + creds.username + ) + .fetch_optional(&self.db) + .await?; + + // Verifying the password is blocking and potentially slow, so we'll do so via + // `spawn_blocking`. + task::spawn_blocking(|| { + // We're using password-based authentication--this works by comparing our form + // input with an argon2 password hash. + Ok(user.filter(|user| verify_password(creds.password, &user.password).is_ok())) + }) + .await? + } + + async fn get_user(&self, user_id: &UserId) -> Result, Self::Error> { + let user = sqlx::query_as!(User, "select * from users where id = $1", user_id) + .fetch_optional(&self.db) + .await?; + + Ok(user) + } +} + +pub async fn get_user(auth_session: AuthSession, mut req: Request, next: Next) -> Response { + match auth_session.user { + Some(user) => { + req.extensions_mut().insert(user); + next.run(req).await + } + None => Redirect::to("/login").into_response(), + } +} + +impl FromRequestParts for User +where + S: Send + Sync, +{ + type Rejection = Response; + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + let Extension(user) = parts + .extract::>() + .await + .map_err(|_| Redirect::to("/login").into_response())?; + Ok(user) + } +} + +// We use a type alias for convenience. +// +// Note that we've supplied our concrete backend here. +pub type AuthSession = axum_login::AuthSession; diff --git a/src/config/mod.rs b/src/config/mod.rs index cb45540..3bec279 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,4 +1,5 @@ pub mod app; +pub mod auth; pub mod database; pub mod inertia; pub mod logging; diff --git a/src/lib.rs b/src/lib.rs index 25dce8f..33e24f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod api; pub mod config; +pub mod models; pub mod pages; pub mod startup; diff --git a/src/models/error.rs b/src/models/error.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..e6329ea --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,23 @@ +pub mod user; + +use axum::response::{IntoResponse, Redirect, Response}; +use tokio::task; + +#[derive(Debug, thiserror::Error)] +pub enum InertiaError { + #[error(transparent)] + Sqlx(#[from] sqlx::Error), + + #[error(transparent)] + TaskJoin(#[from] task::JoinError), + + #[error("Something went wrong")] + Unknown, +} + +impl IntoResponse for InertiaError { + fn into_response(self) -> Response { + tracing::error!("Error: {:?}", self); + Redirect::to("/error").into_response() + } +} diff --git a/src/models/user.rs b/src/models/user.rs new file mode 100644 index 0000000..6251adf --- /dev/null +++ b/src/models/user.rs @@ -0,0 +1,74 @@ +use axum_login::AuthUser; +use chrono::{DateTime, Utc}; +use password_auth::generate_hash; +use serde::{Deserialize, Serialize}; +use sqlx::{Error, PgConnection, prelude::FromRow, types::Uuid}; +use ts_rs::TS; + +use super::InertiaError; + +#[derive(FromRow, Serialize, Clone, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct User { + pub id: Uuid, + pub username: String, + #[serde(skip)] + pub password: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +// Here we've implemented `Debug` manually to avoid accidentally logging the +// password hash. +impl std::fmt::Debug for User { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("User") + .field("id", &self.id) + .field("username", &self.username) + .field("password", &"[redacted]") + .finish() + } +} + +impl AuthUser for User { + type Id = Uuid; + + fn id(&self) -> Self::Id { + self.id + } + + fn session_auth_hash(&self) -> &[u8] { + self.password.as_bytes() // We use the password hash as the auth + // hash--what this means + // is when the user changes their password the + // auth session becomes invalid. + } +} + +#[derive(Deserialize, Clone, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct NewUser { + pub username: String, + pub password: String, +} + +impl NewUser { + pub async fn register(self, conn: &mut PgConnection) -> Result { + let password = generate_hash(&self.password); + + sqlx::query_as!( + User, + r#" + INSERT INTO users (username, password) + VALUES ($1, $2) + RETURNING * + "#, + self.username, + password, + ) + .fetch_one(conn) + .await + } +} diff --git a/src/pages/auth.rs b/src/pages/auth.rs new file mode 100644 index 0000000..a16352f --- /dev/null +++ b/src/pages/auth.rs @@ -0,0 +1,78 @@ +use axum::{ + Json, Router, + extract::State, + response::{IntoResponse, Redirect}, + routing::get, +}; +use axum_inertia::Inertia; +use http::StatusCode; +use serde_json::json; + +use crate::{ + config::{ + ApiContext, + auth::{AuthSession, Credentials}, + }, + models::{InertiaError, user::NewUser}, +}; + +#[tracing::instrument(name = "Login Page", skip(i))] +async fn login_page(i: Inertia) -> impl IntoResponse { + i.render("Login", json!({})) +} + +#[tracing::instrument(name = "Login attempt", skip(auth_session, creds))] +async fn login(mut auth_session: AuthSession, Json(creds): Json) -> impl IntoResponse { + let user = match auth_session.authenticate(creds.clone()).await { + Ok(Some(user)) => user, + Ok(None) => { + let mut login_url = "/login".to_string(); + if let Some(next) = creds.next { + login_url = format!("{}?next={}", login_url, next); + }; + + return Redirect::to(&login_url).into_response(); + } + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + if auth_session.login(&user).await.is_err() { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + + if let Some(ref next) = creds.next { + Redirect::to(next) + } else { + Redirect::to("/") + } + .into_response() +} + +#[tracing::instrument(name = "Register page", skip(i))] +async fn register_page(i: Inertia) -> impl IntoResponse { + i.render("Register", json!({})) +} + +#[tracing::instrument(name = "Registration attempt", skip(ctx, new_user))] +async fn register( + State(ctx): State, + Json(new_user): Json, +) -> Result { + let mut conn = ctx.db.acquire().await?; + new_user.register(&mut conn).await?; + Ok(Redirect::to("/login").into_response()) +} + +#[tracing::instrument(name = "Logout", skip(auth_session))] +async fn logout(mut auth_session: AuthSession) -> Result { + match auth_session.logout().await { + Ok(_) => Ok(Redirect::to("/login").into_response()), + Err(_) => Err(InertiaError::Unknown), + } +} +pub fn routes() -> Router { + Router::new() + .route("/login", get(login_page).post(login)) + .route("/register", get(register_page).post(register)) + .route("/logout", get(logout)) +} diff --git a/src/pages/mod.rs b/src/pages/mod.rs index ea7d634..ef6f6a5 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,22 +1,47 @@ -use axum::{Router, response::IntoResponse, routing::get}; +mod auth; +use axum::{Router, middleware, response::IntoResponse, routing::get}; use axum_inertia::Inertia; +use axum_login::AuthManagerLayerBuilder; use http::Method; use serde_json::json; use tower_http::cors::CorsLayer; +use tower_sessions::{Expiry, MemoryStore, SessionManagerLayer, cookie::time::Duration}; -use crate::config::ApiContext; +use crate::{ + config::{ApiContext, auth::get_user}, + models::user::User, +}; #[tracing::instrument(name = "Home Page", skip(i))] -async fn home(i: Inertia) -> impl IntoResponse { - i.render("Home", json!({})) +async fn home(i: Inertia, user: User) -> impl IntoResponse { + i.render("Home", json!({ "user": user })) } -pub fn routes(api_context: ApiContext) -> Router { +pub fn routes(api_context: ApiContext) -> Router { let cors = CorsLayer::new() // allow `GET` and `POST` when accessing the resource .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]); + + // Create a session store and layer + let session_store = MemoryStore::default(); + let session_layer = SessionManagerLayer::new(session_store) + .with_expiry(Expiry::OnInactivity(Duration::days(1))); + let auth_layer = AuthManagerLayerBuilder::new(api_context.clone(), session_layer).build(); + + Router::new() + .merge(auth::routes()) + .merge(protected_routes()) + .layer(cors) + .layer(auth_layer) +} + +pub fn protected_routes() -> Router { + let cors = CorsLayer::new() + // allow `GET` and `POST` when accessing the resource + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]); + Router::new() .route("/", get(home)) .layer(cors) - .with_state(api_context) + .route_layer(middleware::from_fn(get_user)) } diff --git a/src/startup.rs b/src/startup.rs index 0614bd0..949fe4c 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -18,7 +18,8 @@ pub async fn build(settings: Settings) -> anyhow::Result<()> { tracing::info!("Creating router..."); let mut router = Router::new() .merge(page_routes(api_context.clone())) - .nest("/api", api_routes(api_context)) + .nest("/api", api_routes()) + .with_state(api_context) .layer(TraceLayer::new_for_http()); if !settings.is_dev {