Compare commits

...

7 commits

24 changed files with 653 additions and 24 deletions

39
Cargo.lock generated
View file

@ -738,7 +738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -926,6 +926,15 @@ dependencies = [
"version_check", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@ -2654,6 +2663,25 @@ dependencies = [
"serde", "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]] [[package]]
name = "qrcodegen" name = "qrcodegen"
version = "1.8.0" version = "1.8.0"
@ -3918,6 +3946,7 @@ dependencies = [
"leptos", "leptos",
"leptos_i18n", "leptos_i18n",
"leptos_router", "leptos_router",
"pulldown-cmark",
"qrcodegen", "qrcodegen",
"rand 0.9.4", "rand 0.9.4",
"serde", "serde",
@ -4034,6 +4063,12 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.6" version = "0.2.6"
@ -4358,7 +4393,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]

View file

@ -1,5 +1,5 @@
[workspace.package] [workspace.package]
version = "0.2.12" version = "0.2.13"
[workspace] [workspace]
resolver = "2" resolver = "2"

View file

@ -19,6 +19,7 @@ futures = "0.3"
rand = "0.9" rand = "0.9"
gloo-storage = "0.3" gloo-storage = "0.3"
qrcodegen = "1.8" qrcodegen = "1.8"
pulldown-cmark = "0.13"
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2.118" wasm-bindgen = "0.2.118"

View file

@ -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;
@ -2084,6 +2140,20 @@ a:hover { text-decoration: underline; }
border-top: 1px solid rgba(200,164,72,0.12); 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 { .site-nav-version {
margin: 2em 0 1em; margin: 2em 0 1em;
display: block; display: block;
@ -2093,3 +2163,91 @@ a:hover { text-decoration: underline; }
letter-spacing: 0.06em; letter-spacing: 0.06em;
color: rgba(200,164,72,0.4); 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;
}

View file

@ -140,5 +140,14 @@
"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.",
"about": "About",
"legal": "Legal notices"
} }

View file

@ -138,5 +138,14 @@
"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é.",
"about": "À propos",
"legal": "Mentions légales"
} }

View file

@ -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.

View file

@ -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é.

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
Sync this folder to the PAGES_DIR directory of the server running `relay-server`.

View file

@ -64,6 +64,12 @@ pub struct GameDetail {
pub participants: Vec<Participant>, pub participants: Vec<Participant>,
} }
#[derive(Clone, Debug, Deserialize)]
pub struct PageContent {
pub title: String,
pub content: String,
}
// ── Request bodies ──────────────────────────────────────────────────────────── // ── Request bodies ────────────────────────────────────────────────────────────
#[derive(Serialize)] #[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<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)
@ -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<PageContent, String> {
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::<PageContent>().await.map_err(|e| e.to_string())
} else {
Err(format!("status {}", resp.status()))
}
}
// ── Utilities ───────────────────────────────────────────────────────────────── // ── Utilities ─────────────────────────────────────────────────────────────────
/// Maps to the `Intl.DateTimeFormat` options object accepted by `Date.toLocaleString`. /// Maps to the `Intl.DateTimeFormat` options object accepted by `Date.toLocaleString`.

View file

