feat(auth): add /api/me endpoint, avatar upload, and profile picture support
Build and Deploy / build-and-push (push) Successful in 5m7s
Build and Deploy / build-and-push (push) Successful in 5m7s
- Add migration 005: nullable avatar_url column on users table - Add GET /api/me: returns id, username, avatar_url from DB (fixes UUID-on-profile bug) - Add PUT /api/me/avatar: accepts raw image bytes (≤1 MB, jpeg/png/webp/gif), writes to avatars/ dir, updates avatar_url in DB - Serve /avatars via ServeDir so uploaded images are publicly accessible - Update account.html: fetch username from /api/me instead of parsing JWT; add circular avatar display with initials fallback and click-to-upload - Add SolitaireServerClient::fetch_me() for desktop/Android profile display - Add avatar_url field to SyncBackend::SolitaireServer settings (serde default None) - Update sqlx offline query cache for new avatar_url queries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT username FROM users WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "06c945e50567c6801f1346d436cdc86a82a4e13dd45d8286295ba37cdbdc045e"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE users SET avatar_url = ? WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "15d08e02b102147bc74848ec3692b99edbf4c33078191f0132991ee94d4507b1"
|
||||||
|
}
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "INSERT OR IGNORE INTO analytics_events\n (id, user_id, session_id, event_type, payload, client_time, received_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 7
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3"
|
|
||||||
}
|
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT username, avatar_url FROM users WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "avatar_url",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "fae33e7159b43c756131b1545360dcb0250988b43550881e3f0a336d9516dd45"
|
||||||
|
}
|
||||||
@@ -56,6 +56,9 @@ pub enum SyncBackend {
|
|||||||
url: String,
|
url: String,
|
||||||
/// The player's username on that server.
|
/// The player's username on that server.
|
||||||
username: String,
|
username: String,
|
||||||
|
/// Absolute URL of the user's avatar image, or `None` if not set.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
avatar_url: Option<String>,
|
||||||
// JWT tokens are stored in the OS keychain — not here.
|
// JWT tokens are stored in the OS keychain — not here.
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -500,6 +500,53 @@ impl SolitaireServerClient {
|
|||||||
})?;
|
})?;
|
||||||
Ok(format!("{}/replays/{}", self.base_url, id))
|
Ok(format!("{}/replays/{}", self.base_url, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch the authenticated user's profile (`GET /api/me`).
|
||||||
|
///
|
||||||
|
/// Returns `(username, avatar_url)`. `avatar_url` is `None` when the user
|
||||||
|
/// has not set an avatar. Returns an error on network failure or if the
|
||||||
|
/// token is expired and refresh also fails.
|
||||||
|
pub async fn fetch_me(&self) -> Result<(String, Option<String>), SyncError> {
|
||||||
|
let token = self.access_token()?;
|
||||||
|
let url = format!("{}/api/me", self.base_url);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
self.refresh_token().await?;
|
||||||
|
let new_token = self.access_token()?;
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(new_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
return Self::extract_me_body(resp).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::extract_me_body(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn extract_me_body(resp: reqwest::Response) -> Result<(String, Option<String>), SyncError> {
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(SyncError::Network(format!("GET /api/me returned {status}")));
|
||||||
|
}
|
||||||
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
||||||
|
let username = body["username"].as_str().unwrap_or("").to_string();
|
||||||
|
let avatar_url = body["avatar_url"].as_str().map(str::to_string);
|
||||||
|
Ok((username, avatar_url))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -575,7 +622,7 @@ async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, Sync
|
|||||||
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
|
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
|
||||||
match backend {
|
match backend {
|
||||||
SyncBackend::Local => Box::new(LocalOnlyProvider),
|
SyncBackend::Local => Box::new(LocalOnlyProvider),
|
||||||
SyncBackend::SolitaireServer { url, username } => {
|
SyncBackend::SolitaireServer { url, username, .. } => {
|
||||||
Box::new(SolitaireServerClient::new(url.clone(), username.clone()))
|
Box::new(SolitaireServerClient::new(url.clone(), username.clone()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -628,6 +675,7 @@ mod tests {
|
|||||||
let provider = provider_for_backend(&SyncBackend::SolitaireServer {
|
let provider = provider_for_backend(&SyncBackend::SolitaireServer {
|
||||||
url: "https://example.com".to_string(),
|
url: "https://example.com".to_string(),
|
||||||
username: "bob".to_string(),
|
username: "bob".to_string(),
|
||||||
|
avatar_url: None,
|
||||||
});
|
});
|
||||||
assert_eq!(provider.backend_name(), "solitaire_server");
|
assert_eq!(provider.backend_name(), "solitaire_server");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -424,6 +424,7 @@ fn poll_auth_task(
|
|||||||
settings.0.sync_backend = SyncBackend::SolitaireServer {
|
settings.0.sync_backend = SyncBackend::SolitaireServer {
|
||||||
url: url.clone(),
|
url: url.clone(),
|
||||||
username: username.clone(),
|
username: username.clone(),
|
||||||
|
avatar_url: None,
|
||||||
};
|
};
|
||||||
if let Some(path) = &settings_path.0
|
if let Some(path) = &settings_path.0
|
||||||
&& let Err(e) = save_settings_to(path, &settings.0)
|
&& let Err(e) = save_settings_to(path, &settings.0)
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Migration 005: user avatar
|
||||||
|
-- Adds a nullable avatar_url column to users.
|
||||||
|
-- Existing rows receive NULL (no avatar set).
|
||||||
|
ALTER TABLE users ADD COLUMN avatar_url TEXT;
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
//! Authentication handlers: register, login, refresh, delete account.
|
//! Authentication handlers: register, login, refresh, delete account,
|
||||||
|
//! current-user profile, and avatar upload.
|
||||||
|
|
||||||
use axum::{extract::State, Json};
|
use axum::{
|
||||||
|
body::Bytes,
|
||||||
|
extract::State,
|
||||||
|
http::HeaderMap,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
use bcrypt::{hash, verify};
|
use bcrypt::{hash, verify};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||||
@@ -37,6 +43,14 @@ pub struct AuthResponse {
|
|||||||
pub refresh_token: String,
|
pub refresh_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Response for `GET /api/me`.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MeResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Successful refresh response — contains the new access token and the rotated
|
/// Successful refresh response — contains the new access token and the rotated
|
||||||
/// refresh token. The refresh token is always rotated: the client must store
|
/// refresh token. The refresh token is always rotated: the client must store
|
||||||
/// the new value and discard the old one.
|
/// the new value and discard the old one.
|
||||||
@@ -302,6 +316,107 @@ pub async fn delete_account(
|
|||||||
Ok(Json(serde_json::json!({ "ok": true })))
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET /api/me` — return the authenticated user's id, username, and avatar URL.
|
||||||
|
pub async fn get_me(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
) -> Result<Json<MeResponse>, AppError> {
|
||||||
|
struct Row {
|
||||||
|
username: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
let row = sqlx::query_as!(
|
||||||
|
Row,
|
||||||
|
"SELECT username, avatar_url FROM users WHERE id = ?",
|
||||||
|
user.user_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("user not found".into()))?;
|
||||||
|
|
||||||
|
Ok(Json(MeResponse {
|
||||||
|
id: user.user_id,
|
||||||
|
username: row.username.unwrap_or_default(),
|
||||||
|
avatar_url: row.avatar_url,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allowed MIME types for uploaded avatars.
|
||||||
|
const ALLOWED_IMAGE_TYPES: &[&str] = &["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||||
|
/// Maximum avatar upload size in bytes (1 MB).
|
||||||
|
const AVATAR_MAX_BYTES: usize = 1024 * 1024;
|
||||||
|
|
||||||
|
/// `PUT /api/me/avatar` — upload a new avatar image (raw bytes, ≤ 1 MB).
|
||||||
|
///
|
||||||
|
/// The `Content-Type` header must be one of `image/jpeg`, `image/png`,
|
||||||
|
/// `image/webp`, or `image/gif`. The previous avatar file is replaced in-place.
|
||||||
|
pub async fn upload_avatar(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> Result<Json<MeResponse>, AppError> {
|
||||||
|
let mime = headers
|
||||||
|
.get("content-type")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let ext = if mime.contains("jpeg") || mime.contains("jpg") {
|
||||||
|
"jpg"
|
||||||
|
} else if mime.contains("png") {
|
||||||
|
"png"
|
||||||
|
} else if mime.contains("webp") {
|
||||||
|
"webp"
|
||||||
|
} else if mime.contains("gif") {
|
||||||
|
"gif"
|
||||||
|
} else {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"avatar must be image/jpeg, image/png, image/webp, or image/gif".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if !ALLOWED_IMAGE_TYPES.iter().any(|t| mime.starts_with(t)) {
|
||||||
|
return Err(AppError::BadRequest("unsupported image type".into()));
|
||||||
|
}
|
||||||
|
if body.len() > AVATAR_MAX_BYTES {
|
||||||
|
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to avatars/ directory, replacing any previous file for this user.
|
||||||
|
std::fs::create_dir_all("avatars").map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
let filename = format!("{}.{}", user.user_id, ext);
|
||||||
|
let path = std::path::Path::new("avatars").join(&filename);
|
||||||
|
// Remove stale files with other extensions first.
|
||||||
|
for old_ext in &["jpg", "png", "webp", "gif"] {
|
||||||
|
let _ = std::fs::remove_file(
|
||||||
|
std::path::Path::new("avatars").join(format!("{}.{}", user.user_id, old_ext)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::fs::write(&path, &body).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let avatar_url = format!("/avatars/{filename}");
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE users SET avatar_url = ? WHERE id = ?",
|
||||||
|
avatar_url,
|
||||||
|
user.user_id
|
||||||
|
)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let username: Option<String> = sqlx::query_scalar!(
|
||||||
|
"SELECT username FROM users WHERE id = ?",
|
||||||
|
user.user_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(MeResponse {
|
||||||
|
id: user.user_id,
|
||||||
|
username: username.unwrap_or_default(),
|
||||||
|
avatar_url: Some(avatar_url),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Admin helpers (CLI use only — not exposed via HTTP)
|
// Admin helpers (CLI use only — not exposed via HTTP)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ use axum::{
|
|||||||
http::{HeaderValue, Request},
|
http::{HeaderValue, Request},
|
||||||
middleware as axum_middleware,
|
middleware as axum_middleware,
|
||||||
response::{Html, Response},
|
response::{Html, Response},
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
|
||||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -143,6 +144,8 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
|||||||
.route("/api/leaderboard/opt-in", post(leaderboard::opt_in))
|
.route("/api/leaderboard/opt-in", post(leaderboard::opt_in))
|
||||||
.route("/api/leaderboard/opt-in", delete(leaderboard::opt_out))
|
.route("/api/leaderboard/opt-in", delete(leaderboard::opt_out))
|
||||||
.route("/api/account", delete(auth::delete_account))
|
.route("/api/account", delete(auth::delete_account))
|
||||||
|
.route("/api/me", get(auth::get_me))
|
||||||
|
.route("/api/me/avatar", put(auth::upload_avatar))
|
||||||
.layer(axum_middleware::from_fn_with_state(
|
.layer(axum_middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
middleware::require_auth,
|
middleware::require_auth,
|
||||||
@@ -228,6 +231,7 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
|||||||
)
|
)
|
||||||
.nest_service("/web", ServeDir::new("solitaire_server/web"))
|
.nest_service("/web", ServeDir::new("solitaire_server/web"))
|
||||||
.nest_service("/assets", ServeDir::new("assets"))
|
.nest_service("/assets", ServeDir::new("assets"))
|
||||||
|
.nest_service("/avatars", ServeDir::new("avatars"))
|
||||||
.layer(axum_middleware::from_fn(security_headers));
|
.layer(axum_middleware::from_fn(security_headers));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|||||||
@@ -80,14 +80,14 @@
|
|||||||
button[type="submit"]:disabled { opacity: 0.4; cursor: default; }
|
button[type="submit"]:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
/* ── Signed-in state ── */
|
/* ── Signed-in state ── */
|
||||||
#signed-in { display: none; flex-direction: column; gap: 16px; }
|
#signed-in { display: none; flex-direction: column; gap: 16px; align-items: center; }
|
||||||
.username-display {
|
.username-display {
|
||||||
font-size: 20px; font-weight: 700; text-align: center;
|
font-size: 20px; font-weight: 700; text-align: center;
|
||||||
}
|
}
|
||||||
.signed-in-detail {
|
.signed-in-detail {
|
||||||
font-size: 13px; color: var(--text-muted); text-align: center;
|
font-size: 13px; color: var(--text-muted); text-align: center;
|
||||||
}
|
}
|
||||||
.signed-in-actions { display: flex; flex-direction: column; gap: 8px; }
|
.signed-in-actions { display: flex; flex-direction: column; gap: 8px; width: 100%; }
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: var(--panel-hi); color: var(--text);
|
background: var(--panel-hi); color: var(--text);
|
||||||
border: 1px solid var(--border); border-radius: 6px;
|
border: 1px solid var(--border); border-radius: 6px;
|
||||||
@@ -103,6 +103,40 @@
|
|||||||
cursor: pointer; transition: background 120ms;
|
cursor: pointer; transition: background 120ms;
|
||||||
}
|
}
|
||||||
.btn-danger:hover { background: rgba(165, 66, 66, 0.15); }
|
.btn-danger:hover { background: rgba(165, 66, 66, 0.15); }
|
||||||
|
|
||||||
|
/* ── Avatar ── */
|
||||||
|
.avatar-wrap {
|
||||||
|
position: relative; width: 96px; height: 96px; cursor: pointer;
|
||||||
|
border-radius: 50%; overflow: hidden;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.avatar-img {
|
||||||
|
width: 100%; height: 100%; object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.avatar-initials {
|
||||||
|
width: 100%; height: 100%; border-radius: 50%;
|
||||||
|
background: var(--panel-hi); display: flex;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
font-size: 32px; font-weight: 700; color: var(--text-muted);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.avatar-overlay {
|
||||||
|
position: absolute; inset: 0; border-radius: 50%;
|
||||||
|
background: rgba(0,0,0,0.55); display: flex;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
font-size: 11px; font-weight: 700; color: #fff;
|
||||||
|
letter-spacing: 0.06em; opacity: 0;
|
||||||
|
transition: opacity 120ms;
|
||||||
|
}
|
||||||
|
.avatar-wrap:hover .avatar-overlay { opacity: 1; }
|
||||||
|
.avatar-status {
|
||||||
|
font-size: 11px; color: var(--text-muted); text-align: center;
|
||||||
|
min-height: 16px;
|
||||||
|
}
|
||||||
|
.avatar-status.ok { color: var(--success); }
|
||||||
|
.avatar-status.err { color: var(--accent-hi); }
|
||||||
</style>
|
</style>
|
||||||
<!-- Matomo -->
|
<!-- Matomo -->
|
||||||
<script>
|
<script>
|
||||||
@@ -128,6 +162,13 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<!-- Signed-in view -->
|
<!-- Signed-in view -->
|
||||||
<div id="signed-in">
|
<div id="signed-in">
|
||||||
|
<div class="avatar-wrap" id="avatar-wrap" title="Click to change avatar">
|
||||||
|
<img id="avatar-img" class="avatar-img" src="" alt="" style="display:none">
|
||||||
|
<div id="avatar-initials" class="avatar-initials"></div>
|
||||||
|
<div class="avatar-overlay">Upload</div>
|
||||||
|
<input type="file" id="avatar-file" accept="image/jpeg,image/png,image/webp,image/gif" style="display:none">
|
||||||
|
</div>
|
||||||
|
<div class="avatar-status" id="avatar-status"></div>
|
||||||
<div class="signed-in-detail">Signed in as</div>
|
<div class="signed-in-detail">Signed in as</div>
|
||||||
<div class="username-display" id="display-username"></div>
|
<div class="username-display" id="display-username"></div>
|
||||||
<div class="signed-in-actions">
|
<div class="signed-in-actions">
|
||||||
@@ -152,7 +193,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="si-pass">Password</label>
|
<label for="si-pass">Password</label>
|
||||||
<input type="password" id="si-pass" placeholder="••••••••" autocomplete="current-password">
|
<input type="password" id="si-pass" placeholder="••••••••" autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="error-msg" id="si-error"></div>
|
<div class="error-msg" id="si-error"></div>
|
||||||
<button type="submit">Sign In</button>
|
<button type="submit">Sign In</button>
|
||||||
@@ -164,17 +205,17 @@
|
|||||||
<label for="su-user">Username</label>
|
<label for="su-user">Username</label>
|
||||||
<input type="text" id="su-user" placeholder="your_username" autocomplete="username"
|
<input type="text" id="su-user" placeholder="your_username" autocomplete="username"
|
||||||
minlength="3" maxlength="32">
|
minlength="3" maxlength="32">
|
||||||
<div class="hint" style="margin-top:4px">3–32 characters, letters, digits, underscores</div>
|
<div class="hint" style="margin-top:4px">3–32 characters, letters, digits, underscores</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="su-pass">Password</label>
|
<label for="su-pass">Password</label>
|
||||||
<input type="password" id="su-pass" placeholder="••••••••" autocomplete="new-password"
|
<input type="password" id="su-pass" placeholder="••••••••" autocomplete="new-password"
|
||||||
minlength="8">
|
minlength="8">
|
||||||
<div class="hint" style="margin-top:4px">Minimum 8 characters</div>
|
<div class="hint" style="margin-top:4px">Minimum 8 characters</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="su-pass2">Confirm Password</label>
|
<label for="su-pass2">Confirm Password</label>
|
||||||
<input type="password" id="su-pass2" placeholder="••••••••" autocomplete="new-password">
|
<input type="password" id="su-pass2" placeholder="••••••••" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="error-msg" id="su-error"></div>
|
<div class="error-msg" id="su-error"></div>
|
||||||
<div class="success-msg" id="su-success"></div>
|
<div class="success-msg" id="su-success"></div>
|
||||||
@@ -186,18 +227,40 @@
|
|||||||
<script>
|
<script>
|
||||||
const TOKEN_KEY = 'fs_token';
|
const TOKEN_KEY = 'fs_token';
|
||||||
|
|
||||||
function getUsername(token) {
|
async function fetchMe(token) {
|
||||||
try {
|
const res = await fetch('/api/me', {
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
headers: { 'Authorization': 'Bearer ' + token },
|
||||||
return payload.sub_name ?? payload.username ?? payload.sub ?? null;
|
});
|
||||||
} catch { return null; }
|
if (!res.ok) return null;
|
||||||
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSignedIn(token) {
|
function applyProfile(me) {
|
||||||
const username = getUsername(token);
|
document.getElementById('display-username').textContent = me.username || 'Player';
|
||||||
document.getElementById('display-username').textContent = username ?? 'Player';
|
const img = document.getElementById('avatar-img');
|
||||||
|
const initials = document.getElementById('avatar-initials');
|
||||||
|
if (me.avatar_url) {
|
||||||
|
img.src = me.avatar_url;
|
||||||
|
img.style.display = 'block';
|
||||||
|
initials.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
img.style.display = 'none';
|
||||||
|
initials.style.display = 'flex';
|
||||||
|
initials.textContent = (me.username || 'P')[0].toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showSignedIn(token) {
|
||||||
document.getElementById('signed-in').style.display = 'flex';
|
document.getElementById('signed-in').style.display = 'flex';
|
||||||
document.getElementById('auth-section').style.display = 'none';
|
document.getElementById('auth-section').style.display = 'none';
|
||||||
|
const me = await fetchMe(token);
|
||||||
|
if (me) {
|
||||||
|
applyProfile(me);
|
||||||
|
} else {
|
||||||
|
// Token is stale — kick back to auth
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
showAuth();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAuth() {
|
function showAuth() {
|
||||||
@@ -279,6 +342,46 @@
|
|||||||
showAuth();
|
showAuth();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Avatar: click wrap → trigger file picker
|
||||||
|
document.getElementById('avatar-wrap').addEventListener('click', () => {
|
||||||
|
document.getElementById('avatar-file').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('avatar-file').addEventListener('change', async e => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const status = document.getElementById('avatar-status');
|
||||||
|
status.className = 'avatar-status';
|
||||||
|
status.textContent = 'Uploading...';
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/me/avatar', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + token,
|
||||||
|
'Content-Type': file.type,
|
||||||
|
},
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
status.className = 'avatar-status err';
|
||||||
|
status.textContent = body.message ?? 'Upload failed.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const me = await res.json();
|
||||||
|
applyProfile(me);
|
||||||
|
status.className = 'avatar-status ok';
|
||||||
|
status.textContent = 'Avatar updated.';
|
||||||
|
setTimeout(() => { status.textContent = ''; }, 3000);
|
||||||
|
} catch {
|
||||||
|
status.className = 'avatar-status err';
|
||||||
|
status.textContent = 'Network error.';
|
||||||
|
}
|
||||||
|
// Reset so re-selecting the same file triggers change again
|
||||||
|
e.target.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
const token = localStorage.getItem(TOKEN_KEY);
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
if (token) { showSignedIn(token); } else { showAuth(); }
|
if (token) { showSignedIn(token); } else { showAuth(); }
|
||||||
|
|||||||
Reference in New Issue
Block a user