v0.13.3 account delete api, web client and toolchain update

This commit is contained in:
lixen committed 2026-10-12 21:34:06 -04:00
1 parent cc03dc0294
commit ffbf603bc8
16 files changed
+672 -57

No files matched your search

+1
View File
@@ -30,6 +30,7 @@ Go backend server providing a RESTful API for chess gameplay with user authentic
- Authenticated stored-game listing with cursor pagination and filters - Authenticated stored-game listing with cursor pagination and filters
- Configurable structured debug logs for persistence, cleanup, and engine work - Configurable structured debug logs for persistence, cleanup, and engine work
- User management with secure Argon2id password storage and scoped JWTs - User management with secure Argon2id password storage and scoped JWTs
- Self-service account deletion: claims and names are cleared from stored games
- Static, CGO-free binaries (cross-compiles for FreeBSD) - Static, CGO-free binaries (cross-compiles for FreeBSD)
- Scripted deployment: FreeBSD jail (rc.d) or Linux (sandboxed systemd unit) - Scripted deployment: FreeBSD jail (rc.d) or Linux (sandboxed systemd unit)
- Optional hourly integrity sweep of stored games and accounts - Optional hourly integrity sweep of stored games and accounts
+29 -1
View File
@@ -90,6 +90,32 @@ Authorization: Bearer <token>
} }
``` ```
### Delete Account
`DELETE /auth/me`
Deletes the caller's own account. Requires authentication and the account
password again, so a token left on an unattended device is not enough.
**Request:**
```json
{"password": "SecurePass123"}
```
**Response (204):** no body.
What happens, in order: the session ends, so the token stops working at
once; the account's claims are released on every game in memory; then, behind
any gameplay write already queued, the account's claims and name snapshots are
cleared from every stored game and the account is deleted. A game no other
user claims is anonymous from then on and is purged 24 hours after its last
activity; a game another user claims stays with that user, with this side
unnamed. (The administrative `db user delete` keeps claims and names for the
record instead.)
Errors: 400 `INVALID_REQUEST` without a password, 401 for a wrong password or
token, 429 past 5 attempts a minute per IP, 503 `RESOURCE_LIMIT` when password
hashing is saturated or `STORAGE_UNAVAILABLE` when storage is down.
## Game Endpoints ## Game Endpoints
### Health Check ### Health Check
@@ -523,13 +549,15 @@ Error codes:
- Development (`-dev`): 20 requests/second/IP - Development (`-dev`): 20 requests/second/IP
- Registration: 5 requests/minute/IP - Registration: 5 requests/minute/IP
- Login: 10 requests/minute/IP - Login: 10 requests/minute/IP
- Account deletion: 5 requests/minute/IP
Exceeding limit returns 429 status. Behind a reverse proxy the client IP is the Exceeding limit returns 429 status. Behind a reverse proxy the client IP is the
proxy's `X-Real-IP` header, trusted only from addresses listed in proxy's `X-Real-IP` header, trusted only from addresses listed in
`-trusted-proxies`. `-trusted-proxies`.
Password hashing is bounded to four concurrent operations. When all are busy Password hashing is bounded to four concurrent operations. When all are busy
for five seconds, registration and login return 503 with `RESOURCE_LIMIT`. for five seconds, registration, login and account deletion return 503 with
`RESOURCE_LIMIT`.
## JWT Token Format ## JWT Token Format
+13 -13
View File
@@ -1,6 +1,6 @@
module github.com/lixenwraith/chess module github.com/lixenwraith/chess
go 1.27.1 go 1.27.2
require ( require (
github.com/go-playground/validator/v10 v10.30.5 github.com/go-playground/validator/v10 v10.30.5
@@ -8,7 +8,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.11.0 github.com/jackc/pgx/v5 v5.11.0
github.com/lixenwraith/auth v0.5.1 github.com/lixenwraith/auth v0.5.1
golang.org/x/term v0.46.0 golang.org/x/term v0.47.0
) )
require ( require (
@@ -19,19 +19,19 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.3 // indirect
github.com/klauspost/compress v1.20.1 // indirect github.com/klauspost/compress v1.20.3 // indirect
github.com/leodido/go-urn v1.5.0 // indirect github.com/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-colorable v0.1.16 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect github.com/mattn/go-runewidth v0.0.31 // indirect
github.com/molecule-man/go-brrr v1.1.1 // indirect github.com/molecule-man/go-brrr v1.3.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect github.com/tinylib/msgp v1.6.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.74.0 // indirect github.com/valyala/fasthttp v1.75.0 // indirect
golang.org/x/crypto v0.57.0 // indirect golang.org/x/crypto v0.58.0 // indirect
golang.org/x/sync v0.23.0 // indirect golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.48.0 // indirect golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.42.0 // indirect golang.org/x/text v0.43.0 // indirect
) )
+26 -26
View File
@@ -25,22 +25,22 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/lixenwraith/auth v0.5.1 h1:5/eKRyEO4af/vpSLsUgAXaFiECNbCKtQ0EEXnO/oPF4= github.com/lixenwraith/auth v0.5.1 h1:5/eKRyEO4af/vpSLsUgAXaFiECNbCKtQ0EEXnO/oPF4=
github.com/lixenwraith/auth v0.5.1/go.mod h1:411JUqnbQgy9jgoFV+fH91TfVUEfzYA4dyPv7f4c264= github.com/lixenwraith/auth v0.5.1/go.mod h1:411JUqnbQgy9jgoFV+fH91TfVUEfzYA4dyPv7f4c264=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/molecule-man/go-brrr v1.1.1 h1:KYwusQhjtV3smALnhmafZf4OBrh7vP4p8O47DyUmmuY= github.com/molecule-man/go-brrr v1.3.0 h1:KEGPkUk9PYFw5+Sv/evCOSU5OmMDXCGFqQ9YjRB7GeU=
github.com/molecule-man/go-brrr v1.1.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= github.com/molecule-man/go-brrr v1.3.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -50,24 +50,24 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tinylib/msgp v1.6.5/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.74.0 h1:wMS9fnO2QTALozYx5pId2Vi7ZwU/epUkY8i/KPWCHoU= github.com/valyala/fasthttp v1.75.0 h1:W3xrvNJHG60j2ZCjxpP8kkB+oPG8kh9/g1n4+3xUwNA=
github.com/valyala/fasthttp v1.74.0/go.mod h1:3ARmLamUcw7ElxVtC8PXaGzQ6VEuvnetlkrwIklQBSE= github.com/valyala/fasthttp v1.75.0/go.mod h1:JiVkPRZU124PuPsreV/s9dZRgG2lRK7wNEikdQ0Rk4s=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+14
View File
@@ -406,6 +406,20 @@ func (g *Game) ClaimSlot(color core.Color, userID string) error {
return nil return nil
} }
// ReleaseUser clears userID's claims and name snapshots, leaving those slots
// anonymous, and reports whether any slot changed. Player IDs stay: once the
// account is gone they identify nobody. Caller must hold the lock.
func (g *Game) ReleaseUser(userID string) bool {
changed := false
for _, player := range g.players {
if player != nil && player.ClaimedBy == userID {
player.ClaimedBy, player.Name = "", ""
changed = true
}
}
return changed
}
// GetSlotOwner returns the userID that claimed the slot, empty if unclaimed // GetSlotOwner returns the userID that claimed the slot, empty if unclaimed
// Caller must hold the lock // Caller must hold the lock
func (g *Game) GetSlotOwner(color core.Color) string { func (g *Game) GetSlotOwner(color core.Color) string {
+53
View File
@@ -30,6 +30,11 @@ type LoginRequest struct {
Password string `json:"password" validate:"required"` Password string `json:"password" validate:"required"`
} }
// DeleteAccountRequest re-confirms the password before an account is deleted.
type DeleteAccountRequest struct {
Password string `json:"password" validate:"required"`
}
// AuthResponse contains JWT token and user information // AuthResponse contains JWT token and user information
type AuthResponse struct { type AuthResponse struct {
Token string `json:"token"` Token string `json:"token"`
@@ -269,3 +274,51 @@ func (h *HTTPHandler) LogoutHandler(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"message": "logged out"}) return c.JSON(fiber.Map{"message": "logged out"})
} }
// DeleteAccountHandler deletes the caller's account after re-checking the
// password. The caller's games lose the claim and the name; see
// Service.DeleteAccount.
func (h *HTTPHandler) DeleteAccountHandler(c *fiber.Ctx) error {
userID, _ := c.Locals("userID").(string)
sessionID, _ := c.Locals("sessionID").(string)
if userID == "" || sessionID == "" {
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "unauthorized",
Code: core.ErrInvalidRequest,
})
}
var req DeleteAccountRequest
if err := c.BodyParser(&req); err != nil || req.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid request body",
Code: core.ErrInvalidRequest,
Details: "password is required to delete the account",
})
}
if err := h.svc.DeleteAccount(userID, sessionID, req.Password); err != nil {
switch {
case errors.Is(err, service.ErrInvalidCredentials):
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid credentials",
Code: core.ErrInvalidRequest,
})
case errors.Is(err, service.ErrAuthBusy):
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication temporarily unavailable", Code: core.ErrResourceLimit,
})
case errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable):
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "account storage unavailable", Code: core.ErrStorageUnavailable,
})
default:
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to delete account",
Code: core.ErrInternalError,
})
}
}
return c.SendStatus(fiber.StatusNoContent)
}
+16
View File
@@ -145,6 +145,22 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
// Logout // Logout
auth.Post("/logout", AuthRequired(validateToken), h.LogoutHandler) auth.Post("/logout", AuthRequired(validateToken), h.LogoutHandler)
// Delete own account: 5 req/min per IP. It takes a password, so it is
// limited like login; authentication runs first, so only a signed-in
// caller reaches the password check.
auth.Delete("/me", AuthRequired(validateToken), limiter.New(limiter.Config{
Max: 5,
Expiration: 1 * time.Minute,
KeyGenerator: clientIP,
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
Error: "rate limit exceeded",
Code: core.ErrRateLimitExceeded,
Details: "5 account deletion attempts per minute allowed",
})
},
}), h.DeleteAccountHandler)
// Game routes with standard rate limiting // Game routes with standard rate limiting
maxReq := rateLimitRate maxReq := rateLimitRate
if devMode { if devMode {
+72
View File
@@ -88,6 +88,7 @@ func TestAPIRoutes(t *testing.T) {
"POST /api/auth/login", "POST /api/auth/login",
"GET /api/auth/me", "GET /api/auth/me",
"POST /api/auth/logout", "POST /api/auth/logout",
"DELETE /api/auth/me",
"POST /api/games", "POST /api/games",
"GET /api/games/:gameId", "GET /api/games/:gameId",
"GET /api/games/:gameId/history", "GET /api/games/:gameId/history",
@@ -212,3 +213,74 @@ func TestStoredGameEndpoints(t *testing.T) {
} }
} }
} }
func TestDeleteAccountRequiresPasswordAndEndsTheSession(t *testing.T) {
store, err := storage.NewStore(pgtest.DSN(t))
if err != nil {
t.Fatal(err)
}
if err := store.InitDB(); err != nil {
t.Fatal(err)
}
svc, err := service.New(store, []byte("test-secret-test-secret-test-secret"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { svc.Shutdown(time.Second) })
app := NewFiberApp(nil, svc, Options{DevMode: true})
send := func(method, path, token, body string) int {
t.Helper()
var reader io.Reader
if body != "" {
reader = strings.NewReader(body)
}
request := httptest.NewRequest(method, path, reader)
if body != "" {
request.Header.Set("Content-Type", "application/json")
}
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
response, err := app.Test(request, 10_000)
if err != nil {
t.Fatal(err)
}
return response.StatusCode
}
user, sessionID, err := svc.RegisterUser("alice", "", "Password1")
if err != nil {
t.Fatal(err)
}
token, err := svc.GenerateUserToken(user.UserID, sessionID)
if err != nil {
t.Fatal(err)
}
for name, test := range map[string]struct {
token, body string
want int
}{
"no token": {"", `{"password":"Password1"}`, 401},
"no password": {token, `{}`, 400},
"wrong password": {token, `{"password":"Password2"}`, 401},
} {
if got := send("DELETE", "/api/auth/me", test.token, test.body); got != test.want {
t.Errorf("%s: status %d, want %d", name, got, test.want)
}
}
if got := send("GET", "/api/auth/me", token, ""); got != 200 {
t.Fatalf("a refused deletion must leave the account working: /auth/me = %d", got)
}
if got := send("DELETE", "/api/auth/me", token, `{"password":"Password1"}`); got != 204 {
t.Fatalf("delete: status %d, want 204", got)
}
if got := send("GET", "/api/auth/me", token, ""); got != 401 {
t.Errorf("token still accepted after deletion: /auth/me = %d", got)
}
if _, _, err := svc.AuthenticateUser("alice", "Password1"); err == nil {
t.Error("deleted account can still sign in")
}
}
+50
View File
@@ -203,6 +203,56 @@ func (s *Service) InvalidateSession(sessionID string) error {
return nil return nil
} }
// DeleteAccount deletes the caller's own account after re-checking the
// password, so a token left on an unattended device is not enough. The
// session goes first, so no new request can act for the account; then the
// account's claims are released on every game in memory, then the store
// clears them from stored games and deletes the account, behind any gameplay
// write already queued. Games nobody else claimed are anonymous from then on
// and are purged after the usual inactivity window.
func (s *Service) DeleteAccount(userID, sessionID, password string) error {
if s.store == nil {
return ErrStorageDisabled
}
record, err := s.store.GetUserByID(userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrInvalidCredentials
}
return fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err)
}
if err := s.verifyPassword(password, record.PasswordHash); err != nil {
if errors.Is(err, ErrAuthBusy) {
return err
}
return ErrInvalidCredentials
}
if err := s.store.DeleteSession(sessionID); err != nil {
return fmt.Errorf("%w: end session: %v", ErrStorageUnavailable, err)
}
released := s.releaseClaims(userID)
if err := s.store.DeleteAccount(userID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%w: delete account: %v", ErrStorageUnavailable, err)
}
slog.Info("account deleted by its owner", "user_id", userID, "live_games_released", released)
return nil
}
// releaseClaims makes userID's slots anonymous in every game in memory and
// returns how many games changed.
func (s *Service) releaseClaims(userID string) int {
s.mu.Lock()
defer s.mu.Unlock()
released := 0
for _, g := range s.games {
if g.ReleaseUser(userID) {
released++
}
}
return released
}
// GetUserByID retrieves user information by user ID // GetUserByID retrieves user information by user ID
func (s *Service) GetUserByID(userID string) (*User, error) { func (s *Service) GetUserByID(userID string) (*User, error) {
if s.store == nil { if s.store == nil {
+43
View File
@@ -5,6 +5,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/lixenwraith/chess/internal/server/chess"
"github.com/lixenwraith/chess/internal/server/core"
"github.com/lixenwraith/chess/internal/server/storage" "github.com/lixenwraith/chess/internal/server/storage"
"github.com/google/uuid" "github.com/google/uuid"
@@ -146,3 +148,44 @@ func TestNewRejectsShortJWTSecret(t *testing.T) {
t.Fatal("31-byte JWT secret accepted") t.Fatal("31-byte JWT secret accepted")
} }
} }
func TestDeleteAccountReleasesLiveClaims(t *testing.T) {
svc := newPersistentTestService(t)
user, sessionID, err := svc.RegisterUser("alice", "", "Password1")
if err != nil {
t.Fatal(err)
}
token, err := svc.GenerateUserToken(user.UserID, sessionID)
if err != nil {
t.Fatal(err)
}
gameID := uuid.NewString()
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
white.ID, white.ClaimedBy = user.UserID, user.UserID
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerComputer, Level: 1, SearchTime: 100}, core.ColorBlack)
if err := svc.CreateGame(gameID, white, black, chess.StartFEN, core.ColorWhite, core.StateOngoing, core.TermNone); err != nil {
t.Fatal(err)
}
if err := svc.DeleteAccount(user.UserID, sessionID, "Password2"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("wrong password: %v, want ErrInvalidCredentials", err)
}
if _, _, err := svc.ValidateToken(token); err != nil {
t.Fatalf("a refused deletion ended the session: %v", err)
}
if err := svc.DeleteAccount(user.UserID, sessionID, "Password1"); err != nil {
t.Fatal(err)
}
if _, _, err := svc.ValidateToken(token); err == nil {
t.Error("token still valid after deletion")
}
view, err := svc.GetGameView(gameID)
if err != nil {
t.Fatal(err)
}
if owner := view.WhitePlayer.ClaimedBy; owner != "" {
t.Errorf("live game still claimed by the deleted account: %q", owner)
}
}
+31
View File
@@ -284,6 +284,37 @@ func (s *Store) enqueue(operation, gameID string, fn func(context.Context, *sql.
} }
} }
// enqueueWait queues fn on the ordered writer and waits for its own outcome,
// for a write that must be ordered after queued gameplay writes and must also
// report success only once it is durable.
func (s *Store) enqueueWait(ctx context.Context, operation string, fn func(context.Context, *sql.Tx) error) error {
s.enqueueMu.RLock()
if s.closed.Load() {
s.enqueueMu.RUnlock()
return ErrStoreClosed
}
if !s.healthStatus.Load() {
s.enqueueMu.RUnlock()
return ErrStorageDegraded
}
done := make(chan error, 1)
select {
case s.writeChan <- writeRequest{operation: operation, run: fn, barrier: done}:
s.enqueueMu.RUnlock()
case <-ctx.Done():
s.enqueueMu.RUnlock()
return ctx.Err()
}
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err()
}
}
// Flush waits until every write queued before this call has completed. Replay // Flush waits until every write queued before this call has completed. Replay
// reads use this barrier to provide read-after-write consistency while normal // reads use this barrier to provide read-after-write consistency while normal
// gameplay retains the low-latency async write path. // gameplay retains the low-latency async write path.
+69
View File
@@ -837,6 +837,75 @@ func TestClaimsSnapshotPlayerNames(t *testing.T) {
} }
} }
func TestDeleteAccountClearsClaimsAndNames(t *testing.T) {
store := openStore(t, pgtest.DSN(t))
now := time.Now().UTC()
alice, bob := uuid.NewString(), uuid.NewString()
for id, name := range map[string]string{alice: "alice", bob: "bob"} {
if err := store.CreateUser(UserRecord{UserID: id, Username: name, PasswordHash: "hash", CreatedAt: now}); err != nil {
t.Fatal(err)
}
}
if err := store.CreateSession(SessionRecord{
SessionID: uuid.NewString(), UserID: alice, CreatedAt: now, ExpiresAt: now.Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
// Alice against Bob, and Alice alone against the computer.
shared, solo := uuid.NewString(), uuid.NewString()
for _, record := range []GameRecord{
{GameID: shared, InitialFEN: "initial",
WhitePlayerID: alice, WhiteType: 1, WhiteClaimedBy: alice,
BlackPlayerID: bob, BlackType: 1, BlackClaimedBy: bob, StartTimeUTC: now},
{GameID: solo, InitialFEN: "initial",
WhitePlayerID: uuid.NewString(), WhiteType: 2,
BlackPlayerID: alice, BlackType: 1, BlackClaimedBy: alice, StartTimeUTC: now},
} {
if err := store.RecordNewGame(record); err != nil {
t.Fatal(err)
}
}
if err := store.DeleteAccount(alice); err != nil {
t.Fatal(err)
}
if _, err := store.GetUserByID(alice); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("account still present: %v", err)
}
var sessions int
if err := store.db.QueryRow(`SELECT count(*) FROM sessions WHERE user_id = $1`, alice).Scan(&sessions); err != nil || sessions != 0 {
t.Fatalf("sessions left: %d, %v", sessions, err)
}
record, _, err := store.GetGameHistory(shared)
if err != nil || record.WhiteClaimedBy != "" || record.WhiteName != "" ||
record.BlackClaimedBy != bob || record.BlackName != "bob" {
t.Fatalf("shared game after deletion: white %q/%q black %q/%q, %v",
record.WhiteClaimedBy, record.WhiteName, record.BlackClaimedBy, record.BlackName, err)
}
// Nobody claims the solo game now, so the inactivity purge takes it.
if err := store.DeleteAnonymousGames(now.Add(time.Minute), nil); err != nil {
t.Fatal(err)
}
if err := store.Flush(context.Background()); err != nil {
t.Fatal(err)
}
if _, _, err := store.GetGameHistory(solo); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("solo game survived the anonymous purge: %v", err)
}
if _, _, err := store.GetGameHistory(shared); err != nil {
t.Fatalf("a game another user claims must stay: %v", err)
}
if err := store.DeleteAccount(alice); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("second deletion: %v, want sql.ErrNoRows", err)
}
if !store.IsHealthy() {
t.Fatal("deleting a missing account degraded the writer")
}
}
func timePointer(value time.Time) *time.Time { func timePointer(value time.Time) *time.Time {
return &value return &value
} }
+47
View File
@@ -1,6 +1,7 @@
package storage package storage
import ( import (
"context"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -124,6 +125,52 @@ func (s *Store) DeleteUser(userID string) error {
return nil return nil
} }
// DeleteAccount removes an account at its owner's request. Unlike DeleteUser,
// which keeps the claim IDs and name snapshots for the record, it first clears
// the user's claims and names from every stored game, so nothing in the
// database still names the person, and a game nobody else claimed becomes
// anonymous and falls to the inactivity purge. Sessions cascade.
//
// It runs on the ordered game writer, behind every gameplay write already
// queued, so a queued claim cannot land on a game after it was cleared. It
// returns sql.ErrNoRows when no such user exists.
func (s *Store) DeleteAccount(userID string) error {
if !validUUID(userID) {
return sql.ErrNoRows
}
// Set inside the transaction, read after the writer reports back: the
// barrier channel orders the two. A missing row is an answer, not a
// failed write, so it must not degrade the writer.
var deleted int64
// Behind the queue, then its own transaction.
ctx, cancel := context.WithTimeout(context.Background(), flushTimeout+writeTimeout)
defer cancel()
err := s.enqueueWait(ctx, "delete_account", func(ctx context.Context, tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `UPDATE games SET
white_claimed_by = CASE WHEN white_claimed_by = $1 THEN NULL ELSE white_claimed_by END,
white_name = CASE WHEN white_claimed_by = $1 THEN NULL ELSE white_name END,
black_claimed_by = CASE WHEN black_claimed_by = $1 THEN NULL ELSE black_claimed_by END,
black_name = CASE WHEN black_claimed_by = $1 THEN NULL ELSE black_name END
WHERE white_claimed_by = $1 OR black_claimed_by = $1`, userID); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `DELETE FROM users WHERE user_id = $1`, userID)
if err != nil {
return err
}
deleted, err = result.RowsAffected()
return err
})
if err != nil {
return err
}
if deleted == 0 {
return sql.ErrNoRows
}
slog.Debug("storage account deleted", "user_id", userID)
return nil
}
// UpdateUserPassword updates user password hash // UpdateUserPassword updates user password hash
func (s *Store) UpdateUserPassword(userID, passwordHash string) error { func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
return s.execUserUpdate(`UPDATE users SET password_hash = $2 WHERE user_id = $1`, return s.execUserUpdate(`UPDATE users SET password_hash = $2 WHERE user_id = $1`,
@@ -17,6 +17,7 @@ let gameState = {
username: null, username: null,
authBusy: false, authBusy: false,
newGameBusy: false, newGameBusy: false,
accountBusy: false, // logout or account deletion in flight
termination: '', termination: '',
actionBusy: false, // a draw or resign request is in flight actionBusy: false, // a draw or resign request is in flight
}; };
@@ -85,6 +86,9 @@ document.getElementById('login-submit-btn').addEventListener('click', handleLogi
document.getElementById('register-submit-btn').addEventListener('click', handleRegister); document.getElementById('register-submit-btn').addEventListener('click', handleRegister);
document.getElementById('auth-cancel-btn').addEventListener('click', hideAuthModal); document.getElementById('auth-cancel-btn').addEventListener('click', hideAuthModal);
document.getElementById('auth-cancel-btn-2').addEventListener('click', hideAuthModal); document.getElementById('auth-cancel-btn-2').addEventListener('click', hideAuthModal);
document.getElementById('logout-btn').addEventListener('click', handleLogoutClick);
document.getElementById('account-cancel-btn').addEventListener('click', hideAccountModal);
document.getElementById('delete-account-btn').addEventListener('click', deleteAccount);
document.querySelectorAll('.promotion-choice').forEach(btn => { document.querySelectorAll('.promotion-choice').forEach(btn => {
btn.addEventListener('click', () => closePromotion(btn.dataset.piece)); btn.addEventListener('click', () => closePromotion(btn.dataset.piece));
}); });
@@ -143,15 +147,113 @@ function updateAuthIndicator(authenticated) {
function handleAuthClick() { function handleAuthClick() {
if (gameState.authToken) { if (gameState.authToken) {
// Logged in - confirm logout showAccountModal();
if (confirm(`Logout ${gameState.username}?`)) {
handleLogout();
}
} else { } else {
showAuthModal(); showAuthModal();
} }
} }
// Account dialog, for a signed-in user: log out, or delete the account. It
// replaces a native confirm(), which a sandboxed embed may not be allowed.
function showAccountModal() {
document.getElementById('account-username').textContent = gameState.username || '';
document.getElementById('delete-account').open = false;
document.getElementById('delete-password').value = '';
clearModalMessage('account-modal-message');
document.getElementById('account-overlay').classList.add('show');
document.removeEventListener('keydown', handleAccountKeydown);
document.addEventListener('keydown', handleAccountKeydown);
document.getElementById('account-cancel-btn').focus();
}
function hideAccountModal() {
if (gameState.accountBusy) return;
document.getElementById('account-overlay').classList.remove('show');
document.getElementById('delete-password').value = '';
document.removeEventListener('keydown', handleAccountKeydown);
}
function setAccountBusy(busy) {
gameState.accountBusy = busy;
for (const id of ['logout-btn', 'account-cancel-btn', 'delete-account-btn', 'delete-password']) {
document.getElementById(id).disabled = busy;
}
}
function handleAccountKeydown(e) {
if (gameState.accountBusy) {
if (e.key === 'Enter' || e.key === 'Escape') e.preventDefault();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
hideAccountModal();
} else if (e.key === 'Enter' && document.activeElement?.id === 'delete-password') {
e.preventDefault();
deleteAccount();
}
}
async function handleLogoutClick() {
setAccountBusy(true);
await handleLogout();
setAccountBusy(false);
hideAccountModal();
}
// The server re-checks the password, ends the session, clears the account's
// claims and names from its games, then deletes it (DELETE /api/auth/me).
async function deleteAccount() {
if (gameState.accountBusy) return;
const password = document.getElementById('delete-password').value;
if (!password) {
setModalMessage('account-modal-message', 'Enter your password to delete the account', 'error');
return;
}
setAccountBusy(true);
clearModalMessage('account-modal-message');
let response;
try {
response = await authFetch(`${gameState.apiUrl}/api/auth/me`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
} catch {
setAccountBusy(false);
setModalMessage('account-modal-message', 'Network error: the account was not deleted', 'error');
return;
}
if (response.status === 204) {
const name = gameState.username;
clearAuthState();
setModalMessage('account-modal-message', `Account ${name} deleted`, 'success');
setTimeout(() => {
setAccountBusy(false);
hideAccountModal();
}, MODAL_SUCCESS_DISPLAY_MS * 2);
return;
}
setAccountBusy(false);
const data = await parseErrorResponse(response);
let message;
if (response.status === 401 && data.error === 'invalid credentials') {
message = 'Wrong password';
} else if (response.status === 401) {
// The token itself was refused: the session is already gone.
clearAuthState();
message = 'Your session has ended. Log in again to delete the account.';
} else if (response.status === 429) {
message = 'Too many attempts. Wait a minute and try again.';
} else {
message = data.error ? `Not deleted: ${data.error}` : `Server error (${response.status})`;
}
setModalMessage('account-modal-message', message, 'error');
}
// Disables/enables every interactive control in the auth modal at once, and tracks // Disables/enables every interactive control in the auth modal at once, and tracks
// whether a login/register request is in flight (or its success message is showing). // whether a login/register request is in flight (or its success message is showing).
// Guards re-entrancy from the Enter-key handler, which bypasses individual button // Guards re-entrancy from the Enter-key handler, which bypasses individual button
@@ -117,14 +117,40 @@
</div> </div>
</div> </div>
<!-- Account Modal: shown instead of the login modal while signed in -->
<div id="account-overlay" class="modal-overlay">
<div class="modal account-modal" role="dialog" aria-modal="true" aria-labelledby="account-title">
<h2 id="account-title">Account</h2>
<div id="account-modal-message" class="modal-message" role="status" aria-live="polite"></div>
<p class="account-name">Signed in as <strong id="account-username"></strong></p>
<div class="modal-buttons">
<button id="logout-btn" class="btn btn-primary">Log out</button>
<button id="account-cancel-btn" class="btn btn-secondary">Close</button>
</div>
<details class="danger-zone" id="delete-account">
<summary>Delete account</summary>
<p>Deletes the account and signs you out. Your name and claim are removed
from every stored game; games nobody else plays are deleted 24 hours
after their last move. This cannot be undone.</p>
<div class="form-group account-form">
<label for="delete-password">Password</label>
<input type="password" id="delete-password" autocomplete="current-password">
</div>
<div class="modal-buttons">
<button id="delete-account-btn" class="btn btn-danger">Delete account</button>
</div>
</details>
</div>
</div>
<!-- Overlay Modal --> <!-- Overlay Modal -->
<div id="modal-overlay" class="modal-overlay"> <div id="modal-overlay" class="modal-overlay">
<div class="modal"> <div class="modal">
<h2>New Game</h2> <h2>New Game</h2>
<div id="new-game-modal-message" class="modal-message"></div> <div id="new-game-modal-message" class="modal-message" role="status" aria-live="polite"></div>
<div class="form-group"> <div class="form-group form-row">
<label class="group-label">Your Color</label> <span class="group-label" id="player-color-label">Your Color</span>
<div class="radio-group"> <div class="radio-group" role="radiogroup" aria-labelledby="player-color-label">
<label><input type="radio" name="player-color" value="white" checked><span>White</span></label> <label><input type="radio" name="player-color" value="white" checked><span>White</span></label>
<label><input type="radio" name="player-color" value="black"><span>Black</span></label> <label><input type="radio" name="player-color" value="black"><span>Black</span></label>
</div> </div>
@@ -490,6 +490,16 @@ main {
transform: translateY(-1px); transform: translateY(-1px);
} }
.btn-danger {
background: var(--tokyo-red);
color: var(--host-bg);
}
.btn-danger:hover:not(:disabled) {
filter: brightness(1.1);
transform: translateY(-1px);
}
.btn:disabled { .btn:disabled {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
@@ -546,7 +556,15 @@ main {
} }
.form-group .group-label { .form-group .group-label {
text-align: center; color: var(--tokyo-cyan);
}
/* Label and control on one line: the colour choice. */
.form-group.form-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
} }
.radio-group { .radio-group {
@@ -643,7 +661,8 @@ input[type="range"]::-webkit-slider-thumb {
.auth-form input[type="text"], .auth-form input[type="text"],
.auth-form input[type="email"], .auth-form input[type="email"],
.auth-form input[type="password"] { .auth-form input[type="password"],
.account-form input[type="password"] {
width: 100%; width: 100%;
padding: 0.5rem; padding: 0.5rem;
background: var(--host-bg); background: var(--host-bg);
@@ -653,7 +672,8 @@ input[type="range"]::-webkit-slider-thumb {
font-size: 0.9rem; font-size: 0.9rem;
} }
.auth-form input:focus { .auth-form input:focus,
.account-form input:focus {
outline: none; outline: none;
border-color: var(--host-royal); border-color: var(--host-royal);
} }
@@ -679,18 +699,29 @@ input[type="range"]::-webkit-slider-thumb {
color: var(--tokyo-green); color: var(--tokyo-green);
} }
/* --- Modal status message (Issue 2) --- */ /* --- Modal status message ---
A reserved slot, not a block that appears: an empty message keeps its height
and is only invisible, so a message never moves the form or pushes the
buttons out of a short frame (the embedded board on lixen.com is one). Sized
for one line; a longer message wraps and grows the slot, which the modal's
own scroll absorbs. */
.modal-message { .modal-message {
display: none; display: flex;
margin-bottom: 1rem; align-items: center;
padding: 0.6rem 0.75rem; justify-content: center;
min-height: 2.3rem;
margin-bottom: 0.75rem;
padding: 0.35rem 0.75rem;
border: 1px solid transparent;
border-radius: 6px; border-radius: 6px;
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.3;
text-align: center; text-align: center;
visibility: hidden;
} }
.modal-message.show { .modal-message.show {
display: block; visibility: visible;
} }
.modal-message.error { .modal-message.error {
@@ -706,7 +737,8 @@ input[type="range"]::-webkit-slider-thumb {
} }
/* --- Disabled state while a modal request is in flight (Issue 1) --- */ /* --- Disabled state while a modal request is in flight (Issue 1) --- */
.auth-form input:disabled { .auth-form input:disabled,
.account-form input:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
@@ -956,6 +988,37 @@ input[type="range"]:disabled {
text-shadow: 1px 1px 2px rgba(255,255,255,0.2); text-shadow: 1px 1px 2px rgba(255,255,255,0.2);
} }
/* Account */
.account-modal {
max-width: 360px;
}
.account-name {
text-align: center;
}
.danger-zone {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--tokyo-border);
}
.danger-zone summary {
cursor: pointer;
color: var(--tokyo-red);
text-align: center;
}
.danger-zone p {
margin: 0.75rem 0 1rem;
font-size: 0.85rem;
line-height: 1.4;
}
.danger-zone .modal-buttons {
margin-top: 1rem;
}
/* Resign confirmation */ /* Resign confirmation */
.confirm-modal { .confirm-modal {
max-width: 320px; max-width: 320px;