@ -21,9 +21,9 @@ use crate::game::trictrac::backend::TrictracBackend;
use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState}; use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState};
use crate::i18n::*; use crate::i18n::*;
use crate::portal::{ use crate::portal::{
account::AccountPage, forgot_password::ForgotPasswordPage, game_detail::GameDetailPage, account::AccountPage, content_page::ContentPage, forgot_password::ForgotPasswordPage,
lobby::LobbyPage, profile::ProfilePage, reset_password::ResetPasswordPage, game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage,
verify_email::VerifyEmailPage, reset_password::ResetPasswordPage, verify_email::VerifyEmailPage,
}; };
use trictrac_store::CheckerMove; use trictrac_store::CheckerMove;
@ -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 />
@ -432,6 +438,7 @@ pub fn App() -> impl IntoView {
<Route path=path!("/verify-email") view=VerifyEmailPage /> <Route path=path!("/verify-email") view=VerifyEmailPage />
<Route path=path!("/forgot-password") view=ForgotPasswordPage /> <Route path=path!("/forgot-password") view=ForgotPasswordPage />
<Route path=path!("/reset-password") view=ResetPasswordPage /> <Route path=path!("/reset-password") view=ResetPasswordPage />
<Route path=path!("/page/:slug") view=ContentPage />
</Routes> </Routes>
</main> </main>
@ -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::<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]
@ -659,6 +694,13 @@ fn SiteHamburger() -> impl IntoView {
sidebar_open.set(false); sidebar_open.set(false);
}>{t!(i18n, replay_snapshot)}</a> }>{t!(i18n, replay_snapshot)}</a>
</div> </div>
<div>
<div class="site-nav-infolinks">
<a href="/page/about">{t!(i18n, about)}</a>
<span> - </span>
<a href="/page/legal">{t!(i18n, legal)}</a>
</div>
</div>
<div> <div>
<span class="site-nav-version">"v" {VERSION}</span> <span class="site-nav-version">"v" {VERSION}</span>
</div> </div>

View file

@ -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! {
<div class="portal-main">
{move || match page.get().map(|sw| sw.take()) {
None => view! {
<p class="portal-loading">{t!(i18n, loading)}</p>
}.into_any(),
Some(Err(_)) => view! {
<p class="portal-empty">"Page not found."</p>
}.into_any(),
Some(Ok(p)) => {
let html = md_to_html(&p.content);
view! {
<div class="portal-card content-page" inner_html=html />
}.into_any()
}
}}
</div>
}
}
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
}

View file

@ -1,4 +1,5 @@
pub mod account; pub mod account;
pub mod content_page;
pub mod forgot_password; pub mod forgot_password;
pub mod game_detail; pub mod game_detail;
pub mod lobby; pub mod lobby;

View file

@ -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>
} }
} }

