diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css
index 85455c3..8d6d009 100644
--- a/clients/web/assets/style.css
+++ b/clients/web/assets/style.css
@@ -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-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 {
color: var(--ui-gold);
text-decoration: none;
diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json
index 03ba37c..978d902 100644
--- a/clients/web/locales/en.json
+++ b/clients/web/locales/en.json
@@ -140,5 +140,12 @@
"nickname_modal_sign_in": "Sign in",
"nickname_modal_register": "Create account",
"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."
}
diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json
index d429838..f41446b 100644
--- a/clients/web/locales/fr.json
+++ b/clients/web/locales/fr.json
@@ -138,5 +138,12 @@
"nickname_modal_sign_in": "connectez-vous",
"nickname_modal_register": "Créer un compte",
"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é."
}
diff --git a/clients/web/src/api.rs b/clients/web/src/api.rs
index c3ae3c4..2452b67 100644
--- a/clients/web/src/api.rs
+++ b/clients/web/src/api.rs
@@ -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 {
let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}")))
.credentials(web_sys::RequestCredentials::Include)
diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs
index c3ae904..6e2186f 100644
--- a/clients/web/src/app.rs
+++ b/clients/web/src/app.rs
@@ -34,6 +34,9 @@ use std::collections::VecDeque;
pub(crate) struct AnonNickname(pub RwSignal>);
#[derive(Clone, Copy)]
pub(crate) struct AuthEmailVerified(pub RwSignal);
+/// One-shot message shown as a top banner and auto-dismissed after a few seconds.
+#[derive(Clone, Copy)]
+pub(crate) struct FlashMessage(pub RwSignal>);
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 > = RwSignal::new(None);
provide_context(AnonNickname(anon_nickname));
+ let flash: RwSignal > = 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! {
+
"Page not found."
}>
@@ -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::().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! {
+
+ { msg }
+ "✕"
+
+ })
+}
+
/// 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]
diff --git a/clients/web/src/portal/profile.rs b/clients/web/src/portal/profile.rs
index c727bbd..ac11bd6 100644
--- a/clients/web/src/portal/profile.rs
+++ b/clients/web/src/portal/profile.rs
@@ -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::>>().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! {
@@ -83,6 +88,106 @@ fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
}
}}
+
+ {move || if is_own_profile() {
+ let uname = profile.username.clone();
+ view! { }.into_any()
+ } else {
+ view! { }.into_any()
+ }}
+ }
+}
+
+#[component]
+fn DeleteAccountSection(username: String) -> impl IntoView {
+ let i18n = use_i18n();
+ let auth_username =
+ use_context::>>().expect("auth_username context not found");
+ let auth_email_verified = use_context::()
+ .expect("auth_email_verified context not found").0;
+ let flash = use_context::().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! {
+
+
{t!(i18n, delete_account_title)}
+ {move || if !confirming.get() {
+ view! {
+
+
+ {t!(i18n, delete_account_warning)}
+
+
{t!(i18n, delete_account_btn)}
+
+ }.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! {
+
+ }.into_any()
+ }}
+
}
}
diff --git a/server/relay-server/src/db.rs b/server/relay-server/src/db.rs
index 83b9f25..0b9c878 100644
--- a/server/relay-server/src/db.rs
+++ b/server/relay-server/src/db.rs
@@ -188,6 +188,25 @@ pub async fn update_password_hash(pool: &Pool, user_id: i64, hash: &str) -> Resu
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 ──────────────────────────────────────────────────────────────
pub async fn create_email_token(
diff --git a/server/relay-server/src/http.rs b/server/relay-server/src/http.rs
index b7dfedd..0104c76 100644
--- a/server/relay-server/src/http.rs
+++ b/server/relay-server/src/http.rs
@@ -19,7 +19,7 @@ use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
- routing::{get, post},
+ routing::{delete, get, post},
};
use axum_login::AuthSession;
use rand::distributions::Alphanumeric;
@@ -48,6 +48,7 @@ pub fn router() -> Router> {
.route("/auth/resend-verification", post(resend_verification))
.route("/auth/forgot-password", post(forgot_password))
.route("/auth/reset-password", post(reset_password))
+ .route("/auth/account", delete(delete_account))
.route("/users/{username}", get(user_profile))
.route("/users/{username}/games", get(user_games))
.route("/games/result", post(game_result))
@@ -286,6 +287,16 @@ async fn logout(mut auth_session: AuthSession) -> Result,
+ State(state): State>,
+) -> Result {
+ 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) -> Result {
match auth_session.user {
Some(user) => Ok(Json(MeResponse {
diff --git a/server/relay-server/src/main.rs b/server/relay-server/src/main.rs
index b416811..367ef98 100644
--- a/server/relay-server/src/main.rs
+++ b/server/relay-server/src/main.rs
@@ -87,7 +87,7 @@ async fn main() {
.allow_origin(AllowOrigin::list([
"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([
HeaderName::from_static("content-type"),
HeaderName::from_static("cookie"),