Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions app/src/lib/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -163,6 +164,28 @@ export async function changeEmail(token: string, newEmail: string): Promise<void
if (!res.ok) throw new Error(`change-email failed: ${res.status}`);
}

export async function changeGravatarEmail(token: string, gravatarEmail: string | null): Promise<void> {
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<string> {
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;
Expand Down
1 change: 1 addition & 0 deletions app/src/lib/auth/store.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
68 changes: 67 additions & 1 deletion app/src/lib/components/settings/account-settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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("");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -118,6 +157,33 @@
<div class="flex flex-col gap-8 max-w-xl">
<section class="flex flex-col gap-4">
<h3 class="text-sm font-semibold">Profile</h3>
<div class="flex items-center gap-4">
<Avatar.Root class="size-16 rounded-lg">
{#if avatarPreview}
<Avatar.Image src={avatarPreview} alt="Gravatar avatar" />
{/if}
<Avatar.Fallback class="rounded-lg text-lg">
{(auth.user?.username ?? "??").slice(0, 2).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
<div class="flex-1 flex flex-col gap-1.5">
<Label for="settings-gravatar" class="text-xs text-muted-foreground">Gravatar email</Label>
<div class="flex gap-2">
<Input
id="settings-gravatar"
type="email"
bind:value={gravatarEmail}
placeholder="you@example.com"
/>
<Button onclick={saveGravatarEmail} disabled={!gravatarChanged || gravatarSaving} size="sm">
{#if gravatarSaving}<Spinner />{:else}Save{/if}
</Button>
</div>
<p class="text-xs text-muted-foreground">
Avatar is pulled from <a href="https://gravatar.com/profile" target="_blank" rel="noreferrer" class="underline">gravatar.com</a>
</p>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label for="settings-username" class="text-xs text-muted-foreground">Username</Label>
<Input id="settings-username" value={auth.user?.username ?? ""} disabled />
Expand Down
18 changes: 18 additions & 0 deletions app/src/lib/components/sidebar/nav-user.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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();
}
Expand All @@ -33,6 +45,9 @@
{...props}
>
<Avatar.Root class="size-8 rounded-lg">
{#if avatarUrl}
<Avatar.Image src={avatarUrl} alt={auth.user?.username ?? "avatar"} />
{/if}
<Avatar.Fallback class="rounded-lg text-xs">
{auth.user ? initials(auth.user.username) : "??"}
</Avatar.Fallback>
Expand All @@ -54,6 +69,9 @@
<DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<Avatar.Root class="size-8 rounded-lg">
{#if avatarUrl}
<Avatar.Image src={avatarUrl} alt={auth.user?.username ?? "avatar"} />
{/if}
<Avatar.Fallback class="rounded-lg text-xs">
{auth.user ? initials(auth.user.username) : "??"}
</Avatar.Fallback>
Expand Down
15 changes: 15 additions & 0 deletions control-plane/api-gateway/src/auth_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -347,4 +348,18 @@ impl AuthService {

Ok(())
}

pub async fn change_gravatar_email(
&self,
user_id: Uuid,
gravatar_email: Option<String>,
) -> 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(())
}
}
1 change: 1 addition & 0 deletions control-plane/api-gateway/src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions control-plane/api-gateway/src/routes/organizations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
43 changes: 43 additions & 0 deletions control-plane/api-gateway/src/routes/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ pub struct UserProfileResponse {
pub username: String,
/// Email address
pub email: Option<String>,
/// Gravatar email address used to derive the profile avatar
pub gravatar_email: Option<String>,
/// Whether 2FA is enabled
pub two_factor_enabled: bool,
/// Whether password change is required
Expand All @@ -76,6 +78,7 @@ pub fn users_routes() -> Router<AppState> {
.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
Expand Down Expand Up @@ -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,
})),
Expand Down Expand Up @@ -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,
})),
Expand Down Expand Up @@ -367,6 +372,11 @@ pub struct ChangeEmailRequest {
pub new_email: String,
}

#[derive(Deserialize, ToSchema)]
pub struct ChangeGravatarEmailRequest {
pub gravatar_email: Option<String>,
}

/// Setup 2FA for user (protected)
#[utoipa::path(
post,
Expand Down Expand Up @@ -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<AppState>,
Json(payload): Json<ChangeGravatarEmailRequest>,
) -> Result<Json<Value>, 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)
}
}
}
1 change: 1 addition & 0 deletions control-plane/shared/entity/src/entities/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub struct Model {
pub password: String,
pub salt: String,
pub email: Option<String>,
pub gravatar_email: Option<String>,
pub two_factor_secret: Option<String>,
pub two_factor_enabled: bool,
pub force_password_change: bool,
Expand Down
2 changes: 2 additions & 0 deletions control-plane/shared/migration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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),
]
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading