From ad086f17457f7c89d806ea688a8dfe15200a1fd3 Mon Sep 17 00:00:00 2001 From: CodeMaster4711 Date: Sun, 9 Aug 2026 13:11:50 +0200 Subject: [PATCH] feat: add gravatar avatar support via profile email --- app/src/lib/auth/api.ts | 23 +++++++ app/src/lib/auth/store.svelte.ts | 1 + .../settings/account-settings.svelte | 68 ++++++++++++++++++- .../lib/components/sidebar/nav-user.svelte | 18 +++++ control-plane/api-gateway/src/auth_service.rs | 15 ++++ control-plane/api-gateway/src/init.rs | 1 + .../api-gateway/src/routes/organizations.rs | 1 + control-plane/api-gateway/src/routes/users.rs | 43 ++++++++++++ .../shared/entity/src/entities/user.rs | 1 + control-plane/shared/migration/src/lib.rs | 2 + ...20260809_000000_add_user_gravatar_email.rs | 31 +++++++++ 11 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 control-plane/shared/migration/src/m20260809_000000_add_user_gravatar_email.rs diff --git a/app/src/lib/auth/api.ts b/app/src/lib/auth/api.ts index ccfaebd5..7b333602 100644 --- a/app/src/lib/auth/api.ts +++ b/app/src/lib/auth/api.ts @@ -12,6 +12,7 @@ export interface UserProfile { id: string; username: string; email: string | null; + gravatar_email: string | null; two_factor_enabled: boolean; force_password_change: boolean; } @@ -163,6 +164,28 @@ export async function changeEmail(token: string, newEmail: string): Promise { + const res = await fetch(`${API_BASE}/change-gravatar-email`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ gravatar_email: gravatarEmail }), + }); + if (!res.ok) throw new Error(`change-gravatar-email failed: ${res.status}`); +} + +export async function gravatarUrl(email: string, size = 80): Promise { + const normalized = email.trim().toLowerCase(); + const encoded = new TextEncoder().encode(normalized); + const digest = await crypto.subtle.digest('SHA-256', encoded); + const hash = Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=404`; +} + export interface Setup2FAResponse { secret: string; qr_code: string; diff --git a/app/src/lib/auth/store.svelte.ts b/app/src/lib/auth/store.svelte.ts index beecab6a..8c1d1703 100644 --- a/app/src/lib/auth/store.svelte.ts +++ b/app/src/lib/auth/store.svelte.ts @@ -32,6 +32,7 @@ function createAuthStore() { id: response.user_id, username: response.username, email: null, + gravatar_email: null, two_factor_enabled: response.two_factor_enabled, force_password_change: response.force_password_change, }; diff --git a/app/src/lib/components/settings/account-settings.svelte b/app/src/lib/components/settings/account-settings.svelte index affb2e4d..9e5c98ac 100644 --- a/app/src/lib/components/settings/account-settings.svelte +++ b/app/src/lib/components/settings/account-settings.svelte @@ -5,13 +5,37 @@ import { Switch } from "$lib/components/ui/switch/index.js"; import * as InputOTP from "$lib/components/ui/input-otp/index.js"; import Spinner from "$lib/components/ui/spinner/spinner.svelte"; + import * as Avatar from "$lib/components/ui/avatar/index.js"; import { auth } from "$lib/auth/store.svelte"; - import { changeEmail, changePassword, setup2FA, enable2FA, disable2FA } from "$lib/auth/api"; + import { + changeEmail, + changeGravatarEmail, + changePassword, + gravatarUrl, + setup2FA, + enable2FA, + disable2FA, + } from "$lib/auth/api"; import { toast } from "svelte-sonner"; let email = $state(auth.user?.email ?? ""); let emailSaving = $state(false); + let gravatarEmail = $state(auth.user?.gravatar_email ?? ""); + let gravatarSaving = $state(false); + let avatarPreview = $state(null); + + const gravatarChanged = $derived(gravatarEmail.trim() !== (auth.user?.gravatar_email ?? "")); + + $effect(() => { + const source = auth.user?.gravatar_email; + if (!source) { + avatarPreview = null; + return; + } + gravatarUrl(source, 96).then((url) => (avatarPreview = url)); + }); + let oldPassword = $state(""); let newPassword = $state(""); let confirmPassword = $state(""); @@ -39,6 +63,21 @@ } } + async function saveGravatarEmail() { + if (!auth.token || !gravatarChanged) return; + gravatarSaving = true; + try { + const trimmed = gravatarEmail.trim(); + await changeGravatarEmail(auth.token, trimmed === "" ? null : trimmed); + auth.setUser({ ...auth.user!, gravatar_email: trimmed === "" ? null : trimmed }); + toast.success("Gravatar updated"); + } catch { + toast.error("Failed to update gravatar"); + } finally { + gravatarSaving = false; + } + } + async function savePassword() { if (!auth.token || !passwordValid) return; passwordSaving = true; @@ -118,6 +157,33 @@

Profile

+
+ + {#if avatarPreview} + + {/if} + + {(auth.user?.username ?? "??").slice(0, 2).toUpperCase()} + + +
+ +
+ + +
+

+ Avatar is pulled from gravatar.com +

+
+
diff --git a/app/src/lib/components/sidebar/nav-user.svelte b/app/src/lib/components/sidebar/nav-user.svelte index cadad46e..2eb5f235 100644 --- a/app/src/lib/components/sidebar/nav-user.svelte +++ b/app/src/lib/components/sidebar/nav-user.svelte @@ -7,11 +7,23 @@ import LogOutIcon from "@lucide/svelte/icons/log-out"; import UserIcon from "@lucide/svelte/icons/user"; import { auth } from "$lib/auth/store.svelte"; + import { gravatarUrl } from "$lib/auth/api"; import { settingsDialog } from "$lib/components/settings/settings-store.svelte.js"; import { goto } from "$app/navigation"; const sidebar = useSidebar(); + let avatarUrl = $state(null); + + $effect(() => { + const source = auth.user?.gravatar_email; + if (!source) { + avatarUrl = null; + return; + } + gravatarUrl(source, 64).then((url) => (avatarUrl = url)); + }); + function initials(name: string): string { return name.slice(0, 2).toUpperCase(); } @@ -33,6 +45,9 @@ {...props} > + {#if avatarUrl} + + {/if} {auth.user ? initials(auth.user.username) : "??"} @@ -54,6 +69,9 @@
+ {#if avatarUrl} + + {/if} {auth.user ? initials(auth.user.username) : "??"} diff --git a/control-plane/api-gateway/src/auth_service.rs b/control-plane/api-gateway/src/auth_service.rs index 0c54c446..2f26b036 100644 --- a/control-plane/api-gateway/src/auth_service.rs +++ b/control-plane/api-gateway/src/auth_service.rs @@ -83,6 +83,7 @@ impl AuthService { password: ActiveValue::Set(hashed_password), salt: ActiveValue::Set(salt), email: ActiveValue::NotSet, + gravatar_email: ActiveValue::NotSet, two_factor_secret: ActiveValue::NotSet, two_factor_enabled: ActiveValue::Set(false), force_password_change: ActiveValue::Set(false), @@ -347,4 +348,18 @@ impl AuthService { Ok(()) } + + pub async fn change_gravatar_email( + &self, + user_id: Uuid, + gravatar_email: Option, + ) -> AuthResult<()> { + let user = self.get_user_by_id(user_id).await?; + + let mut user_active: user::ActiveModel = user.into(); + user_active.gravatar_email = Set(gravatar_email); + user_active.update(&self.db).await?; + + Ok(()) + } } diff --git a/control-plane/api-gateway/src/init.rs b/control-plane/api-gateway/src/init.rs index dcc0f819..9fb029eb 100644 --- a/control-plane/api-gateway/src/init.rs +++ b/control-plane/api-gateway/src/init.rs @@ -368,6 +368,7 @@ pub async fn initialize_database( password: ActiveValue::Set(hashed_password), salt: ActiveValue::Set(salt), email: ActiveValue::Set(Some("admin@local.com".to_string())), + gravatar_email: ActiveValue::NotSet, two_factor_secret: ActiveValue::NotSet, two_factor_enabled: ActiveValue::Set(false), force_password_change: ActiveValue::Set(true), diff --git a/control-plane/api-gateway/src/routes/organizations.rs b/control-plane/api-gateway/src/routes/organizations.rs index 0fd01469..bc532976 100644 --- a/control-plane/api-gateway/src/routes/organizations.rs +++ b/control-plane/api-gateway/src/routes/organizations.rs @@ -350,6 +350,7 @@ async fn create_user( password: ActiveValue::Set(hashed_password), salt: ActiveValue::Set(salt), email: ActiveValue::Set(req.email.clone()), + gravatar_email: ActiveValue::NotSet, two_factor_secret: ActiveValue::NotSet, two_factor_enabled: ActiveValue::Set(false), force_password_change: ActiveValue::Set(req.force_password_change), diff --git a/control-plane/api-gateway/src/routes/users.rs b/control-plane/api-gateway/src/routes/users.rs index 6f86bce6..1875aee0 100644 --- a/control-plane/api-gateway/src/routes/users.rs +++ b/control-plane/api-gateway/src/routes/users.rs @@ -59,6 +59,8 @@ pub struct UserProfileResponse { pub username: String, /// Email address pub email: Option, + /// Gravatar email address used to derive the profile avatar + pub gravatar_email: Option, /// Whether 2FA is enabled pub two_factor_enabled: bool, /// Whether password change is required @@ -76,6 +78,7 @@ pub fn users_routes() -> Router { .route("/2fa/disable", post(disable_2fa)) .route("/change-password", post(change_password)) .route("/change-email", post(change_email)) + .route("/change-gravatar-email", post(change_gravatar_email)) } // Define the public, unauthenticated routes for the users module @@ -298,6 +301,7 @@ pub async fn get_user_profile( id: user.id.to_string(), username: user.name, email: user.email.clone(), + gravatar_email: user.gravatar_email.clone(), two_factor_enabled: user.two_factor_enabled, force_password_change: user.force_password_change, })), @@ -336,6 +340,7 @@ pub async fn validate_session( id: user.id.to_string(), username: user.name, email: user.email.clone(), + gravatar_email: user.gravatar_email.clone(), two_factor_enabled: user.two_factor_enabled, force_password_change: user.force_password_change, })), @@ -367,6 +372,11 @@ pub struct ChangeEmailRequest { pub new_email: String, } +#[derive(Deserialize, ToSchema)] +pub struct ChangeGravatarEmailRequest { + pub gravatar_email: Option, +} + /// Setup 2FA for user (protected) #[utoipa::path( post, @@ -528,3 +538,36 @@ pub async fn change_email( } } } + +/// Change gravatar avatar email (protected) +#[utoipa::path( + post, + path = "/api/change-gravatar-email", + request_body = ChangeGravatarEmailRequest, + responses( + (status = 200, description = "Gravatar email changed successfully"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "Authentication" +)] +pub async fn change_gravatar_email( + AuthenticatedUser(claims): AuthenticatedUser, + State(state): State, + Json(payload): Json, +) -> Result, StatusCode> { + let auth_service = AuthService::new(state.db_conn.clone()); + + match auth_service + .change_gravatar_email(claims.user_id, payload.gravatar_email) + .await + { + Ok(_) => Ok(Json(json!({ "message": "Gravatar email changed successfully" }))), + Err(err) => { + tracing::error!("Failed to change gravatar email: {}", err); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} diff --git a/control-plane/shared/entity/src/entities/user.rs b/control-plane/shared/entity/src/entities/user.rs index fbb7d18b..3f2f8bb6 100644 --- a/control-plane/shared/entity/src/entities/user.rs +++ b/control-plane/shared/entity/src/entities/user.rs @@ -10,6 +10,7 @@ pub struct Model { pub password: String, pub salt: String, pub email: Option, + pub gravatar_email: Option, pub two_factor_secret: Option, pub two_factor_enabled: bool, pub force_password_change: bool, diff --git a/control-plane/shared/migration/src/lib.rs b/control-plane/shared/migration/src/lib.rs index 2d7e3d10..aeed8750 100644 --- a/control-plane/shared/migration/src/lib.rs +++ b/control-plane/shared/migration/src/lib.rs @@ -31,6 +31,7 @@ mod m20260712_000000_add_workload_lifecycle; mod m20260712_010000_add_resource_group_vpn_peers; mod m20260723_000000_add_resource_group_appearance; mod m20260723_010000_runtime_class_default_firecracker; +mod m20260809_000000_add_user_gravatar_email; pub struct Migrator; @@ -69,6 +70,7 @@ impl MigratorTrait for Migrator { Box::new(m20260712_010000_add_resource_group_vpn_peers::Migration), Box::new(m20260723_000000_add_resource_group_appearance::Migration), Box::new(m20260723_010000_runtime_class_default_firecracker::Migration), + Box::new(m20260809_000000_add_user_gravatar_email::Migration), ] } } diff --git a/control-plane/shared/migration/src/m20260809_000000_add_user_gravatar_email.rs b/control-plane/shared/migration/src/m20260809_000000_add_user_gravatar_email.rs new file mode 100644 index 00000000..7fa2d898 --- /dev/null +++ b/control-plane/shared/migration/src/m20260809_000000_add_user_gravatar_email.rs @@ -0,0 +1,31 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("user")) + .add_column_if_not_exists( + ColumnDef::new(Alias::new("gravatar_email")).string().null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("user")) + .drop_column(Alias::new("gravatar_email")) + .to_owned(), + ) + .await + } +}