diff --git a/Cargo.lock b/Cargo.lock
index 8992cbe..c1257d6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -738,7 +738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -926,6 +926,15 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "getopts"
+version = "0.2.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
+dependencies = [
+ "unicode-width",
+]
+
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -2654,6 +2663,25 @@ dependencies = [
"serde",
]
+[[package]]
+name = "pulldown-cmark"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
+dependencies = [
+ "bitflags",
+ "getopts",
+ "memchr",
+ "pulldown-cmark-escape",
+ "unicase",
+]
+
+[[package]]
+name = "pulldown-cmark-escape"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+
[[package]]
name = "qrcodegen"
version = "1.8.0"
@@ -3918,6 +3946,7 @@ dependencies = [
"leptos",
"leptos_i18n",
"leptos_router",
+ "pulldown-cmark",
"qrcodegen",
"rand 0.9.4",
"serde",
@@ -4034,6 +4063,12 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
[[package]]
name = "unicode-xid"
version = "0.2.6"
@@ -4358,7 +4393,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index d722f4f..a468cd0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
[workspace.package]
-version = "0.2.12"
+version = "0.2.13"
[workspace]
resolver = "2"
diff --git a/clients/web/Cargo.toml b/clients/web/Cargo.toml
index 1edb9eb..4b82427 100644
--- a/clients/web/Cargo.toml
+++ b/clients/web/Cargo.toml
@@ -19,6 +19,7 @@ futures = "0.3"
rand = "0.9"
gloo-storage = "0.3"
qrcodegen = "1.8"
+pulldown-cmark = "0.13"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2.118"
diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css
index dcc1b7b..1d4cc77 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;
@@ -2084,6 +2140,20 @@ a:hover { text-decoration: underline; }
border-top: 1px solid rgba(200,164,72,0.12);
}
+.site-nav-infolinks {
+ margin: 2em 0 1em;
+ text-align: center;
+ font-size: 0.9rem;
+ color: rgba(200,164,72,0.4);
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+.site-nav-infolinks > a {
+ width: 100%;
+}
+
.site-nav-version {
margin: 2em 0 1em;
display: block;
@@ -2093,3 +2163,91 @@ a:hover { text-decoration: underline; }
letter-spacing: 0.06em;
color: rgba(200,164,72,0.4);
}
+
+/* ── Content pages (markdown-rendered) ─────────────────────────────────────── */
+
+.content-page h1 {
+ font-family: var(--font-display);
+ font-size: 2rem;
+ font-weight: 600;
+ color: var(--ui-ink);
+ letter-spacing: 0.04em;
+ margin-bottom: 0.5rem;
+}
+.content-page h2 {
+ font-family: var(--font-display);
+ font-size: 1.4rem;
+ font-weight: 600;
+ color: var(--ui-ink);
+ margin: 1.75rem 0 0.5rem;
+ border-bottom: 1px solid rgba(200,164,72,0.25);
+ padding-bottom: 0.25rem;
+}
+.content-page h3 {
+ font-family: var(--font-display);
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--ui-ink);
+ margin: 1.25rem 0 0.4rem;
+}
+.content-page p {
+ line-height: 1.7;
+ margin-bottom: 0.9rem;
+ color: var(--ui-ink);
+}
+.content-page ul,
+.content-page ol {
+ margin: 0.5rem 0 1rem 1.5rem;
+ line-height: 1.7;
+ color: var(--ui-ink);
+}
+.content-page li {
+ margin-bottom: 0.25rem;
+}
+.content-page a {
+ color: var(--ui-gold-dark);
+ text-decoration: underline;
+}
+.content-page a:hover {
+ color: var(--ui-ink);
+}
+.content-page code {
+ font-family: monospace;
+ background: rgba(0,0,0,0.07);
+ border-radius: 3px;
+ padding: 0.1em 0.35em;
+ font-size: 0.88em;
+}
+.content-page pre {
+ background: rgba(0,0,0,0.07);
+ border-radius: 5px;
+ padding: 1rem 1.25rem;
+ overflow-x: auto;
+ margin-bottom: 1rem;
+}
+.content-page pre code {
+ background: none;
+ padding: 0;
+}
+.content-page blockquote {
+ border-left: 3px solid rgba(200,164,72,0.5);
+ margin: 0.75rem 0;
+ padding: 0.25rem 1rem;
+ color: #665544;
+ font-style: italic;
+}
+.content-page table {
+ border-collapse: collapse;
+ width: 100%;
+ margin-bottom: 1rem;
+}
+.content-page th,
+.content-page td {
+ border: 1px solid rgba(200,164,72,0.3);
+ padding: 0.4rem 0.75rem;
+ text-align: left;
+}
+.content-page th {
+ background: rgba(200,164,72,0.1);
+ font-weight: 600;
+}
diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json
index 03ba37c..1e5fbc2 100644
--- a/clients/web/locales/en.json
+++ b/clients/web/locales/en.json
@@ -140,5 +140,14 @@
"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.",
+ "about": "About",
+ "legal": "Legal notices"
}
diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json
index d429838..28ae43c 100644
--- a/clients/web/locales/fr.json
+++ b/clients/web/locales/fr.json
@@ -138,5 +138,14 @@
"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é.",
+ "about": "À propos",
+ "legal": "Mentions légales"
}
diff --git a/clients/web/pages/about/en.md b/clients/web/pages/about/en.md
new file mode 100644
index 0000000..27a382e
--- /dev/null
+++ b/clients/web/pages/about/en.md
@@ -0,0 +1,12 @@
+# About
+
+This application allows you to play [trictrac](https://en.wikipedia.org/wiki/Trictrac) against a friend online or locally against a bot.
+
+The source code is available at [github.com/mmai/trictrac](https://github.com/mmai/trictrac)
+The application is self-hosted and runs on a simple Raspberry Pi.
+
+## Contact & bug Report
+
+For any questions, bug reports, or feedback, you can contact me at rhumbs@rhumbs.fr.
+
+If you encounter an issue during gameplay, you can copy the context of a game by clicking _Take snapshot_ then paste the resulting code into your message, specifying the expected behavior and the incorrect behavior you observed.
diff --git a/clients/web/pages/about/fr.md b/clients/web/pages/about/fr.md
new file mode 100644
index 0000000..1c3ec74
--- /dev/null
+++ b/clients/web/pages/about/fr.md
@@ -0,0 +1,12 @@
+# À propos
+
+Cette application vous permet de jouer au [trictrac](https://fr.wikipedia.org/wiki/Trictrac) contre un ami en ligne ou localement contre un bot.
+
+Le code source est disponible sur [github.com/mmai/trictrac](https://github.com/mmai/trictrac).
+L'application est auto hébergée et tourne sur un simple Raspberry Pi.
+
+## Contact et rapport de bogue
+
+Pour toute question, rapport de bogue ou retour d'expérience, vous pouvez me contacter à l'adresse rhumbs@rhumbs.fr.
+
+Si vous constatez une anomalie en cours de jeu, vous pouvez copier le contexte d'une partie en cliquant sur _Prendre un instantané_, puis coller le code obtenu dans le message, en précisant le comportement auquel vous vous attendiez, et le comportement erroné constaté.
diff --git a/clients/web/pages/legal/en.md b/clients/web/pages/legal/en.md
new file mode 100644
index 0000000..ff72761
--- /dev/null
+++ b/clients/web/pages/legal/en.md
@@ -0,0 +1,26 @@
+# Legal Notices
+
+## Data and Privacy
+
+This site does not use third-party analytics or advertising trackers.
+
+If you create an account, your username, email address, and argon2-hashed password are stored in a database on our server. Your email address is used only to send you account-related messages (email verification, password reset). It is never shared with third parties.
+
+Game records (room codes, move history, outcomes) may be stored to display game history on your profile page.
+
+## Cookies and Sessions
+
+A session cookie is stored in your browser when you sign in. It is used solely to keep you authenticated and expires after 30 days of inactivity.
+
+## Contact
+
+The website is created by
+
+Henri Bourcereau\
+7 rue Lugeol\
+33000 Bordeaux\
+France
+
+It is hosted at the same address.
+
+You can contact me at rhumbs@rhumbs.fr
diff --git a/clients/web/pages/legal/fr.md b/clients/web/pages/legal/fr.md
new file mode 100644
index 0000000..43f85d5
--- /dev/null
+++ b/clients/web/pages/legal/fr.md
@@ -0,0 +1,28 @@
+# Mentions légales
+
+## Données et vie privée
+
+Ce site n'utilise pas d'outils d'analyse tiers ni de traceurs publicitaires.
+
+Si vous créez un compte, votre nom d'utilisateur, votre adresse e-mail et votre mot de passe haché (argon2) sont stockés dans une base de données sur notre serveur. Votre adresse e-mail est utilisée uniquement pour vous envoyer des messages liés à votre compte (vérification d'e-mail, réinitialisation de mot de passe). Elle n'est jamais partagée avec des tiers.
+
+Les enregistrements de parties (codes de salle, historique des coups, résultats) peuvent être conservés afin d'afficher l'historique des parties sur votre page de profil.
+
+Vous pouvez supprimer votre compte et la totalité des données associées depuis votre page de profil.
+
+## Cookies et sessions
+
+Un cookie de session est stocké dans votre navigateur lorsque vous vous connectez. Il sert uniquement à maintenir votre authentification et expire après 30 jours d'inactivité.
+
+## Contact
+
+Le site est réalisé par
+
+Henri Bourcereau\
+7 rue Lugeol\
+33000 Bordeaux\
+France
+
+Il est hébergé à la même adresse.
+
+Vous pouvez me contacter à l'adresse rhumbs@rhumbs.fr
diff --git a/clients/web/pages/readme.txt b/clients/web/pages/readme.txt
new file mode 100644
index 0000000..ea3df35
--- /dev/null
+++ b/clients/web/pages/readme.txt
@@ -0,0 +1 @@
+Sync this folder to the PAGES_DIR directory of the server running `relay-server`.
diff --git a/clients/web/src/api.rs b/clients/web/src/api.rs
index d826165..2452b67 100644
--- a/clients/web/src/api.rs
+++ b/clients/web/src/api.rs
@@ -64,6 +64,12 @@ pub struct GameDetail {
pub participants: Vec,
}
+#[derive(Clone, Debug, Deserialize)]
+pub struct PageContent {
+ pub title: String,
+ pub content: String,
+}
+
// ── Request bodies ────────────────────────────────────────────────────────────
#[derive(Serialize)]
@@ -141,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)
@@ -242,6 +261,18 @@ pub async fn post_reset_password(token: &str, new_password: &str) -> Result<(),
}
}
+pub async fn get_page(slug: &str, lang: &str) -> Result {
+ let resp = gloo_net::http::Request::get(&url(&format!("/pages/{slug}?lang={lang}")))
+ .send()
+ .await
+ .map_err(|e| e.to_string())?;
+ if resp.status() == 200 {
+ resp.json::().await.map_err(|e| e.to_string())
+ } else {
+ Err(format!("status {}", resp.status()))
+ }
+}
+
// ── Utilities ─────────────────────────────────────────────────────────────────
/// Maps to the `Intl.DateTimeFormat` options object accepted by `Date.toLocaleString`.
diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs
index 9288be3..ba90a54 100644
--- a/clients/web/src/app.rs
+++ b/clients/web/src/app.rs
@@ -21,9 +21,9 @@ use crate::game::trictrac::backend::TrictracBackend;
use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState};
use crate::i18n::*;
use crate::portal::{
- account::AccountPage, forgot_password::ForgotPasswordPage, game_detail::GameDetailPage,
- lobby::LobbyPage, profile::ProfilePage, reset_password::ResetPasswordPage,
- verify_email::VerifyEmailPage,
+ account::AccountPage, content_page::ContentPage, forgot_password::ForgotPasswordPage,
+ game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage,
+ reset_password::ResetPasswordPage, verify_email::VerifyEmailPage,
};
use trictrac_store::CheckerMove;
@@ -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."
}>
@@ -432,6 +438,7 @@ pub fn App() -> impl IntoView {
+
@@ -440,6 +447,34 @@ 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]
@@ -659,6 +694,13 @@ fn SiteHamburger() -> impl IntoView {
sidebar_open.set(false);
}>{t!(i18n, replay_snapshot)}
+
"v" {VERSION}
diff --git a/clients/web/src/portal/content_page.rs b/clients/web/src/portal/content_page.rs
new file mode 100644
index 0000000..f44e3c0
--- /dev/null
+++ b/clients/web/src/portal/content_page.rs
@@ -0,0 +1,51 @@
+use leptos::prelude::*;
+use leptos_router::hooks::use_params_map;
+use pulldown_cmark::{Options, Parser, html};
+
+use crate::api;
+use crate::i18n::*;
+
+#[component]
+pub fn ContentPage() -> impl IntoView {
+ let params = use_params_map();
+ let slug = move || params.read().get("slug").unwrap_or_default();
+ let i18n = use_i18n();
+
+ let page = LocalResource::new(move || {
+ let s = slug();
+ let lang = match i18n.get_locale() {
+ Locale::en => "en",
+ Locale::fr => "fr",
+ };
+ async move { api::get_page(&s, lang).await }
+ });
+
+ view! {
+
+ {move || match page.get().map(|sw| sw.take()) {
+ None => view! {
+
{t!(i18n, loading)}
+ }.into_any(),
+ Some(Err(_)) => view! {
+
"Page not found."
+ }.into_any(),
+ Some(Ok(p)) => {
+ let html = md_to_html(&p.content);
+ view! {
+
+ }.into_any()
+ }
+ }}
+
+ }
+}
+
+fn md_to_html(md: &str) -> String {
+ let mut opts = Options::empty();
+ opts.insert(Options::ENABLE_TABLES);
+ opts.insert(Options::ENABLE_STRIKETHROUGH);
+ let parser = Parser::new_ext(md, opts);
+ let mut output = String::new();
+ html::push_html(&mut output, parser);
+ output
+}
diff --git a/clients/web/src/portal/mod.rs b/clients/web/src/portal/mod.rs
index a270b5f..54a84d1 100644
--- a/clients/web/src/portal/mod.rs
+++ b/clients/web/src/portal/mod.rs
@@ -1,4 +1,5 @@
pub mod account;
+pub mod content_page;
pub mod forgot_password;
pub mod game_detail;
pub mod lobby;
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/container/flake.lock b/container/flake.lock
index 073ffc3..d81bd13 100644
--- a/container/flake.lock
+++ b/container/flake.lock
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
- "lastModified": 1778430510,
- "narHash": "sha256-Ti+ZBvW6yrWWAg2szExVTwCd4qOJ3KlVr1tFHfyfi8Q=",
+ "lastModified": 1779467186,
+ "narHash": "sha256-nOesoDCiXcUftqbRBMz9tt4blI5PvljMWbm3kuCA+0s=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "8fd9daa3db09ced9700431c5b7ad0e8ba199b575",
+ "rev": "b77b3de8775677f84492abe84635f87b0e153f0f",
"type": "github"
},
"original": {
diff --git a/flake.nix b/flake.nix
index 62b1eac..93b33d8 100644
--- a/flake.nix
+++ b/flake.nix
@@ -56,7 +56,7 @@
frontendCargoDeps = rustPlatform.fetchCargoVendor {
src = ./.;
name = "trictrac-frontend-vendor";
- hash = "sha256-neJh0ZQGa5LNY8vBu3kYkM+ARkXOW/EHx8sPBOsWsgE=";
+ hash = "sha256-E3MJbEehbXni7B8fQPS8fSri4f2b0A33r2djiK81E2Y=";
};
in
final.stdenv.mkDerivation {
@@ -103,7 +103,7 @@
trictrac = with final; rustPlatform.buildRustPackage {
pname = "trictrac";
- version = "0.2.12"; # trictrac-version
+ version = "0.2.13"; # trictrac-version
src = ./.;
nativeBuildInputs = [ pkg-config ];
diff --git a/justfile b/justfile
index bc78103..3ae77d1 100644
--- a/justfile
+++ b/justfile
@@ -11,9 +11,12 @@ bump version:
sed -i 's/version = "[0-9.]*"; # trictrac-version/version = "{{version}}"; # trictrac-version/' flake.nix
git add Cargo.toml flake.nix
git commit -m "chore: bump version to {{version}}"
- git flow release start {{version}}
@echo "Done. Finish with: git flow release finish {{version}}"
+# Sync pages content to production server
+pages-deploy:
+ rsync -av clients/web/pages/ raspberry:/var/lib/trictrac/pages/
+
doc:
cargo doc --no-deps
shell:
@@ -47,7 +50,7 @@ build:
[working-directory: 'deploy']
run-relay:
- ./relay-server
+ PAGES_DIR=../clients/web/pages ./relay-server
build-relay:
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release
diff --git a/module.nix b/module.nix
index 53f77c6..28bec85 100644
--- a/module.nix
+++ b/module.nix
@@ -29,6 +29,12 @@ in
description = "Web server protocol.";
};
+ pages_dir = mkOption {
+ type = types.str;
+ default = "/var/lib/trictrac/pages";
+ description = "Directory containing content pages.";
+ };
+
hostname = mkOption {
type = types.str;
default = "trictrac.localhost";
@@ -132,9 +138,9 @@ in
# Explicit listen so this vhost isn't shadowed by a default_server
# created by other virtual hosts with forceSSL = true.
listen = [
- { addr = "0.0.0.0"; port = listenPort; ssl = withSSL; }
- { addr = "[::]"; port = listenPort; ssl = withSSL; }
- ];
+ { addr = "0.0.0.0"; port = listenPort; ssl = withSSL; }
+ { addr = "[::]"; port = listenPort; ssl = withSSL; }
+ ];
locations."/" = {
extraConfig = proxyConfig;
proxyPass = "http://trictrac-api/";
@@ -195,6 +201,7 @@ in
environment = {
DATABASE_URL = "postgresql://${cfg.user}@127.0.0.1/${cfg.user}";
APP_URL = "${cfg.protocol}://${cfg.hostname}";
+ PAGES_DIR = cfg.pages_dir;
SMTP_HOST = cfg.smtp.host;
SMTP_PORT = toString (if cfg.smtp.port != null then cfg.smtp.port
else if cfg.smtp.tls then 465 else 1025);
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 c8701fc..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,10 +48,12 @@ 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))
.route("/games/{id}", get(game_detail))
+ .route("/pages/{slug}", get(get_page))
}
// ── Token generation ──────────────────────────────────────────────────────────
@@ -285,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 {
@@ -535,3 +547,66 @@ async fn game_result(
Ok(Json(GameResultResponse { game_record_id }))
}
+
+// ── Static content pages ──────────────────────────────────────────────────────
+
+#[derive(Deserialize)]
+struct LangQuery {
+ #[serde(default = "default_lang")]
+ lang: String,
+}
+
+fn default_lang() -> String {
+ "en".to_string()
+}
+
+#[derive(Serialize)]
+struct PageResponse {
+ title: String,
+ content: String,
+}
+
+async fn get_page(
+ Path(slug): Path,
+ Query(query): Query,
+ State(state): State>,
+) -> Result {
+ // Reject slugs with path-traversal characters or unusual lengths.
+ if slug.is_empty()
+ || slug.len() > 64
+ || !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
+ {
+ return Err(AppError::NotFound);
+ }
+ // Normalise lang to a safe identifier.
+ let lang = if !query.lang.is_empty()
+ && query.lang.len() <= 5
+ && query.lang.chars().all(|c| c.is_ascii_alphabetic())
+ {
+ query.lang.to_ascii_lowercase()
+ } else {
+ "en".to_string()
+ };
+
+ let base = std::path::Path::new(&state.pages_dir);
+ let primary = base.join(&slug).join(format!("{lang}.md"));
+
+ let content = match tokio::fs::read_to_string(&primary).await {
+ Ok(c) => c,
+ Err(_) if lang != "en" => {
+ let fallback = base.join(&slug).join("en.md");
+ tokio::fs::read_to_string(&fallback)
+ .await
+ .map_err(|_| AppError::NotFound)?
+ }
+ Err(_) => return Err(AppError::NotFound),
+ };
+
+ let title = content
+ .lines()
+ .find(|l| l.starts_with("# "))
+ .map(|l| l[2..].trim().to_string())
+ .unwrap_or_default();
+
+ Ok(Json(PageResponse { title, content }))
+}
diff --git a/server/relay-server/src/lobby.rs b/server/relay-server/src/lobby.rs
index db1c4f8..db8f57c 100644
--- a/server/relay-server/src/lobby.rs
+++ b/server/relay-server/src/lobby.rs
@@ -63,15 +63,18 @@ pub struct AppState {
pub db: Pool,
/// SMTP mailer for email verification and password reset.
pub mailer: Mailer,
+ /// Directory containing static content pages as `{slug}/{lang}.md` files.
+ pub pages_dir: String,
}
impl AppState {
- pub fn new(db: Pool, mailer: Mailer) -> Self {
+ pub fn new(db: Pool, mailer: Mailer, pages_dir: String) -> Self {
Self {
rooms: Mutex::new(HashMap::new()),
configs: RwLock::new(HashMap::new()),
db,
mailer,
+ pages_dir,
}
}
}
diff --git a/server/relay-server/src/main.rs b/server/relay-server/src/main.rs
index 32baf70..367ef98 100644
--- a/server/relay-server/src/main.rs
+++ b/server/relay-server/src/main.rs
@@ -66,7 +66,8 @@ async fn main() {
let auth_backend = AuthBackend::new(pool.clone());
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build();
- let app_state = Arc::new(AppState::new(pool, mailer));
+ let pages_dir = std::env::var("PAGES_DIR").unwrap_or_else(|_| "pages".to_string());
+ let app_state = Arc::new(AppState::new(pool, mailer, pages_dir));
let watchdog_state = app_state.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1200)); // 20 Min
@@ -86,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"),