From 675b3e4ee65e17e12c5d7a5d6d45aaae12decb89 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 5 Jun 2025 20:56:56 -0400 Subject: [PATCH] add: user associations --- crates/app/src/langs/en-US.toml | 1 + crates/app/src/public/html/mod/profile.lisp | 14 +++++ crates/app/src/public/html/post/post.lisp | 1 + crates/app/src/public/js/me.js | 25 ++++++++- crates/app/src/routes/api/v1/auth/profile.rs | 52 ++++++++++++++++++- crates/app/src/routes/api/v1/mod.rs | 11 +++- crates/app/src/routes/pages/mod_panel.rs | 16 ++++++ crates/core/src/database/auth.rs | 10 ++-- .../src/database/drivers/sql/create_users.sql | 4 +- crates/core/src/model/auth.rs | 4 ++ sql_changes/users_associated.sql | 2 + 11 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 sql_changes/users_associated.sql diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index f364489..0810267 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -170,6 +170,7 @@ version = "1.0.0" "mod_panel:label.permissions_level_builder" = "Permission level builder" "mod_panel:label.warnings" = "Warnings" "mod_panel:label.create_warning" = "Create warning" +"mod_panel:label.associations" = "Associations" "requests:label.requests" = "Requests" "requests:label.community_join_request" = "Community join request" diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index b238e94..c3bdd96 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -188,6 +188,20 @@ ); }, 100); }, 150);")))) + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (text "{{ icon \"users-round\" }}") + (span + (text "{{ text \"mod_panel:label.associations\" }}")))) + (div + ("class" "card tertiary flex flex-wrap gap-2") + (text "{% for user in associations -%}") + (text "{{ components::user_plate(user=user, show_menu=false) }}") + (text "{%- endfor %}"))) (div ("class" "card-nest w-full") (div diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index 82f407c..3c56a56 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -74,6 +74,7 @@ (text "{% if user and user.id == post.owner -%}") (a ("href" "/post/{{ post.id }}#/edit") + ("data-tab-button" "edit") (text "{{ icon \"pen\" }}") (span (text "{{ text \"communities:label.edit_content\" }}"))) diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 8634c00..6cc76af 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -455,6 +455,26 @@ }); // token switcher + self.define("append_associations", (_, tokens) => { + fetch("/api/v1/auth/user/me/append_associations", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tokens, + }), + }) + .then((res) => res.json()) + .then((res) => { + if (res.ok) { + console.log("associations sent"); + } else { + console.warn(res.message); + } + }); + }); + self.define( "set_login_account_tokens", ({ $ }, value) => { @@ -474,7 +494,10 @@ return; } - window.location.href = `/api/v1/auth/token?token=${token}`; + self.append_associations([token]); + setTimeout(() => { + window.location.href = `/api/v1/auth/token?token=${token}`; + }, 150); }); self.define("remove_token", async (_, username) => { diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 95c1430..f12fdd4 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -3,8 +3,8 @@ use crate::{ get_user_from_token, model::{ApiReturn, Error}, routes::api::v1::{ - DeleteUser, DisableTotp, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, - UpdateUserUsername, + AppendAssociations, DeleteUser, DisableTotp, UpdateUserIsVerified, UpdateUserPassword, + UpdateUserRole, UpdateUserUsername, }, State, }; @@ -30,6 +30,7 @@ use tetratto_core::{ #[cfg(feature = "redis")] use redis::Commands; +use tetratto_shared::hash; pub async fn redirect_from_id( Extension(data): Extension, @@ -137,6 +138,53 @@ pub async fn update_user_settings_request( } } +/// Append associations to the current user. +pub async fn append_associations_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + // check existing associations to remove associations to deleted users + // the user should take care of cleaning their ui themselves + for (idx, id) in user.associated.clone().iter().enumerate() { + if data.get_user_by_id(id.to_owned()).await.is_err() { + user.associated.remove(idx); + } + } + + // resolve tokens + for token in req.tokens { + let hashed = hash::hash(token); + let user_from_token = match data.get_user_by_token(&hashed).await { + Ok(ua) => ua, + Err(_) => continue, + }; + + if user.associated.contains(&user_from_token.id) { + // we already know about this; skip + continue; + } + + user.associated.push(user_from_token.id); + } + + // ... + match data.update_user_associated(user.id, user.associated).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Associations updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + /// Update the password of the given user. pub async fn update_user_password_request( jar: CookieJar, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index efd7ea7..a0aab34 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -12,7 +12,7 @@ pub mod util; pub mod channels; use axum::{ - routing::{any, delete, get, post}, + routing::{any, delete, get, post, put}, Router, }; use serde::Deserialize; @@ -207,6 +207,10 @@ pub fn routes() -> Router { get(auth::profile::has_totp_enabled_request), ) .route("/auth/user/me/seen", post(auth::profile::seen_request)) + .route( + "/auth/user/me/append_associations", + put(auth::profile::append_associations_request), + ) .route("/auth/user/find/{id}", get(auth::profile::redirect_from_id)) .route( "/auth/user/find_by_ip/{ip}", @@ -618,3 +622,8 @@ pub struct CreatePostDraft { pub struct VoteInPoll { pub option: PollOption, } + +#[derive(Deserialize)] +pub struct AppendAssociations { + pub tokens: Vec, +} diff --git a/crates/app/src/routes/pages/mod_panel.rs b/crates/app/src/routes/pages/mod_panel.rs index 9ee1e16..4ef7324 100644 --- a/crates/app/src/routes/pages/mod_panel.rs +++ b/crates/app/src/routes/pages/mod_panel.rs @@ -180,9 +180,25 @@ pub async fn manage_profile_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; + let associations = { + let mut out = Vec::new(); + + for id in &profile.associated { + out.push(match data.0.get_user_by_id(id.to_owned()).await { + Ok(ua) => ua, + // TODO: remove from associated on error + Err(_) => continue, + }); + } + + out + }; + let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0, lang, &Some(user)).await; + context.insert("profile", &profile); + context.insert("associations", &associations); // return Ok(Html(data.1.render("mod/profile.html", &context).unwrap())) diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 423eaec..1084c6e 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -11,7 +11,6 @@ use crate::model::{ }; use crate::{auto_method, execute, get, query_row, params}; use pathbufd::PathBufD; -use std::collections::HashMap; use std::fs::{exists, remove_file}; use tetratto_shared::hash::{hash_salted, salt}; use tetratto_shared::unix_epoch_timestamp; @@ -49,6 +48,7 @@ impl DataManager { connections: serde_json::from_str(&get!(x->17(String)).to_string()).unwrap(), stripe_id: get!(x->18(String)), grants: serde_json::from_str(&get!(x->19(String)).to_string()).unwrap(), + associated: serde_json::from_str(&get!(x->20(String)).to_string()).unwrap(), } } @@ -189,7 +189,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)", params![ &(data.id as i64), &(data.created as i64), @@ -209,7 +209,9 @@ impl DataManager { &0_i32, &0_i32, &serde_json::to_string(&data.connections).unwrap(), - &"" + &"", + &serde_json::to_string(&data.grants).unwrap(), + &serde_json::to_string(&data.associated).unwrap(), ] ); @@ -753,7 +755,7 @@ impl DataManager { auto_method!(update_user_grants(Vec)@get_user_by_id -> "UPDATE users SET grants = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_settings(UserSettings)@get_user_by_id -> "UPDATE users SET settings = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_connections(UserConnections)@get_user_by_id -> "UPDATE users SET connections = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); - auto_method!(update_user_subscriptions(HashMap)@get_user_by_id -> "UPDATE users SET subscriptions = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_associated(Vec)@get_user_by_id -> "UPDATE users SET associated = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User); auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index af853bd..fa95be5 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -17,5 +17,7 @@ CREATE TABLE IF NOT EXISTS users ( post_count INT NOT NULL, request_count INT NOT NULL, connections TEXT NOT NULL, - subscriptions TEXT NOT NULL + stripe_id TEXT NOT NULL, + grants TEXT NOT NULL, + associated TEXT NOT NULL ) diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 93a53c2..425a8be 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -46,6 +46,9 @@ pub struct User { /// The grants associated with the user's account. #[serde(default)] pub grants: Vec, + /// A list of the IDs of all accounts the user has signed into through the UI. + #[serde(default)] + pub associated: Vec, } pub type UserConnections = @@ -261,6 +264,7 @@ impl User { connections: HashMap::new(), stripe_id: String::new(), grants: Vec::new(), + associated: Vec::new(), } } diff --git a/sql_changes/users_associated.sql b/sql_changes/users_associated.sql new file mode 100644 index 0000000..453c7d0 --- /dev/null +++ b/sql_changes/users_associated.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN associated TEXT NOT NULL DEFAULT '[]';