User accounts, login before the title screen, own savegames per user, standalone download

- Service: accounts with Argon2 password hashes and session tokens; the first login creates
  the admin (shared savegames of before move to the admin); the admin creates and deletes
  users; everybody can change the password; savegames are stored and listed per user
- Game: login page before the title (only when the service is reachable), with licence and
  repository link; account page (password, logout; users for the admin); text fields with
  Tab/Enter, masked passwords, browser dialog on touch screens; long status messages wrap
- Download Standalone: DungeonCraft.html with the game embedded (built in the Docker image,
  served to logged-in users via nginx auth_request); works from file:// without server and
  opens downloaded savegames
- Without the service (standalone, local files) there is no login, savegame files as before

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 13:33:22 +01:00
1 parent 8853d84564
commit c9848c8948
15 files changed
+1232 -83

No files matched your search

+162 -4
View File
@@ -4,7 +4,7 @@
// `online` becomes false and the game falls back to savegame files and the browser autosave.
use crate::platform;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
const API: &str = "api"; // relative, so the game also works below a sub path
@@ -37,22 +37,54 @@ enum Req {
Save,
Load,
Delete,
Login,
Me,
Logout,
Password,
Users,
CreateUser,
DeleteUser,
}
// the logged-in account
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Session {
pub token: String,
pub name: String,
pub admin: bool,
}
#[derive(Clone, Debug, Deserialize)]
pub struct UserInfo {
pub name: String,
pub admin: bool,
}
const SESSION_KEY: &str = "dc.session";
pub enum CloudEvent {
None,
Saved(String), // name of the stored savegame
Loaded(Vec<u8>), // savegame data
Deleted,
Error(&'static str), // language key
LoggedIn, // login accepted (or the stored session is still valid)
LoginNeeded, // the service is there but nobody is logged in
LoginFailed(&'static str),
PasswordChanged,
UsersChanged,
}
pub struct Cloud {
pub online: Online,
pub setup: bool, // no account yet: the first login creates the admin
pub session: Option<Session>,
pub list: Vec<SaveInfo>,
pub listed: bool, // the list has been fetched at least once
pub users: Vec<UserInfo>,
pending: Option<(u32, Req)>,
save_name: String,
login_name: String,
}
// percent-encoding for a query parameter
@@ -71,6 +103,7 @@ fn encode(s: &str) -> String {
fn error_key(status: i32) -> &'static str {
match status {
0 => "cloud.err_net",
401 => "login.err_session",
413 => "cloud.err_large",
429 => "cloud.err_rate",
507 => "cloud.err_full",
@@ -83,11 +116,57 @@ impl Cloud {
// starts the availability check
pub fn new() -> Cloud {
let id = platform::http("GET", &format!("{}/health", API), &[]);
Cloud { online: Online::Unknown, list: Vec::new(), listed: false, pending: Some((id, Req::Health)), save_name: String::new() }
let session: Option<Session> = platform::storage_get(SESSION_KEY).and_then(|s| serde_json::from_str(&s).ok());
if let Some(s) = &session {
platform::set_token(&s.token);
}
Cloud {
online: Online::Unknown,
setup: false,
session,
list: Vec::new(),
listed: false,
users: Vec::new(),
pending: Some((id, Req::Health)),
save_name: String::new(),
login_name: String::new(),
}
}
// savegames on the server: the service is there and somebody is logged in
pub fn available(&self) -> bool {
self.online == Online::Yes
self.online == Online::Yes && self.session.is_some()
}
pub fn login(&mut self, name: &str, password: &str) -> bool {
self.login_name = name.to_string();
let body = serde_json::json!({ "name": name, "password": password }).to_string();
self.start("POST", "login", body.as_bytes(), Req::Login)
}
pub fn logout(&mut self) {
self.start("POST", "logout", &[], Req::Logout);
self.session = None;
platform::set_token("");
platform::storage_set(SESSION_KEY, "");
}
pub fn change_password(&mut self, old: &str, new: &str) -> bool {
let body = serde_json::json!({ "old": old, "new": new }).to_string();
self.start("POST", "password", body.as_bytes(), Req::Password)
}
pub fn load_users(&mut self) -> bool {
self.start("GET", "users", &[], Req::Users)
}
pub fn create_user(&mut self, name: &str, password: &str) -> bool {
let body = serde_json::json!({ "name": name, "password": password }).to_string();
self.start("POST", "users", body.as_bytes(), Req::CreateUser)
}
pub fn delete_user(&mut self, name: &str) -> bool {
self.start("DELETE", &format!("users/{}", encode(name)), &[], Req::DeleteUser)
}
pub fn busy(&self) -> bool {
@@ -132,7 +211,86 @@ impl Cloud {
match req {
Req::Health => {
self.online = if ok && res.body.starts_with(b"ok") { Online::Yes } else { Online::No };
CloudEvent::None
self.setup = res.body.ends_with(b"setup");
if self.online != Online::Yes {
return CloudEvent::None;
}
// a stored session: still valid?
if self.session.is_some() && !self.setup {
self.start("GET", "me", &[], Req::Me);
return CloudEvent::None;
}
self.session = None;
CloudEvent::LoginNeeded
}
Req::Me => {
if ok {
CloudEvent::LoggedIn
} else if res.status == 0 {
self.online = Online::No;
CloudEvent::None
} else {
self.session = None;
platform::set_token("");
platform::storage_set(SESSION_KEY, "");
CloudEvent::LoginNeeded
}
}
Req::Login => {
#[derive(Deserialize)]
struct Answer {
token: String,
name: String,
admin: bool,
}
match (ok, serde_json::from_slice::<Answer>(&res.body)) {
(true, Ok(a)) => {
let s = Session { token: a.token, name: a.name, admin: a.admin };
platform::set_token(&s.token);
platform::storage_set(SESSION_KEY, &serde_json::to_string(&s).unwrap_or_default());
self.session = Some(s);
self.setup = false;
CloudEvent::LoggedIn
}
_ => CloudEvent::LoginFailed(match res.status {
0 => "cloud.err_net",
400 => "login.err_invalid",
429 => "cloud.err_rate",
_ => "login.err_wrong",
}),
}
}
Req::Logout => CloudEvent::None,
Req::Password => {
if ok {
CloudEvent::PasswordChanged
} else {
CloudEvent::Error(match res.status {
403 => "account.err_old",
400 => "login.err_invalid",
_ => error_key(res.status),
})
}
}
Req::Users => {
if ok {
self.users = serde_json::from_slice(&res.body).unwrap_or_default();
CloudEvent::None
} else {
CloudEvent::Error(error_key(res.status))
}
}
Req::CreateUser | Req::DeleteUser => {
if ok {
self.load_users();
CloudEvent::UsersChanged
} else {
CloudEvent::Error(match res.status {
409 => "account.err_exists",
400 => "login.err_invalid",
_ => error_key(res.status),
})
}
}
Req::List => {
if !ok {