feat(profile): migrate auth.users.image into meImages + avatar autosync (M2.5)

Hard-follow-up to M1's soft Dexie schema landing (plan
docs/plans/me-images-and-reference-generation.md). After this commit
the source of truth for the avatar is meImages(primaryFor='avatar');
auth.users.image becomes a derived mirror that gets pushed back to
Better Auth whenever the primary changes.

Changes:

- New migration/legacy-avatar.ts: one-shot, idempotent bootstrap. On
  first visit to /profile/me-images it reads profile.image via
  profileService.getProfile() and writes a single meImage with
  kind='face', primaryFor='avatar', usage.aiReference=false. The
  mediaId is a sentinel `legacy-avatar:<uid>` — the original bytes
  never went through mana-media, so verifyMediaOwnership (M3) will
  naturally bounce if the user ever flips aiReference on without
  re-uploading. Guarded per user via localStorage +
  existing-avatar-holder check so reruns are no-ops.

- Store avatar autosync: setPrimary and deleteMeImage now push
  meImages(primaryFor='avatar').publicUrl back to
  profileService.updateProfile({ image }). The avatar slot is
  coupled to face-ref — setting a new face-ref primary also claims
  the avatar on the same row, so users don't need a second UI
  control to keep their profile picture fresh. Failures are logged
  but swallowed; meImages stays authoritative for in-app rendering.

- MeImagesView triggers the migration once on mount.

- EditProfileModal replaces the broken inline avatar upload (the old
  POST /api/v1/storage/avatar/upload endpoint never existed in the
  unified API) with a read-only preview + a button that closes the
  modal and navigates to /profile/me-images. Name + email flows are
  untouched.

- profileService.uploadAvatar + AvatarUploadResponse + its test are
  deleted (no callers left after the modal rewrite).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 14:38:20 +02:00
parent 57be0f61b1
commit e2b5ac38cb
6 changed files with 211 additions and 216 deletions

View file

@ -157,34 +157,4 @@ describe('profileService', () => {
);
});
});
describe('uploadAvatar', () => {
it('should upload avatar to API and update profile', async () => {
const mockFile = new File(['image-data'], 'avatar.png', { type: 'image/png' });
global.fetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({ url: 'https://media.mana.how/avatar.png', mediaId: 'media-1' }),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
success: true,
user: { id: 'user-1', name: 'Test', email: 'test@mana.how' },
}),
});
const result = await profileService.uploadAvatar(mockFile);
expect(result.success).toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3060/api/v1/storage/avatar/upload',
expect.objectContaining({ method: 'POST' })
);
});
});
});

View file

