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)} +

+ +
+ }.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! { +
+

+ {t!(i18n, delete_account_warning)} +

+ + + {move || if !error.get().is_empty() { + view! {

{ error.get() }

}.into_any() + } else { + view! { }.into_any() + }} +
+ + +
+ + }.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"),