6
container/flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1778430510, "lastModified": 1779467186,
"narHash": "sha256-Ti+ZBvW6yrWWAg2szExVTwCd4qOJ3KlVr1tFHfyfi8Q=", "narHash": "sha256-nOesoDCiXcUftqbRBMz9tt4blI5PvljMWbm3kuCA+0s=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "8fd9daa3db09ced9700431c5b7ad0e8ba199b575", "rev": "b77b3de8775677f84492abe84635f87b0e153f0f",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -56,7 +56,7 @@
frontendCargoDeps = rustPlatform.fetchCargoVendor { frontendCargoDeps = rustPlatform.fetchCargoVendor {
src = ./.; src = ./.;
name = "trictrac-frontend-vendor"; name = "trictrac-frontend-vendor";
hash = "sha256-neJh0ZQGa5LNY8vBu3kYkM+ARkXOW/EHx8sPBOsWsgE="; hash = "sha256-E3MJbEehbXni7B8fQPS8fSri4f2b0A33r2djiK81E2Y=";
}; };
in in
final.stdenv.mkDerivation { final.stdenv.mkDerivation {
@ -103,7 +103,7 @@
trictrac = with final; rustPlatform.buildRustPackage { trictrac = with final; rustPlatform.buildRustPackage {
pname = "trictrac"; pname = "trictrac";
version = "0.2.12"; # trictrac-version version = "0.2.13"; # trictrac-version
src = ./.; src = ./.;
nativeBuildInputs = [ pkg-config ]; nativeBuildInputs = [ pkg-config ];

View file

@ -11,9 +11,12 @@ bump version:
sed -i 's/version = "[0-9.]*"; # trictrac-version/version = "{{version}}"; # trictrac-version/' flake.nix sed -i 's/version = "[0-9.]*"; # trictrac-version/version = "{{version}}"; # trictrac-version/' flake.nix
git add Cargo.toml flake.nix git add Cargo.toml flake.nix
git commit -m "chore: bump version to {{version}}" git commit -m "chore: bump version to {{version}}"
git flow release start {{version}}
@echo "Done. Finish with: git flow release finish {{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: doc:
cargo doc --no-deps cargo doc --no-deps
shell: shell:
@ -47,7 +50,7 @@ build:
[working-directory: 'deploy'] [working-directory: 'deploy']
run-relay: run-relay:
./relay-server PAGES_DIR=../clients/web/pages ./relay-server
build-relay: build-relay:
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release

View file

@ -29,6 +29,12 @@ in
description = "Web server protocol."; description = "Web server protocol.";
}; };
pages_dir = mkOption {
type = types.str;
default = "/var/lib/trictrac/pages";
description = "Directory containing content pages.";
};
hostname = mkOption { hostname = mkOption {
type = types.str; type = types.str;
default = "trictrac.localhost"; default = "trictrac.localhost";
@ -132,9 +138,9 @@ in
# Explicit listen so this vhost isn't shadowed by a default_server # Explicit listen so this vhost isn't shadowed by a default_server
# created by other virtual hosts with forceSSL = true. # created by other virtual hosts with forceSSL = true.
listen = [ listen = [
{ addr = "0.0.0.0"; port = listenPort; ssl = withSSL; } { addr = "0.0.0.0"; port = listenPort; ssl = withSSL; }
{ addr = "[::]"; port = listenPort; ssl = withSSL; } { addr = "[::]"; port = listenPort; ssl = withSSL; }
]; ];
locations."/" = { locations."/" = {
extraConfig = proxyConfig; extraConfig = proxyConfig;
proxyPass = "http://trictrac-api/"; proxyPass = "http://trictrac-api/";
@ -195,6 +201,7 @@ in
environment = { environment = {
DATABASE_URL = "postgresql://${cfg.user}@127.0.0.1/${cfg.user}"; DATABASE_URL = "postgresql://${cfg.user}@127.0.0.1/${cfg.user}";
APP_URL = "${cfg.protocol}://${cfg.hostname}"; APP_URL = "${cfg.protocol}://${cfg.hostname}";
PAGES_DIR = cfg.pages_dir;
SMTP_HOST = cfg.smtp.host; SMTP_HOST = cfg.smtp.host;
SMTP_PORT = toString (if cfg.smtp.port != null then cfg.smtp.port SMTP_PORT = toString (if cfg.smtp.port != null then cfg.smtp.port
else if cfg.smtp.tls then 465 else 1025); else if cfg.smtp.tls then 465 else 1025);

View file

@ -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(

View file

@ -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,10 +48,12 @@ 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))
.route("/games/{id}", get(game_detail)) .route("/games/{id}", get(game_detail))
.route("/pages/{slug}", get(get_page))
} }
// ── Token generation ────────────────────────────────────────────────────────── // ── Token generation ──────────────────────────────────────────────────────────
@ -285,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 {
@ -535,3 +547,66 @@ async fn game_result(
Ok(Json(GameResultResponse { game_record_id })) 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<String>,
Query(query): Query<LangQuery>,
State(state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, AppError> {
// 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 }))
}

View file

@ -63,15 +63,18 @@ pub struct AppState {
pub db: Pool, pub db: Pool,
/// SMTP mailer for email verification and password reset. /// SMTP mailer for email verification and password reset.
pub mailer: Mailer, pub mailer: Mailer,
/// Directory containing static content pages as `{slug}/{lang}.md` files.
pub pages_dir: String,
} }
impl AppState { impl AppState {
pub fn new(db: Pool, mailer: Mailer) -> Self { pub fn new(db: Pool, mailer: Mailer, pages_dir: String) -> Self {
Self { Self {
rooms: Mutex::new(HashMap::new()), rooms: Mutex::new(HashMap::new()),
configs: RwLock::new(HashMap::new()), configs: RwLock::new(HashMap::new()),
db, db,
mailer, mailer,
pages_dir,
} }
} }
} }

View file

@ -66,7 +66,8 @@ async fn main() {
let auth_backend = AuthBackend::new(pool.clone()); let auth_backend = AuthBackend::new(pool.clone());
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build(); 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(); let watchdog_state = app_state.clone();
tokio::spawn(async move { tokio::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1200)); // 20 Min 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([ .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"),