feat(server): user account deletion

This commit is contained in:
Henri Bourcereau 2026-05-25 17:12:23 +02:00
parent 6fd3499d7b
commit 20b8353cfb
9 changed files with 252 additions and 6 deletions

View file

@ -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> {
let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}")))
.credentials(web_sys::RequestCredentials::Include)

View file

@ -34,6 +34,9 @@ use std::collections::VecDeque;
pub(crate) struct AnonNickname(pub RwSignal<Option<String>>);
#[derive(Clone, Copy)]
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 {
#[cfg(debug_assertions)]
@ -182,6 +185,8 @@ pub fn App() -> impl IntoView {
// Nickname chosen by an anonymous player; used instead of "Anonymous".
let anon_nickname: RwSignal<Option<String>> = RwSignal::new(None);
provide_context(AnonNickname(anon_nickname));
let flash: RwSignal<Option<String>> = RwSignal::new(None);
provide_context(FlashMessage(flash));
spawn_local(async move {
if let Ok(me) = api::get_me().await {
auth_username.set(Some(me.username));
@ -423,6 +428,7 @@ pub fn App() -> impl IntoView {
view! {
<Router>
<SiteHamburger />
<FlashBanner />
<main>
<Routes fallback=|| view! { <p class="portal-empty" style="padding:3rem;text-align:center">"Page not found."</p> }>
<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 "/".
/// This lets the user navigate to profile/account pages while a game is running.
#[component]

View file

@ -1,7 +1,8 @@
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::app::{AuthEmailVerified, FlashMessage};
use crate::i18n::*;
#[component]
@ -30,6 +31,8 @@ pub fn ProfilePage() -> impl IntoView {
#[component]
fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
let i18n = use_i18n();
let auth_username =
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
let page = RwSignal::new(0i64);
let games = LocalResource::new(move || {
let u = username.clone();
@ -46,7 +49,9 @@ fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
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, &api::DateFormatOptions::date_only());
let profile_username = profile.username.clone();
let is_own_profile = move || auth_username.get().as_deref() == Some(&profile_username);
view! {
<div class="portal-card">
@ -83,6 +88,106 @@ fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
}
}}
</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>
}
}