@ -4,7 +4,7 @@
*/
import { authStore } from '$lib/stores/auth.svelte';
import { getManaAuthUrl, getManaApiUrl } from './config';
import { getManaAuthUrl } from './config';
// Types
export interface UserProfile {
@ -36,11 +36,6 @@ export interface ChangeEmailRequest {
newEmail: string;
}
export interface AvatarUploadResponse {
url: string;
mediaId: string;
}
// Helper function for authenticated requests
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = await authStore.getValidToken();
@ -114,23 +109,4 @@ export const profileService = {
body: JSON.stringify(data),
});
},
/**
* Upload avatar file directly, then update profile
*/
async uploadAvatar(file: File): Promise<{ success: boolean; user: UserProfile }> {
const token = await authStore.getValidToken();
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch(`${getManaApiUrl()}/api/v1/storage/avatar/upload`, {
method: 'POST',
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: formData,
});
if (!uploadResponse.ok) throw new Error('Avatar-Upload fehlgeschlagen');
const { url } = (await uploadResponse.json()) as AvatarUploadResponse;
return this.updateProfile({ image: url });
},
};

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { profileService, type UserProfile } from '$lib/api/profile';
import { PencilSimple } from '@mana/shared-icons';
@ -17,13 +18,7 @@
let editingEmail = $state(false);
let emailSent = $state(false);
let saving = $state(false);
let uploadingAvatar = $state(false);
let error = $state<string | null>(null);
let avatarPreview = $state<string | null>(null);
let selectedFile = $state<File | null>(null);
// File input ref
let fileInput = $state<HTMLInputElement | undefined>(undefined);
// Initialize form when modal opens
$effect(() => {
@ -32,57 +27,19 @@
newEmail = '';
editingEmail = false;
emailSent = false;
avatarPreview = user.image || null;
selectedFile = null;
error = null;
}
});
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget && !saving && !uploadingAvatar) {
if (event.target === event.currentTarget && !saving) {
onClose();
}
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
error = 'Bitte wähle ein Bild aus (JPG, PNG, GIF, WebP)';
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
error = 'Das Bild darf maximal 5MB groß sein';
return;
}
selectedFile = file;
error = null;
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
avatarPreview = e.target?.result as string;
};
reader.readAsDataURL(file);
}
function triggerFileInput() {
fileInput?.click();
}
function removeAvatar() {
selectedFile = null;
avatarPreview = null;
if (fileInput) {
fileInput.value = '';
}
function openMeImages() {
onClose();
goto('/profile/me-images');
}
async function handleSubmit(e: Event) {
@ -96,40 +53,16 @@
error = null;
try {
let updatedUser: UserProfile;
// If new avatar selected, upload first
if (selectedFile) {
uploadingAvatar = true;
const result = await profileService.uploadAvatar(selectedFile);
updatedUser = result.user;
uploadingAvatar = false;
// Now update name if changed
if (name.trim() !== updatedUser.name) {
const nameResult = await profileService.updateProfile({ name: name.trim() });
updatedUser = nameResult.user;
}
} else if (avatarPreview === null && user?.image) {
// Avatar was removed
const result = await profileService.updateProfile({
name: name.trim(),
image: '',
});
updatedUser = result.user;
} else {
// Just update name
const result = await profileService.updateProfile({ name: name.trim() });
updatedUser = result.user;
}
onSuccess(updatedUser);
// Avatar is now managed exclusively via /profile/me-images (plan
// M2.5): setting a primary face in meImages syncs the URL back
// to auth.users.image. This modal only covers the name here.
const result = await profileService.updateProfile({ name: name.trim() });
onSuccess(result.user);
onClose();
} catch (e) {
error = e instanceof Error ? e.message : $_('common.error_saving');
} finally {
saving = false;
uploadingAvatar = false;
}
}
@ -169,7 +102,7 @@
<div
class="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && !saving && !uploadingAvatar && onClose()}
onkeydown={(e) => e.key === 'Escape' && !saving && onClose()}
tabindex="-1"
role="presentation"
>
@ -188,79 +121,37 @@
<form onsubmit={handleSubmit}>
<div class="space-y-4">
<!-- Avatar Upload -->
<!-- Profilbild (read-only preview + link to Meine Bilder) -->
<div>
<span class="block text-sm font-medium mb-2">Profilbild</span>
<div class="flex items-center gap-4">
<!-- Avatar Preview -->
<div class="relative">
{#if avatarPreview}
<img
src={avatarPreview}
alt="Avatar"
class="h-20 w-20 rounded-full object-cover border-2 border-border"
/>
{:else}
<div
class="h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xl font-semibold"
>
{getInitials(name || user?.name || 'U')}
</div>
{/if}
{#if uploadingAvatar}
<div
class="absolute inset-0 bg-black/50 rounded-full flex items-center justify-center"
>
<svg class="animate-spin h-6 w-6 text-white" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
{/if}
</div>
<!-- Upload/Remove Buttons -->
<div class="flex flex-col gap-2">
<input
bind:this={fileInput}
type="file"
accept="image/*"
class="hidden"
onchange={handleFileSelect}
{#if user?.image}
<img
src={user.image}
alt="Avatar"
class="h-20 w-20 rounded-full object-cover border-2 border-border"
/>
{:else}
<div
class="h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xl font-semibold"
>
{getInitials(name || user?.name || 'U')}
</div>
{/if}
<div class="flex flex-col gap-1">
<button
type="button"
onclick={triggerFileInput}
disabled={saving || uploadingAvatar}
class="px-3 py-1.5 text-sm border rounded-lg hover:bg-muted transition-colors disabled:opacity-50"
onclick={openMeImages}
disabled={saving}
class="px-3 py-1.5 text-sm border rounded-lg hover:bg-muted transition-colors disabled:opacity-50 text-left"
>
Bild auswählen
In „Meine Bilder" verwalten →
</button>
{#if avatarPreview}
<button
type="button"
onclick={removeAvatar}
disabled={saving || uploadingAvatar}
class="px-3 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50"
>
Entfernen
</button>
{/if}
<p class="text-xs text-muted-foreground">
Dein Profilbild folgt dem Gesichts-Primärbild unter Meine Bilder.
</p>
</div>
</div>
<p class="mt-2 text-xs text-muted-foreground">JPG, PNG, GIF oder WebP. Max. 5MB.</p>
</div>
<!-- Email -->
@ -332,7 +223,7 @@
newEmail = '';
error = null;
}}
disabled={saving || uploadingAvatar}
disabled={saving}
class="px-3 py-2 text-sm border rounded-lg hover:bg-muted transition-colors disabled:opacity-50 whitespace-nowrap"
>
Ändern
@ -351,7 +242,7 @@
id="profile-name"
type="text"
bind:value={name}
disabled={saving || uploadingAvatar}
disabled={saving}
placeholder="Dein Name"
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50"
/>
@ -370,17 +261,17 @@
<button
type="button"
onclick={onClose}
disabled={saving || uploadingAvatar}
disabled={saving}
class="flex-1 px-4 py-2 border rounded-lg hover:bg-muted transition-colors disabled:opacity-50"
>
{$_('common.cancel')}
</button>
<button
type="submit"
disabled={saving || uploadingAvatar}
disabled={saving}
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
>
{#if saving || uploadingAvatar}
{#if saving}
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
@ -396,7 +287,7 @@
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>{uploadingAvatar ? $_('common.uploading') : $_('common.saving')}</span>
<span>{$_('common.saving')}</span>
{:else}
{$_('common.save')}
{/if}

View file

@ -14,6 +14,7 @@
live on the tile/slot components; this file just orchestrates.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { Info, Sparkle } from '@mana/shared-icons';
import MeImageSlotCard from './components/MeImageSlotCard.svelte';
import MeImageTile from './components/MeImageTile.svelte';
@ -21,8 +22,17 @@
import { useAllMeImages, useImageByPrimary } from './queries';
import { meImagesStore } from './stores/me-images.svelte';
import { readImageDimensions, uploadMeImageFile } from './api/me-images';
import { migrateLegacyAvatarIfNeeded } from './migration/legacy-avatar';
import type { MeImage, MeImageKind, MeImagePrimarySlot } from './types';
// One-shot bootstrap: pull the pre-M1 auth.users.image into meImages
// as the avatar primary. Idempotent — see migration/legacy-avatar.ts.
onMount(() => {
migrateLegacyAvatarIfNeeded().catch((err) => {
console.error('[profile] legacy avatar migration failed', err);
});
});
const allImages$ = useAllMeImages();
const faceSlot$ = useImageByPrimary('face-ref');
const bodySlot$ = useImageByPrimary('body-ref');

View file

@ -0,0 +1,87 @@
/**
* One-shot migration of the legacy `auth.users.image` URL into a
* meImages row with `primaryFor='avatar'`. Runs the first time the
* user opens /profile/me-images after M2.5 ships; idempotent after
* that (localStorage guard + primary-slot existence check).
*
* Why this lives as a client-side bootstrap rather than a Dexie
* db.version() upgrade: the legacy value lives in Better Auth
* (services/mana-auth), not in Dexie. A schema-upgrade hook has no
* way to reach it without a network call, and running network calls
* from inside a Dexie version upgrade is exactly the kind of thing
* that breaks silently on slow links. A mount-time bootstrap gives
* us explicit error handling + a retry path (next visit).
*
* The migrated row carries a sentinel mediaId (`legacy-avatar:<uid>`)
* because the original bytes were not uploaded through mana-media
* they live wherever the old avatar upload endpoint put them. As a
* result, this row intentionally fails M3's verifyMediaOwnership if
* the user ever flips its `usage.aiReference` on and tries to use it
* for generation. That is correct: legacy avatars shouldn't silently
* start feeding OpenAI without an explicit re-upload.
*/
import { authStore } from '$lib/stores/auth.svelte';
import { profileService } from '$lib/api/profile';
import { meImagesTable } from '../collections';
import { meImagesStore } from '../stores/me-images.svelte';
export async function migrateLegacyAvatarIfNeeded(): Promise<void> {
const user = authStore.user;
if (!user?.id) return;
const flagKey = `mana.profile.avatarMigration.${user.id}`;
if (typeof localStorage !== 'undefined' && localStorage.getItem(flagKey)) return;
// Already have an avatar-holder? Mark done and skip. This also
// covers the case where a user had their primary set in a prior
// browser — after sync catches up, the row is here and we should
// not create a duplicate.
const existing = await meImagesTable.where('primaryFor').equals('avatar').toArray();
if (existing.some((row) => !row.deletedAt)) {
try {
localStorage.setItem(flagKey, '1');
} catch {
// localStorage blocked — fine, next visit re-checks Dexie.
}
return;
}
let profile;
try {
profile = await profileService.getProfile();
} catch {
// Offline or Better Auth down; try again on next visit.
return;
}
if (!profile.image) {
try {
localStorage.setItem(flagKey, '1');
} catch {
// ignore
}
return;
}
await meImagesStore.createMeImage({
kind: 'face',
// Sentinel mediaId: not a real mana-media reference. The generate-
// with-reference path (M3) gates on MediaClient.list({app:'me'}),
// so this id will naturally bounce if ever used for generation.
mediaId: `legacy-avatar:${user.id}`,
storagePath: profile.image,
publicUrl: profile.image,
thumbnailUrl: profile.image,
width: 0,
height: 0,
label: 'Bisheriges Profilbild',
usage: { aiReference: false, showInProfile: true },
primaryFor: 'avatar',
});
try {
localStorage.setItem(flagKey, '1');
} catch {
// ignore
}
}

View file

@ -10,6 +10,7 @@
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { profileService } from '$lib/api/profile';
import { meImagesTable } from '../collections';
import { toMeImage } from '../types';
import type {
@ -47,6 +48,47 @@ function defaultUsage(override?: Partial<MeImageUsage>): MeImageUsage {
};
}
/**
* After any primary-avatar change, push the current holder's publicUrl
* back to Better Auth so `auth.users.image` stays in lockstep. Plan
* M2.5 this is the only path by which auth.users.image ever gets
* written from now on; EditProfileModal's legacy inline upload is
* gone in the same commit.
*
* Best-effort: failures are logged and swallowed. The meImages row is
* authoritative for the app's own avatar rendering, so a stale
* auth.users.image is a cross-session degradation, not data loss.
*/
async function syncAvatarToAuth(): Promise<void> {
try {
const rows = await meImagesTable.where('primaryFor').equals('avatar').toArray();
const holder = rows.find((row) => !row.deletedAt);
const nextImage = holder?.publicUrl ?? '';
await profileService.updateProfile({ image: nextImage });
} catch (err) {
console.error('[profile] syncing avatar to Better Auth failed', err);
}
}
/**
* Internal: swap the primary holder of `slot` to `id` (or clear it
* when id is null) in one transaction. Extracted so `setPrimary` can
* reuse it when avatar silently follows face-ref.
*/
async function setPrimaryInTx(id: string | null, slot: MeImagePrimarySlot): Promise<void> {
const nowIso = new Date().toISOString();
await meImagesTable.db.transaction('rw', meImagesTable, async () => {
const current = await meImagesTable.where('primaryFor').equals(slot).toArray();
for (const row of current) {
if (row.id === id) continue;
await meImagesTable.update(row.id, { primaryFor: null, updatedAt: nowIso });
}
if (id !== null) {
await meImagesTable.update(id, { primaryFor: slot, updatedAt: nowIso });
}
});
}
export const meImagesStore = {
async createMeImage(input: CreateMeImageInput): Promise<MeImage> {
const newLocal: LocalMeImage = {
@ -115,29 +157,47 @@ export const meImagesStore = {
*
* Pass `null` as the second argument to unset the slot on `id`
* without claiming it for anyone else.
*
* The `avatar` slot is coupled to `face-ref`: setting a new
* face-ref also claims the avatar on the same row (plan M2.5
* decision keeps auth.users.image in lockstep with the user's
* current reference face without a second UI control). Explicit
* avatar-only setPrimary calls (e.g. the legacy migration
* bootstrap) still work and only touch the avatar slot.
*/
async setPrimary(id: string, slot: MeImagePrimarySlot | null): Promise<void> {
const nowIso = new Date().toISOString();
await meImagesTable.db.transaction('rw', meImagesTable, async () => {
if (slot === null) {
await meImagesTable.update(id, { primaryFor: null, updatedAt: nowIso });
return;
}
// Clear any current holder of this slot (usually zero or one).
const current = await meImagesTable.where('primaryFor').equals(slot).toArray();
for (const row of current) {
if (row.id === id) continue;
await meImagesTable.update(row.id, { primaryFor: null, updatedAt: nowIso });
}
await meImagesTable.update(id, { primaryFor: slot, updatedAt: nowIso });
});
if (slot === null) {
// Clear whatever this row currently holds. If it was the
// avatar, we also need to sync that out to Better Auth.
const existing = await meImagesTable.get(id);
const wasAvatar = existing?.primaryFor === 'avatar';
const nowIso = new Date().toISOString();
await meImagesTable.update(id, { primaryFor: null, updatedAt: nowIso });
emitDomainEvent('MeImagePrimaryChanged', 'profile', 'meImages', id, {
meImageId: id,
slot: null,
});
if (wasAvatar) await syncAvatarToAuth();
return;
}
await setPrimaryInTx(id, slot);
// Silent twin: a fresh face-ref is also the fresh avatar.
if (slot === 'face-ref') {
await setPrimaryInTx(id, 'avatar');
}
emitDomainEvent('MeImagePrimaryChanged', 'profile', 'meImages', id, {
meImageId: id,
slot,
});
if (slot === 'avatar' || slot === 'face-ref') {
await syncAvatarToAuth();
}
},
async deleteMeImage(id: string): Promise<void> {
const existing = await meImagesTable.get(id);
const wasAvatar = existing?.primaryFor === 'avatar';
const nowIso = new Date().toISOString();
await meImagesTable.update(id, {
deletedAt: nowIso,
@ -148,5 +208,6 @@ export const meImagesStore = {
primaryFor: null,
});
emitDomainEvent('MeImageDeleted', 'profile', 'meImages', id, { meImageId: id });
if (wasAvatar) await syncAvatarToAuth();
},
};