From 63d3c2350d90658ac148c033a671f6a588cf9d15 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 03:17:21 -0400 Subject: [PATCH] add: user is_deactivated --- crates/app/src/langs/en-US.toml | 4 + crates/app/src/macros.rs | 13 +++ crates/app/src/public/html/mod/profile.lisp | 10 ++ .../app/src/public/html/profile/settings.lisp | 92 ++++++++++++++----- crates/app/src/public/html/root.lisp | 50 +++++++++- crates/app/src/routes/api/v1/auth/profile.rs | 32 ++++++- crates/app/src/routes/api/v1/mod.rs | 9 ++ crates/core/src/database/auth.rs | 42 ++++++++- crates/core/src/database/common.rs | 5 +- .../src/database/drivers/sql/create_users.sql | 3 +- .../drivers/sql/version_migrations.sql | 4 + crates/core/src/database/posts.rs | 4 +- crates/core/src/model/auth.rs | 5 + 13 files changed, 243 insertions(+), 30 deletions(-) diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 5a2b0e1..3246755 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -184,6 +184,10 @@ version = "1.0.0" "settings:label.generate_invites" = "Generate invites" "settings:label.add_to_stack" = "Add to stack" "settings:label.alt_text" = "Alt text" +"settings:label.deactivate_account" = "Deactivate account" +"settings:label.activate_account" = "Activate account" +"settings:label.deactivate" = "Deactivate" +"settings:label.account_deactivated" = "Account deactivated" "settings:tab.security" = "Security" "settings:tab.blocks" = "Blocks" "settings:tab.billing" = "Billing" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 44669a4..1aa9a2d 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -192,6 +192,19 @@ macro_rules! user_banned { #[macro_export] macro_rules! check_user_blocked_or_private { ($user:expr, $other_user:ident, $data:ident, $jar:ident) => { + // check is_deactivated + if $other_user.is_deactivated { + return Err(Html( + render_error( + Error::GeneralNotFound("user".to_string()), + &$jar, + &$data, + &$user, + ) + .await, + )); + } + // check require_account if $user.is_none() && $other_user.settings.require_account { return Err(Html( diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 6f07c93..2b68c90 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -212,6 +212,11 @@ \"{{ profile.awaiting_purchase }}\", \"checkbox\", ], + [ + [\"is_deactivated\", \"Is deactivated\"], + \"{{ profile.is_deactivated }}\", + \"checkbox\", + ], [ [\"role\", \"Permission level\"], \"{{ profile.permissions }}\", @@ -235,6 +240,11 @@ awaiting_purchase: value, }); }, + is_deactivated: (value) => { + profile_request(false, \"deactivated\", { + is_deactivated: value, + }); + }, role: (new_role) => { return update_user_role(new_role); }, diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index b8e251c..59b64d0 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -284,29 +284,50 @@ ("ui_ident" "delete_account") (div ("class" "card small flex items-center gap-2 red") - (text "{{ icon \"skull\" }}") - (b - (text "{{ text \"settings:label.delete_account\" }}"))) - (form - ("class" "card flex flex-col gap-2") - ("onsubmit" "delete_account(event)") - (div - ("class" "flex flex-col gap-1") - (label - ("for" "current_password") - (text "{{ text \"settings:label.current_password\" }}")) - (input - ("type" "password") - ("name" "current_password") - ("id" "current_password") - ("placeholder" "current_password") - ("required" "") - ("minlength" "6") - ("autocomplete" "off"))) - (button - (text "{{ icon \"trash\" }}") - (span - (text "{{ text \"general:action.delete\" }}"))))) + (icon (text "skull")) + (b (str (text "communities:label.danger_zone")))) + (div + ("class" "card lowered flex flex-col gap-2") + (details + ("class" "accordion") + (summary + ("class" "flex items-center gap-2") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (str (text "settings:label.deactivate_account"))) + (div + ("class" "inner flex flex-col gap-2") + (p (text "Deactivating your account will treat it as deleted, but all your data will be recoverable if you change your mind. This option is recommended over a full deletion.")) + (button + ("onclick" "deactivate_account()") + (icon (text "lock")) + (span + (str (text "settings:label.deactivate")))))) + (details + ("class" "accordion") + (summary + ("class" "flex items-center gap-2") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (str (text "settings:label.delete_account"))) + (form + ("class" "inner flex flex-col gap-2") + ("onsubmit" "delete_account(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "current_password") + (text "{{ text \"settings:label.current_password\" }}")) + (input + ("type" "password") + ("name" "current_password") + ("id" "current_password") + ("placeholder" "current_password") + ("required" "") + ("minlength" "6") + ("autocomplete" "off"))) + (button + (text "{{ icon \"trash\" }}") + (span + (text "{{ text \"general:action.delete\" }}"))))))) (button ("onclick" "save_settings()") ("id" "save_button") @@ -1612,6 +1633,31 @@ }); } + globalThis.deactivate_account = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you want to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/auth/user/{{ profile.id }}/deactivate\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ is_deactivated: true }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + // presets globalThis.apply_preset = async (preset) => { if ( diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index 3dd15a0..5cf7da9 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -77,7 +77,6 @@ (div ("class" "card lowered w-full") (text "{{ user.ban_reason|markdown|safe }}")))))) - ; if we aren't banned, just show the page body (text "{% elif user and user.awaiting_purchase %}") ; account waiting for payment message @@ -142,6 +141,55 @@ } }); }")))))) + (text "{% elif user.is_deactivated -%}") + ; account deactivated message + (article + (main + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2 red") + (icon (text "frown")) + (str (text "settings:label.account_deactivated"))) + + (div + ("class" "card flex flex-col gap-2 no_p_margin") + (p (text "You have deactivated your account. You can undo this with the button below if you'd like.")) + (hr) + (button + ("onclick" "activate_account()") + (icon (text "lock-open")) + (str (text "settings:label.activate_account"))))))) + + (script + (text "globalThis.activate_account = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you want to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/auth/user/{{ user.id }}/deactivate\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ is_deactivated: false }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + };")) (text "{% else %}") ; page body (text "{% block body %}{% endblock %}") diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index fdb71cf..aec31ef 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -5,8 +5,8 @@ use crate::{ routes::api::v1::{ AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken, UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanReason, - UpdateUserInviteCode, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, - UpdateUserUsername, + UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword, + UpdateUserRole, UpdateUserUsername, }, State, }; @@ -372,6 +372,34 @@ pub async fn update_user_awaiting_purchase_request( } } +/// Update the deactivated status of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_is_deactivated_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .update_user_is_deactivated(id, req.is_deactivated, user) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Deactivated status updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + /// Update the role of the given user. /// /// Does not support third-party grants. diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 2420007..60bbf20 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -351,6 +351,10 @@ pub fn routes() -> Router { "/auth/user/{id}/awaiting_purchase", post(auth::profile::update_user_awaiting_purchase_request), ) + .route( + "/auth/user/{id}/deactivate", + post(auth::profile::update_user_is_deactivated_request), + ) .route( "/auth/user/{id}/totp", post(auth::profile::enable_totp_request), @@ -836,6 +840,11 @@ pub struct UpdateUserAwaitingPurchase { pub awaiting_purchase: bool, } +#[derive(Deserialize)] +pub struct UpdateUserIsDeactivated { + pub is_deactivated: bool, +} + #[derive(Deserialize)] pub struct UpdateNotificationRead { pub read: bool, diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index c520794..513be6d 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -121,6 +121,7 @@ impl DataManager { seller_data: serde_json::from_str(&get!(x->27(String)).to_string()).unwrap(), ban_reason: get!(x->28(String)), channel_mutes: serde_json::from_str(&get!(x->29(String)).to_string()).unwrap(), + is_deactivated: get!(x->30(i32)) as i8 == 1, } } @@ -277,7 +278,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, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30)", + "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, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31)", params![ &(data.id as i64), &(data.created as i64), @@ -309,6 +310,7 @@ impl DataManager { &serde_json::to_string(&data.seller_data).unwrap(), &data.ban_reason, &serde_json::to_string(&data.channel_mutes).unwrap(), + &if data.is_deactivated { 1_i32 } else { 0_i32 }, ] ); @@ -626,6 +628,44 @@ impl DataManager { Ok(()) } + pub async fn update_user_is_deactivated(&self, id: usize, x: bool, user: User) -> Result<()> { + if id != user.id && !user.permissions.check(FinePermission::MANAGE_USERS) { + return Err(Error::NotAllowed); + } + + let other_user = self.get_user_by_id(id).await?; + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE users SET is_deactivated = $1 WHERE id = $2", + params![&{ if x { 1 } else { 0 } }, &(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.cache_clear_user(&other_user).await; + + // create audit log entry + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!( + "invoked `update_user_is_deactivated` with x value `{}` and y value `{}`", + other_user.id, x + ), + )) + .await?; + + // ... + Ok(()) + } + pub async fn update_user_password( &self, id: usize, diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 1bf00cf..d37c330 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -44,7 +44,10 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap(); execute!(&conn, common::CREATE_TABLE_APP_DATA).unwrap(); - execute!(&conn, common::VERSION_MIGRATIONS).unwrap(); + + for x in common::VERSION_MIGRATIONS.split(";") { + execute!(&conn, x).unwrap(); + } self.0 .1 diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 1cbbbc8..6a939e5 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -28,5 +28,6 @@ CREATE TABLE IF NOT EXISTS users ( browser_session TEXT NOT NULL, seller_data TEXT NOT NULL, ban_reason TEXT NOT NULL, - channel_mutes TEXT NOT NULL + channel_mutes TEXT NOT NULL, + is_deactivated INT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql index 0f5682b..c0c863a 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -1,3 +1,7 @@ -- users channel_mutes ALTER TABLE users ADD COLUMN IF NOT EXISTS channel_mutes TEXT DEFAULT '[]'; + +-- users is_deactivated +ALTER TABLE users +ADD COLUMN IF NOT EXISTS is_deactivated INT DEFAULT 0; diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 701c053..0891fed 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -397,7 +397,9 @@ impl DataManager { continue; } - if ua.permissions.check_banned() | ignore_users.contains(&owner) + if (ua.permissions.check_banned() + | ignore_users.contains(&owner) + | ua.is_deactivated) && !ua.permissions.check(FinePermission::MANAGE_POSTS) { continue; diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index ffcb264..37d2bf9 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -89,6 +89,10 @@ pub struct User { /// IDs of channels the user has muted. #[serde(default)] pub channel_mutes: Vec, + /// If the user is deactivated. Deactivated users act almost like deleted + /// users, but their data is not wiped. + #[serde(default)] + pub is_deactivated: bool, } pub type UserConnections = @@ -391,6 +395,7 @@ impl User { seller_data: StripeSellerData::default(), ban_reason: String::new(), channel_mutes: Vec::new(), + is_deactivated: false, } }