feat(server): user account deletion
This commit is contained in:
parent
6fd3499d7b
commit
20b8353cfb
9 changed files with 252 additions and 6 deletions
|
|
@ -305,6 +305,62 @@ a:hover { text-decoration: underline; }
|
||||||
.portal-error { color: var(--ui-red-accent); font-size: 0.875rem; margin-top: 0.5rem; }
|
.portal-error { color: var(--ui-red-accent); font-size: 0.875rem; margin-top: 0.5rem; }
|
||||||
.portal-success { color: var(--ui-green-accent); font-size: 0.875rem; margin-top: 0.5rem; }
|
.portal-success { color: var(--ui-green-accent); font-size: 0.875rem; margin-top: 0.5rem; }
|
||||||
|
|
||||||
|
.flash-banner {
|
||||||
|
position: fixed;
|
||||||
|
top: 1.25rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: var(--ui-green-accent);
|
||||||
|
color: #f5edd8;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
max-width: 90vw;
|
||||||
|
animation: flash-in 0.2s ease;
|
||||||
|
}
|
||||||
|
@keyframes flash-in {
|
||||||
|
from { opacity: 0; transform: translateX(-50%) translateY(-0.5rem); }
|
||||||
|
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
}
|
||||||
|
.flash-dismiss {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.flash-dismiss:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.portal-danger-zone {
|
||||||
|
border: 1px solid rgba(122, 30, 42, 0.4);
|
||||||
|
background: rgba(122, 30, 42, 0.04);
|
||||||
|
}
|
||||||
|
.portal-danger-zone h2 {
|
||||||
|
color: var(--ui-red-accent);
|
||||||
|
}
|
||||||
|
.portal-danger-btn {
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--ui-red-accent);
|
||||||
|
color: #f5edd8;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.portal-danger-btn:hover { opacity: 0.85; }
|
||||||
|
.portal-danger-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
|
||||||
.portal-link {
|
.portal-link {
|
||||||
color: var(--ui-gold);
|
color: var(--ui-gold);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
|
||||||
|
|
@ -140,5 +140,12 @@
|
||||||
"nickname_modal_sign_in": "Sign in",
|
"nickname_modal_sign_in": "Sign in",
|
||||||
"nickname_modal_register": "Create account",
|
"nickname_modal_register": "Create account",
|
||||||
"new_game": "New game",
|
"new_game": "New game",
|
||||||
"language": "Language"
|
"language": "Language",
|
||||||
|
"delete_account_title": "Danger zone",
|
||||||
|
"delete_account_btn": "Delete my account",
|
||||||
|
"delete_account_warning": "This action is irreversible. Your account will be permanently deleted.",
|
||||||
|
"delete_account_confirm_label": "Type your username to confirm:",
|
||||||
|
"delete_account_confirm_btn": "Delete permanently",
|
||||||
|
"delete_account_mismatch": "Username does not match.",
|
||||||
|
"account_deleted": "Your account has been permanently deleted."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,5 +138,12 @@
|
||||||
"nickname_modal_sign_in": "connectez-vous",
|
"nickname_modal_sign_in": "connectez-vous",
|
||||||
"nickname_modal_register": "Créer un compte",
|
"nickname_modal_register": "Créer un compte",
|
||||||
"new_game": "Nouvelle partie",
|
"new_game": "Nouvelle partie",
|
||||||
"language": "Langue"
|
"language": "Langue",
|
||||||
|
"delete_account_title": "Zone de danger",
|
||||||
|
"delete_account_btn": "Supprimer mon compte",
|
||||||
|
"delete_account_warning": "Cette action est irréversible. Votre compte sera définitivement supprimé.",
|
||||||
|
"delete_account_confirm_label": "Tapez votre nom d'utilisateur pour confirmer :",
|
||||||
|
"delete_account_confirm_btn": "Supprimer définitivement",
|
||||||
|
"delete_account_mismatch": "Le nom d'utilisateur ne correspond pas.",
|
||||||
|
"account_deleted": "Votre compte a été définitivement supprimé."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,19 @@ pub async fn post_logout() -> Result<(), String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_account() -> Result<(), String> {
|
||||||
|
let resp = gloo_net::http::Request::delete(&url("/auth/account"))
|
||||||
|
.credentials(web_sys::RequestCredentials::Include)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if resp.status() == 204 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("status {}", resp.status()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_user_profile(username: &str) -> Result<UserProfile, String> {
|
pub async fn get_user_profile(username: &str) -> Result<UserProfile, String> {
|
||||||
let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}")))
|
let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}")))
|
||||||
.credentials(web_sys::RequestCredentials::Include)
|
.credentials(web_sys::RequestCredentials::Include)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ use std::collections::VecDeque;
|
||||||
pub(crate) struct AnonNickname(pub RwSignal<Option<String>>);
|
pub(crate) struct AnonNickname(pub RwSignal<Option<String>>);
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub(crate) struct AuthEmailVerified(pub RwSignal<bool>);
|
pub(crate) struct AuthEmailVerified(pub RwSignal<bool>);
|
||||||
|
/// One-shot message shown as a top banner and auto-dismissed after a few seconds.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) struct FlashMessage(pub RwSignal<Option<String>>);
|
||||||
|
|
||||||
fn relay_url() -> String {
|
fn relay_url() -> String {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
|
|
@ -182,6 +185,8 @@ pub fn App() -> impl IntoView {
|
||||||
// Nickname chosen by an anonymous player; used instead of "Anonymous".
|
// Nickname chosen by an anonymous player; used instead of "Anonymous".
|
||||||
let anon_nickname: RwSignal<Option<String>> = RwSignal::new(None);
|
let anon_nickname: RwSignal<Option<String>> = RwSignal::new(None);
|
||||||
provide_context(AnonNickname(anon_nickname));
|
provide_context(AnonNickname(anon_nickname));
|
||||||
|
let flash: RwSignal<Option<String>> = RwSignal::new(None);
|
||||||
|
provide_context(FlashMessage(flash));
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(me) = api::get_me().await {
|
if let Ok(me) = api::get_me().await {
|
||||||
auth_username.set(Some(me.username));
|
auth_username.set(Some(me.username));
|
||||||
|
|
@ -423,6 +428,7 @@ pub fn App() -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<Router>
|
<Router>
|
||||||
<SiteHamburger />
|
<SiteHamburger />
|
||||||
|
<FlashBanner />
|
||||||
<main>
|
<main>
|
||||||
<Routes fallback=|| view! { <p class="portal-empty" style="padding:3rem;text-align:center">"Page not found."</p> }>
|
<Routes fallback=|| view! { <p class="portal-empty" style="padding:3rem;text-align:center">"Page not found."</p> }>
|
||||||
<Route path=path!("/") view=LobbyPage />
|
<Route path=path!("/") view=LobbyPage />
|
||||||
|
|
@ -441,6 +447,28 @@ pub fn App() -> impl IntoView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fixed top banner that shows a flash message and auto-dismisses after 5 seconds.
|
||||||
|
#[component]
|
||||||
|
fn FlashBanner() -> impl IntoView {
|
||||||
|
let flash = use_context::<FlashMessage>().expect("FlashMessage context not found").0;
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if flash.get().is_some() {
|
||||||
|
spawn_local(async move {
|
||||||
|
gloo_timers::future::TimeoutFuture::new(5_000).await;
|
||||||
|
flash.set(None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
move || flash.get().map(|msg| view! {
|
||||||
|
<div class="flash-banner">
|
||||||
|
<span>{ msg }</span>
|
||||||
|
<button class="flash-dismiss" on:click=move |_| flash.set(None)>"✕"</button>
|
||||||
|
</div>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Renders the full-screen game overlay, but only when the current route is "/".
|
/// Renders the full-screen game overlay, but only when the current route is "/".
|
||||||
/// This lets the user navigate to profile/account pages while a game is running.
|
/// This lets the user navigate to profile/account pages while a game is running.
|
||||||
#[component]
|
#[component]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_router::{components::A, hooks::use_params_map};
|
use leptos_router::{components::A, hooks::use_navigate, hooks::use_params_map};
|
||||||
|
|
||||||
use crate::api::{self, GameSummary, UserProfile};
|
use crate::api::{self, GameSummary, UserProfile};
|
||||||
|
use crate::app::{AuthEmailVerified, FlashMessage};
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
|
|
@ -30,6 +31,8 @@ pub fn ProfilePage() -> impl IntoView {
|
||||||
#[component]
|
#[component]
|
||||||
fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
|
fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
|
||||||
let i18n = use_i18n();
|
let i18n = use_i18n();
|
||||||
|
let auth_username =
|
||||||
|
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||||
let page = RwSignal::new(0i64);
|
let page = RwSignal::new(0i64);
|
||||||
let games = LocalResource::new(move || {
|
let games = LocalResource::new(move || {
|
||||||
let u = username.clone();
|
let u = username.clone();
|
||||||
|
|
@ -46,7 +49,9 @@ fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
|
||||||
time_style: None,
|
time_style: None,
|
||||||
};
|
};
|
||||||
let joined = api::format_ts(profile.created_at, locale_tag, &date_format);
|
let joined = api::format_ts(profile.created_at, locale_tag, &date_format);
|
||||||
// let joined = api::format_ts(profile.created_at, locale_tag, &api::DateFormatOptions::date_only());
|
|
||||||
|
let profile_username = profile.username.clone();
|
||||||
|
let is_own_profile = move || auth_username.get().as_deref() == Some(&profile_username);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="portal-card">
|
<div class="portal-card">
|
||||||
|
|
@ -83,6 +88,106 @@ fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{move || if is_own_profile() {
|
||||||
|
let uname = profile.username.clone();
|
||||||
|
view! { <DeleteAccountSection username=uname /> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span /> }.into_any()
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn DeleteAccountSection(username: String) -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
let auth_username =
|
||||||
|
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||||
|
let auth_email_verified = use_context::<AuthEmailVerified>()
|
||||||
|
.expect("auth_email_verified context not found").0;
|
||||||
|
let flash = use_context::<FlashMessage>().expect("FlashMessage context not found").0;
|
||||||
|
let navigate = use_navigate();
|
||||||
|
|
||||||
|
let confirming = RwSignal::new(false);
|
||||||
|
let confirm_input = RwSignal::new(String::new());
|
||||||
|
let error = RwSignal::new(String::new());
|
||||||
|
let pending = RwSignal::new(false);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="portal-card portal-danger-zone">
|
||||||
|
<h2>{t!(i18n, delete_account_title)}</h2>
|
||||||
|
{move || if !confirming.get() {
|
||||||
|
view! {
|
||||||
|
<div>
|
||||||
|
<p class="portal-meta" style="margin-bottom:1rem">
|
||||||
|
{t!(i18n, delete_account_warning)}
|
||||||
|
</p>
|
||||||
|
<button class="portal-danger-btn"
|
||||||
|
on:click=move |_| confirming.set(true)
|
||||||
|
>{t!(i18n, delete_account_btn)}</button>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
// Define submit fresh each reactive call so the closure is FnMut-compatible.
|
||||||
|
let expected = username.clone();
|
||||||
|
let nav = navigate.clone();
|
||||||
|
let submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
if pending.get() { return; }
|
||||||
|
error.set(String::new());
|
||||||
|
|
||||||
|
if confirm_input.get() != expected {
|
||||||
|
error.set(t_string!(i18n, delete_account_mismatch).to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.set(true);
|
||||||
|
let nav = nav.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::delete_account().await {
|
||||||
|
Ok(()) => {
|
||||||
|
auth_username.set(None);
|
||||||
|
auth_email_verified.set(false);
|
||||||
|
flash.set(Some(t_string!(i18n, account_deleted).to_string()));
|
||||||
|
nav("/", Default::default());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error.set(e);
|
||||||
|
pending.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
<form on:submit=submit>
|
||||||
|
<p class="portal-meta" style="margin-bottom:1rem">
|
||||||
|
{t!(i18n, delete_account_warning)}
|
||||||
|
</p>
|
||||||
|
<label class="portal-label">{t!(i18n, delete_account_confirm_label)}</label>
|
||||||
|
<input class="portal-input" type="text" required
|
||||||
|
prop:value=move || confirm_input.get()
|
||||||
|
on:input=move |ev| confirm_input.set(event_target_value(&ev)) />
|
||||||
|
{move || if !error.get().is_empty() {
|
||||||
|
view! { <p class="portal-error">{ error.get() }</p> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span /> }.into_any()
|
||||||
|
}}
|
||||||
|
<div style="display:flex;gap:0.75rem;margin-top:1rem">
|
||||||
|
<button class="portal-danger-btn" type="submit"
|
||||||
|
disabled=move || pending.get()
|
||||||
|
>{t!(i18n, delete_account_confirm_btn)}</button>
|
||||||
|
<button class="portal-page-btn" type="button"
|
||||||
|
on:click=move |_| {
|
||||||
|
confirming.set(false);
|
||||||
|
confirm_input.set(String::new());
|
||||||
|
error.set(String::new());
|
||||||
|
}
|
||||||
|
>{t!(i18n, cancel)}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,25 @@ pub async fn update_password_hash(pool: &Pool, user_id: i64, hash: &str) -> Resu
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Permanently deletes a user and their auth data.
|
||||||
|
/// Game history rows are kept but de-associated (user_id set to NULL).
|
||||||
|
pub async fn delete_user(pool: &Pool, user_id: i64) -> Result<(), DbError> {
|
||||||
|
let client = pool.get().await?;
|
||||||
|
client
|
||||||
|
.execute(
|
||||||
|
"UPDATE game_participants SET user_id = NULL WHERE user_id = $1",
|
||||||
|
&[&user_id],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
client
|
||||||
|
.execute("DELETE FROM email_tokens WHERE user_id = $1", &[&user_id])
|
||||||
|
.await?;
|
||||||
|
client
|
||||||
|
.execute("DELETE FROM users WHERE id = $1", &[&user_id])
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ── Email tokens ──────────────────────────────────────────────────────────────
|
// ── Email tokens ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub async fn create_email_token(
|
pub async fn create_email_token(
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{get, post},
|
routing::{delete, get, post},
|
||||||
};
|
};
|
||||||
use axum_login::AuthSession;
|
use axum_login::AuthSession;
|
||||||
use rand::distributions::Alphanumeric;
|
use rand::distributions::Alphanumeric;
|
||||||
|
|
@ -48,6 +48,7 @@ pub fn router() -> Router<Arc<AppState>> {
|
||||||
.route("/auth/resend-verification", post(resend_verification))
|
.route("/auth/resend-verification", post(resend_verification))
|
||||||
.route("/auth/forgot-password", post(forgot_password))
|
.route("/auth/forgot-password", post(forgot_password))
|
||||||
.route("/auth/reset-password", post(reset_password))
|
.route("/auth/reset-password", post(reset_password))
|
||||||
|
.route("/auth/account", delete(delete_account))
|
||||||
.route("/users/{username}", get(user_profile))
|
.route("/users/{username}", get(user_profile))
|
||||||
.route("/users/{username}/games", get(user_games))
|
.route("/users/{username}/games", get(user_games))
|
||||||
.route("/games/result", post(game_result))
|
.route("/games/result", post(game_result))
|
||||||
|
|
@ -286,6 +287,16 @@ async fn logout(mut auth_session: AuthSession<AuthBackend>) -> Result<StatusCode
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn delete_account(
|
||||||
|
mut auth_session: AuthSession<AuthBackend>,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<StatusCode, AppError> {
|
||||||
|
let user = auth_session.user.clone().ok_or(AppError::Unauthorized)?;
|
||||||
|
auth_session.logout().await.map_err(|_| AppError::Internal)?;
|
||||||
|
db::delete_user(&state.db, user.id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
async fn me(auth_session: AuthSession<AuthBackend>) -> Result<impl IntoResponse, AppError> {
|
async fn me(auth_session: AuthSession<AuthBackend>) -> Result<impl IntoResponse, AppError> {
|
||||||
match auth_session.user {
|
match auth_session.user {
|
||||||
Some(user) => Ok(Json(MeResponse {
|
Some(user) => Ok(Json(MeResponse {
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ async fn main() {
|
||||||
.allow_origin(AllowOrigin::list([
|
.allow_origin(AllowOrigin::list([
|
||||||
"http://localhost:9091".parse().unwrap(), // unified web dev server
|
"http://localhost:9091".parse().unwrap(), // unified web dev server
|
||||||
]))
|
]))
|
||||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
|
||||||
.allow_headers([
|
.allow_headers([
|
||||||
HeaderName::from_static("content-type"),
|
HeaderName::from_static("content-type"),
|
||||||
HeaderName::from_static("cookie"),
|
HeaderName::from_static("cookie"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue