Files
veridian/app/pages/settings/profile.vue
T
zoeissleeping 4bba529ba8 feat: improve account security
Support TOTP 2fa, allow for changing password, add session management
and account removal.
2026-10-08 12:43:22 -05:00

258 lines
10 KiB
Vue

<script setup lang="ts">
import { Err, Ok, type Result } from '~~/types/result';
import { DialogType } from '~/composables/useDialog';
const AVATAR_SIZE = 256;
const MAX_SOURCE_SIZE = 10 * 1024 * 1024;
const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
const { user, updateProfile } = useAuth();
const { openDialog } = useDialog();
const fileInput = ref<HTMLInputElement | null>(null);
const avatarBusy = ref(false);
const avatarError = ref('');
const name = ref(user.value?.name ?? '');
const nameSaving = ref(false);
const nameError = ref('');
watch(() => user.value?.name, (value) => {
if (!nameSaving.value) name.value = value ?? '';
});
const trimmedName = computed(() => name.value.trim());
const nameChanged = computed(() => trimmedName.value !== '' && trimmedName.value !== user.value?.name);
// Center-crops the image to a square and scales it down, so we only ever
// store a small file no matter what the user picked.
const cropToSquare = async (file: File): Promise<Result<Blob, string>> => {
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file);
} catch {
return Err('That file could not be read as an image.');
}
const side = Math.min(bitmap.width, bitmap.height);
const canvas = document.createElement('canvas');
canvas.width = AVATAR_SIZE;
canvas.height = AVATAR_SIZE;
const ctx = canvas.getContext('2d');
if (ctx === null) {
bitmap.close();
return Err('Your browser could not process the image.');
}
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(
bitmap,
(bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side,
0, 0, AVATAR_SIZE, AVATAR_SIZE,
);
bitmap.close();
// browsers that can't encode webp hand back a png instead
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/webp', 0.9));
if (blob === null) {
return Err('Your browser could not process the image.');
}
return Ok(blob);
};
const uploadAvatar = async (blob: Blob): Promise<Result<string, string>> => {
const extension = blob.type === 'image/webp' ? 'webp' : 'png';
try {
const { url, assetUrl } = await $fetch<{ url: string; assetUrl: string }>('/api/upload/presigned', {
method: 'POST',
body: {
file: {
name: `avatar.${extension}`,
mimeType: blob.type,
},
},
});
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': blob.type },
body: blob,
});
if (!response.ok) {
return Err('Upload failed. Check that file storage is configured for this instance.');
}
return Ok(assetUrl);
} catch (error) {
console.error('Avatar upload failed:', error);
return Err('Upload failed. Check that file storage is configured for this instance.');
}
};
const handleFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
// reset so picking the same file again still fires a change event
input.value = '';
if (!file) return;
avatarError.value = '';
if (!ACCEPTED_TYPES.includes(file.type)) {
avatarError.value = 'Pick a PNG, JPG or WebP image.';
return;
}
if (file.size > MAX_SOURCE_SIZE) {
avatarError.value = 'That image is over 10 MB. Pick a smaller one.';
return;
}
avatarBusy.value = true;
const cropped = await cropToSquare(file);
if (cropped.ok === false) {
avatarError.value = cropped.error;
avatarBusy.value = false;
return;
}
const uploaded = await uploadAvatar(cropped.data);
if (uploaded.ok === false) {
avatarError.value = uploaded.error;
avatarBusy.value = false;
return;
}
const updated = await updateProfile({ image: uploaded.data });
if (updated.ok === false) {
avatarError.value = 'The image uploaded, but your profile could not be updated. Try again.';
}
avatarBusy.value = false;
};
const removeAvatar = async () => {
avatarError.value = '';
avatarBusy.value = true;
const updated = await updateProfile({ image: null });
if (updated.ok === false) {
avatarError.value = 'Your profile picture could not be removed. Try again.';
}
avatarBusy.value = false;
};
const saveName = async () => {
if (!nameChanged.value || nameSaving.value) return;
nameError.value = '';
nameSaving.value = true;
const updated = await updateProfile({ name: trimmedName.value });
if (updated.ok === false) {
nameError.value = 'Your name could not be saved. Try again.';
} else {
name.value = trimmedName.value;
}
nameSaving.value = false;
};
const memberSince = computed(() => {
if (!user.value?.createdAt) return '';
// fixed locale and time zone so the server and client render the same text
return new Date(user.value.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
});
});
</script>
<template>
<div
class="flex flex-col gap-5 py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
<div class="flex flex-col gap-5 w-full max-w-3xl">
<section class="flex flex-wrap items-center gap-5 pb-5 border-b border-[var(--color-border)]">
<UserAvatar :user="user" class="w-20 h-20 rounded-xl text-3xl" />
<div class="flex flex-col gap-2 flex-1 min-w-60">
<h2 class="font-medium">Profile picture</h2>
<div class="flex flex-wrap gap-2">
<button type="button" :disabled="avatarBusy" @click="fileInput?.click()"
class="flex items-center gap-2 h-8 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span :class="avatarBusy ? 'i-svg-spinners-180-ring-with-bg' : 'i-mynaui-upload'"
class="text-4"></span>
{{ avatarBusy ? 'Working…' : 'Upload image' }}
</button>
<button v-if="user?.image" type="button" :disabled="avatarBusy" @click="removeAvatar"
class="flex items-center h-8 px-3 text-sm rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
Remove
</button>
</div>
<input ref="fileInput" type="file" class="hidden" :accept="ACCEPTED_TYPES.join(',')"
aria-label="Upload a profile picture" @change="handleFileChange" />
<p v-if="avatarError" role="alert" class="text-xs text-red-400">{{ avatarError }}</p>
<p v-else class="text-xs text-[var(--text-secondary)]">
PNG, JPG or WebP up to 10MB.
</p>
</div>
</section>
<form class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2"
@submit.prevent="saveName">
<label class="whitespace-nowrap" for="profile-name">Display name</label>
<div class="flex flex-col gap-1 flex-1 min-w-60 max-w-120">
<div class="flex gap-2">
<input id="profile-name" v-model="name" type="text" autocomplete="name" maxlength="64"
class="flex-1 min-w-0 h-8.5 px-2.5 text-sm rounded-md bg-[var(--bg-container)] placeholder:text-[var(--text-tertiary)]" />
<button type="submit" :disabled="!nameChanged || nameSaving"
class="h-8.5 px-3 items-center text-sm font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed transition-opacity duration-200">
{{ nameSaving ? 'Saving…' : 'Save' }}
</button>
</div>
<p v-if="nameError" role="alert" class="text-xs text-red-400">{{ nameError }}</p>
</div>
</form>
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
<span class="whitespace-nowrap">Email</span>
<span
class="flex-1 min-w-60 max-w-120 text-sm text-[var(--text-secondary)] overflow-hidden text-ellipsis">
{{ user?.email }}
</span>
</div>
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
<span class="whitespace-nowrap">Member since</span>
<span class="flex-1 min-w-60 max-w-120 text-sm text-[var(--text-secondary)]">
{{ memberSince }}
</span>
</div>
<section
class="flex flex-wrap items-center justify-between gap-3 mt-3 p-4 rounded-lg border border-red-400/35">
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
<h2 class="text-[15px] font-semibold">Delete account</h2>
<p class="text-[13px] leading-snug text-[var(--text-secondary)]">
Permanently deletes your agents, topics, and api keys from this instance.
</p>
</div>
<button type="button" @click="openDialog(DialogType.DeleteAccount)"
class="flex items-center h-8.5 px-3 text-sm font-medium rounded-md bg-red-600 text-white">
Delete account
</button>
</section>
</div>
</div>
</template>