diff --git a/Cargo.lock b/Cargo.lock index aac483d..9baf424 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -317,6 +317,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be6ea09c9b96cb5076af0de2e383bd2bc0c18f827cf1967bdd353e0b910d733" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "mime", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-macros" version = "0.4.1" @@ -822,6 +845,17 @@ dependencies = [ "unicode-segmentation", ] +[[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" version = "0.9.4" @@ -5556,6 +5590,7 @@ version = "0.1.0" dependencies = [ "argon2", "axum", + "axum-extra", "dioxus", "dioxus-logger", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index 5400e7d..b201c71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ once_cell = "1.19.0" tokio = { version = "1.38.0", features = [ "macros", "rt-multi-thread" ], optional = true } argon2 = { version = "0.5.3", optional = true } axum = { version = "0.7.5", optional = true } +axum-extra = { version = "0.9.3", features = [ "cookie" ], optional = true} # Debug tracing = "0.1.40" @@ -23,5 +24,5 @@ dioxus-logger = "0.5.0" [features] default = [] -server = ["dioxus/axum", "axum", "surrealdb", "tokio", "argon2" ] +server = ["dioxus/axum", "axum", "axum-extra", "surrealdb", "tokio", "argon2" ] web = ["dioxus/web"] diff --git a/assets/tailwind.css b/assets/tailwind.css index 547056a..00f99cd 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -766,15 +766,6 @@ html { color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); } - .menu li > *:not(ul, .menu-title, details, .btn):active, -.menu li > *:not(ul, .menu-title, details, .btn).active, -.menu li > details > summary:active { - --tw-bg-opacity: 1; - background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))); - --tw-text-opacity: 1; - color: var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity))); - } - .tab:hover { --tw-text-opacity: 1; } @@ -783,6 +774,12 @@ html { --tw-text-opacity: 1; color: var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity))); } + + .table tr.hover:hover, + .table tr.hover:nth-child(even):hover { + --tw-bg-opacity: 1; + background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity))); + } } .btn { @@ -980,18 +977,6 @@ html { } } - :where(.menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(.active, .btn):hover, :where(.menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(.active, .btn):hover { - cursor: pointer; - outline: 2px solid transparent; - outline-offset: 2px; - } - - @supports (color: oklch(0% 0 0)) { - :where(.menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(.active, .btn):hover, :where(.menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(.active, .btn):hover { - background-color: var(--fallback-bc,oklch(var(--bc)/0.1)); - } - } - .tab[disabled], .tab[disabled]:hover { cursor: not-allowed; @@ -1066,12 +1051,6 @@ html { margin-inline-end: -1rem; } -.input-sm[type="number"]::-webkit-inner-spin-button { - margin-top: 0px; - margin-bottom: 0px; - margin-inline-end: -0px; -} - .link { cursor: pointer; text-decoration-line: underline; @@ -1081,59 +1060,6 @@ html { text-decoration-line: none; } -.menu { - display: flex; - flex-direction: column; - flex-wrap: wrap; - font-size: 0.875rem; - line-height: 1.25rem; - padding: 0.5rem; -} - -.menu :where(li ul) { - position: relative; - white-space: nowrap; - margin-inline-start: 1rem; - padding-inline-start: 0.5rem; -} - -.menu :where(li:not(.menu-title) > *:not(ul, details, .menu-title, .btn)), .menu :where(li:not(.menu-title) > details > summary:not(.menu-title)) { - display: grid; - grid-auto-flow: column; - align-content: flex-start; - align-items: center; - gap: 0.5rem; - grid-auto-columns: minmax(auto, max-content) auto max-content; - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; -} - -.menu li.disabled { - cursor: not-allowed; - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - color: var(--fallback-bc,oklch(var(--bc)/0.3)); -} - -.menu :where(li > .menu-dropdown:not(.menu-dropdown-show)) { - display: none; -} - -:where(.menu li) { - position: relative; - display: flex; - flex-shrink: 0; - flex-direction: column; - flex-wrap: wrap; - align-items: stretch; -} - -:where(.menu li) .badge { - justify-self: end; -} - .navbar { display: flex; align-items: center; @@ -1147,11 +1073,6 @@ html { align-items: center; } -.navbar-end { - width: 50%; - justify-content: flex-end; -} - .tabs { display: grid; align-items: flex-end; @@ -1216,6 +1137,39 @@ input.tab:checked + .tab-content, display: block; } +.table { + position: relative; + width: 100%; + border-radius: var(--rounded-box, 1rem); + text-align: left; + font-size: 0.875rem; + line-height: 1.25rem; +} + +.table :where(.table-pin-rows thead tr) { + position: sticky; + top: 0px; + z-index: 1; + --tw-bg-opacity: 1; + background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity))); +} + +.table :where(.table-pin-rows tfoot tr) { + position: sticky; + bottom: 0px; + z-index: 1; + --tw-bg-opacity: 1; + background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity))); +} + +.table :where(.table-pin-cols tr th) { + position: sticky; + left: 0px; + right: 0px; + --tw-bg-opacity: 1; + background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity))); +} + .btm-nav > * .label { font-size: 1rem; line-height: 1.5rem; @@ -1483,88 +1437,6 @@ input.tab:checked + .tab-content, outline-offset: 2px; } -:where(.menu li:empty) { - --tw-bg-opacity: 1; - background-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity))); - opacity: 0.1; - margin: 0.5rem 1rem; - height: 1px; -} - -.menu :where(li ul):before { - position: absolute; - bottom: 0.75rem; - inset-inline-start: 0px; - top: 0.75rem; - width: 1px; - --tw-bg-opacity: 1; - background-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity))); - opacity: 0.1; - content: ""; -} - -.menu :where(li:not(.menu-title) > *:not(ul, details, .menu-title, .btn)), -.menu :where(li:not(.menu-title) > details > summary:not(.menu-title)) { - border-radius: var(--rounded-btn, 0.5rem); - padding-left: 1rem; - padding-right: 1rem; - padding-top: 0.5rem; - padding-bottom: 0.5rem; - text-align: start; - transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; - transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; - transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-timing-function: cubic-bezier(0, 0, 0.2, 1); - transition-duration: 200ms; - text-wrap: balance; -} - -:where(.menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(summary, .active, .btn).focus, :where(.menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(summary, .active, .btn):focus, :where(.menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):is(summary):not(.active, .btn):focus-visible, :where(.menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(summary, .active, .btn).focus, :where(.menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(summary, .active, .btn):focus, :where(.menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):is(summary):not(.active, .btn):focus-visible { - cursor: pointer; - background-color: var(--fallback-bc,oklch(var(--bc)/0.1)); - --tw-text-opacity: 1; - color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); - outline: 2px solid transparent; - outline-offset: 2px; -} - -.menu li > *:not(ul, .menu-title, details, .btn):active, -.menu li > *:not(ul, .menu-title, details, .btn).active, -.menu li > details > summary:active { - --tw-bg-opacity: 1; - background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))); - --tw-text-opacity: 1; - color: var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity))); -} - -.menu :where(li > details > summary)::-webkit-details-marker { - display: none; -} - -.menu :where(li > details > summary):after, -.menu :where(li > .menu-dropdown-toggle):after { - justify-self: end; - display: block; - margin-top: -0.5rem; - height: 0.5rem; - width: 0.5rem; - transform: rotate(45deg); - transition-property: transform, margin-top; - transition-duration: 0.3s; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - content: ""; - transform-origin: 75% 75%; - box-shadow: 2px 2px; - pointer-events: none; -} - -.menu :where(li > details[open] > summary):after, -.menu :where(li > .menu-dropdown-toggle.menu-dropdown-show):after { - transform: rotate(225deg); - margin-top: 0; -} - .mockup-browser .mockup-browser-toolbar .input { position: relative; margin-left: auto; @@ -1798,6 +1670,45 @@ input.tab:checked + .tab-content, color: var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity))); } +:is([dir="rtl"] .table) { + text-align: right; +} + +.table :where(th, td) { + padding-left: 1rem; + padding-right: 1rem; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + vertical-align: middle; +} + +.table tr.active, + .table tr.active:nth-child(even), + .table-zebra tbody tr:nth-child(even) { + --tw-bg-opacity: 1; + background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity))); +} + +.table :where(thead tr, tbody tr:not(:last-child),tbody tr:first-child:last-child) { + border-bottom-width: 1px; + --tw-border-opacity: 1; + border-bottom-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity))); +} + +.table :where(thead, tfoot) { + white-space: nowrap; + font-size: 0.75rem; + line-height: 1rem; + font-weight: 700; + color: var(--fallback-bc,oklch(var(--bc)/0.6)); +} + +.table :where(tfoot) { + border-top-width: 1px; + --tw-border-opacity: 1; + border-top-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity))); +} + @keyframes toast-pop { 0% { transform: scale(0.9); @@ -1810,44 +1721,6 @@ input.tab:checked + .tab-content, } } -.btn-sm { - height: 2rem; - min-height: 2rem; - padding-left: 0.75rem; - padding-right: 0.75rem; - font-size: 0.875rem; -} - -.btn-square:where(.btn-sm) { - height: 2rem; - width: 2rem; - padding: 0px; -} - -.btn-circle:where(.btn-sm) { - height: 2rem; - width: 2rem; - border-radius: 9999px; - padding: 0px; -} - -.input-sm { - height: 2rem; - padding-left: 0.75rem; - padding-right: 0.75rem; - font-size: 0.875rem; - line-height: 2rem; -} - -.menu-horizontal { - display: inline-flex; - flex-direction: row; -} - -.menu-horizontal > li:not(.menu-title) > details > ul { - position: absolute; -} - .tabs-md :where(.tab) { height: 2rem; font-size: 0.875rem; @@ -1890,27 +1763,6 @@ input.tab:checked + .tab-content, line-height: 1.5rem; } -.menu-horizontal > li:not(.menu-title) > details > ul { - margin-inline-start: 0px; - margin-top: 1rem; - padding-top: 0.5rem; - padding-bottom: 0.5rem; - padding-inline-end: 0.5rem; -} - -.menu-horizontal > li > details > ul:before { - content: none; -} - -:where(.menu-horizontal > li:not(.menu-title) > details > ul) { - border-radius: var(--rounded-box, 1rem); - --tw-bg-opacity: 1; - background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity))); - --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); - --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color); - box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); -} - .static { position: static; } @@ -1928,6 +1780,10 @@ input.tab:checked + .tab-content, margin-top: 1.5rem; } +.table { + display: table; +} + .w-full { width: 100%; } @@ -1963,16 +1819,16 @@ input.tab:checked + .tab-content, padding-right: 0.25rem; } -.py-6 { - padding-top: 1.5rem; - padding-bottom: 1.5rem; -} - .py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; } +.py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} + .pt-40 { padding-top: 10rem; } diff --git a/src/server/auth.rs b/src/server/auth.rs index ba8b593..de0229d 100644 --- a/src/server/auth.rs +++ b/src/server/auth.rs @@ -7,8 +7,9 @@ use surrealdb::sql::Thing; #[derive(Debug, Serialize, Deserialize)] pub struct User { - email: String, - password: String, + pub id: String, + pub email: String, + pub password: Option, } #[cfg(feature = "server")] @@ -17,10 +18,92 @@ struct Record { id: Thing, } +#[derive(Debug, Deserialize)] +struct Session { + user_id: String, + id: String, + expires: i64, + token: String, +} + +impl User { + #[cfg(feature = "server")] + async fn from_cookie() -> Result { + use axum_extra::extract::CookieJar; + + let jar: CookieJar = extract().await?; + + let session_token = jar + .get("session_token") + .ok_or_else(|| ServerFnError::new("Session token cookie is not set"))?; + + let session_token = session_token.value(); + + let mut res = DB + .query("SELECT type::string(user.id) as id, user.email as email FROM session WHERE token = $session_token") + .bind(("session_token", session_token)) + .await?; + + let user: Option = res.take(0)?; + + match user { + Some(u) => { + tracing::info!("Authorized session for {}", u.id); + Ok(u) + } + None => Err(ServerFnError::ServerError( + "Could not authorize session".to_string(), + )), + } + } +} + +impl Session { + #[cfg(feature = "server")] + async fn create(user_id: &String) -> Result { + // Create a session + let mut res = DB + .query("CREATE session SET user = type::thing($user_id) RETURN type::string(id) as id, type::string(id) as user_id, time::unix(expires) as expires, token;") + .bind(("user_id", user_id)) + .await?; + + let session: Option = res.take(0)?; + + match session { + Some(s) => { + tracing::info!("Created new session for {}", user_id); + Ok(s) + } + None => Err(ServerFnError::ServerError( + "Could not generate session".to_string(), + )), + } + } + + #[cfg(feature = "server")] + fn write(&self) -> Result<(), ServerFnError> { + use axum::http::{header, HeaderValue}; + use axum_extra::extract::cookie::{Cookie, SameSite}; + + let mut cookie = Cookie::new("session_token", &self.token); + + cookie.set_same_site(SameSite::Strict); + + server_context() + .response_parts_mut() + .unwrap() + .headers + .insert( + header::SET_COOKIE, + HeaderValue::from_str(&cookie.to_string())?, + ); + + Ok(()) + } +} + #[server(Register)] pub async fn register(email: String, password: String) -> Result { - tracing::info!("Creating new user"); - let mut res = DB .query("CREATE user SET email = $email, password = crypto::argon2::generate($password)") .bind(("email", email)) @@ -32,6 +115,10 @@ pub async fn register(email: String, password: String) -> Result { tracing::info!("Created new user ({id})"); + + let session = Session::create(&id.to_raw()).await?; + session.write()?; + Ok(id.to_string()) } _ => Err(ServerFnError::ServerError("Could not get id".to_string())), @@ -40,8 +127,9 @@ pub async fn register(email: String, password: String) -> Result Result { + // Find the user with the correct email and password let mut res = DB - .query("SELECT * FROM user WHERE email = $email AND crypto::argon2::compare(password, $password)") + .query("SELECT type::string(id) as id, email, password FROM user WHERE email = $email AND crypto::argon2::compare(password, $password)") .bind(("email", email)) .bind(("password", password)) .await?; @@ -51,8 +139,17 @@ pub async fn signin(email: String, password: String) -> Result { tracing::info!("User ({}) has signed in", u.email); + + let session = Session::create(&u.id).await?; + session.write()?; + Ok(u) } _ => Err(ServerFnError::ServerError("Could not get id".to_string())), } } + +#[server(GetUser)] +pub async fn get_current_user() -> Result { + User::from_cookie().await +} diff --git a/src/server/surrealdb.rs b/src/server/surrealdb.rs index 710aee3..4e183a7 100644 --- a/src/server/surrealdb.rs +++ b/src/server/surrealdb.rs @@ -15,16 +15,30 @@ pub async fn connect() -> surrealdb::Result<()> { } pub async fn define_schema() -> surrealdb::Result<()> { + // Define user table let sql = " DEFINE TABLE user SCHEMAFULL; DEFINE FIELD email ON TABLE user TYPE string - ASSERT string::is::email($value); + ASSERT string::is::email($value) VALUE string::lowercase($value); DEFINE FIELD password ON TABLE user TYPE string; DEFINE INDEX userEmailIndex ON TABLE user COLUMNS email UNIQUE; "; DB.query(sql).await?; + // Define session table + let sql = " + DEFINE TABLE session SCHEMAFULL; + + DEFINE FIELD user ON TABLE session TYPE record; + DEFINE FIELD token ON TABLE session TYPE string VALUE rand::string(32); + DEFINE FIELD expires ON TABLE session TYPE datetime VALUE time::now() + 1w; + + DEFINE INDEX sessionTokenIndex ON TABLE session COLUMNS token UNIQUE; + "; + + DB.query(sql).await?; + Ok(()) }