Files
dockhand/routes/profile/+page.svelte
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

642 lines
17 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { Badge } from '$lib/components/ui/badge';
import {
User,
Mail,
Shield,
ShieldCheck,
Key,
RefreshCw,
Check,
Smartphone,
QrCode,
AlertTriangle,
Crown,
Calendar,
Clock,
Camera,
Trash2,
TriangleAlert,
Palette
} from 'lucide-svelte';
import { authStore } from '$lib/stores/auth';
import * as Alert from '$lib/components/ui/alert';
import { licenseStore } from '$lib/stores/license';
import AvatarCropper from '$lib/components/AvatarCropper.svelte';
import * as Avatar from '$lib/components/ui/avatar';
import ChangePasswordModal from './ChangePasswordModal.svelte';
import MfaSetupModal from './MfaSetupModal.svelte';
import DisableMfaModal from './DisableMfaModal.svelte';
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
import { themeStore } from '$lib/stores/theme';
import PageHeader from '$lib/components/PageHeader.svelte';
interface Profile {
id: number;
username: string;
email: string | null;
displayName: string | null;
avatar: string | null;
mfaEnabled: boolean;
isAdmin: boolean;
provider: string;
lastLogin: string | null;
createdAt: string;
updatedAt: string;
}
let profile = $state<Profile | null>(null);
let loading = $state(true);
let error = $state('');
let profileFetched = $state(false);
// Profile form state
let formEmail = $state('');
let formDisplayName = $state('');
let formSaving = $state(false);
let formError = $state('');
let formSuccess = $state('');
// Password change state
let showPasswordModal = $state(false);
// MFA state
let showMfaSetupModal = $state(false);
let mfaQrCode = $state('');
let mfaSecret = $state('');
let mfaLoading = $state(false);
let mfaError = $state('');
let showDisableMfaModal = $state(false);
// Avatar state
let showAvatarCropper = $state(false);
let avatarSaving = $state(false);
let avatarFileInput = $state<HTMLInputElement | null>(null);
let imageForCrop = $state('');
function handleAvatarFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
if (!file.type.startsWith('image/')) {
formError = 'Please select an image file';
return;
}
const reader = new FileReader();
reader.onload = (e) => {
imageForCrop = e.target?.result as string;
showAvatarCropper = true;
};
reader.readAsDataURL(file);
}
}
function triggerAvatarUpload() {
avatarFileInput?.click();
}
function cancelAvatarCrop() {
showAvatarCropper = false;
imageForCrop = '';
if (avatarFileInput) {
avatarFileInput.value = '';
}
}
async function fetchProfile() {
loading = true;
error = '';
try {
const response = await fetch('/api/profile');
if (response.ok) {
profile = await response.json();
formEmail = profile?.email || '';
formDisplayName = profile?.display_name || '';
} else if (response.status === 401) {
goto('/login');
} else {
const data = await response.json();
error = data.error || 'Failed to load profile';
}
} catch (e) {
error = 'Failed to load profile';
} finally {
loading = false;
}
}
async function saveProfile() {
formSaving = true;
formError = '';
formSuccess = '';
try {
const response = await fetch('/api/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formEmail.trim() || null,
display_name: formDisplayName.trim() || null
})
});
if (response.ok) {
profile = await response.json();
formSuccess = 'Profile updated successfully';
// Refresh auth store to update sidebar
await authStore.check();
setTimeout(() => formSuccess = '', 3000);
} else {
const data = await response.json();
formError = data.error || 'Failed to update profile';
}
} catch (e) {
formError = 'Failed to update profile';
} finally {
formSaving = false;
}
}
function showSuccessMessage(message: string) {
formSuccess = message;
setTimeout(() => formSuccess = '', 3000);
}
async function setupMfa() {
if (!profile) return;
mfaLoading = true;
mfaError = '';
mfaQrCode = '';
mfaSecret = '';
try {
const response = await fetch(`/api/users/${profile.id}/mfa`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (response.ok) {
const data = await response.json();
mfaQrCode = data.qrDataUrl;
mfaSecret = data.secret;
showMfaSetupModal = true;
} else {
const data = await response.json();
mfaError = data.error || 'Failed to setup MFA';
}
} catch (e) {
mfaError = 'Failed to setup MFA';
} finally {
mfaLoading = false;
}
}
async function handleMfaEnabled() {
await fetchProfile();
showSuccessMessage('MFA enabled successfully');
}
async function handleMfaDisabled() {
await fetchProfile();
showSuccessMessage('MFA disabled successfully');
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return 'Never';
return new Date(dateStr).toLocaleString();
}
async function saveAvatar(dataUrl: string) {
avatarSaving = true;
formError = '';
try {
const response = await fetch('/api/profile/avatar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ avatar: dataUrl })
});
if (response.ok) {
const data = await response.json();
if (profile) {
profile.avatar = data.avatar;
}
formSuccess = 'Avatar updated successfully';
await authStore.check();
setTimeout(() => formSuccess = '', 3000);
// Close cropper
showAvatarCropper = false;
imageForCrop = '';
if (avatarFileInput) {
avatarFileInput.value = '';
}
} else {
const data = await response.json();
formError = data.error || 'Failed to upload avatar';
}
} catch (e) {
formError = 'Failed to upload avatar';
} finally {
avatarSaving = false;
}
}
async function removeAvatar() {
avatarSaving = true;
formError = '';
try {
const response = await fetch('/api/profile/avatar', {
method: 'DELETE'
});
if (response.ok) {
if (profile) {
profile.avatar = null;
}
formSuccess = 'Avatar removed successfully';
await authStore.check();
setTimeout(() => formSuccess = '', 3000);
} else {
const data = await response.json();
formError = data.error || 'Failed to remove avatar';
}
} catch (e) {
formError = 'Failed to remove avatar';
} finally {
avatarSaving = false;
}
}
// Wait for auth store to finish loading before making routing decisions
$effect(() => {
if (!$authStore.loading) {
if (!$authStore.authEnabled) {
goto('/');
return;
}
if (!$authStore.authenticated) {
goto('/login');
return;
}
if (!profileFetched) {
profileFetched = true;
fetchProfile();
}
}
});
</script>
<svelte:head>
<title>Profile - Dockhand</title>
</svelte:head>
<div class="container mx-auto p-6 max-w-4xl">
<div class="flex items-center gap-3 mb-6">
<PageHeader icon={User} title="Profile" showConnection={false}>
<p class="text-muted-foreground text-sm">Manage your account settings</p>
</PageHeader>
</div>
{#if loading}
<div class="flex items-center justify-center py-12">
<RefreshCw class="w-6 h-6 animate-spin text-muted-foreground" />
</div>
{:else if error}
<Card.Root>
<Card.Content class="py-6">
<div class="flex items-center gap-2 text-destructive">
<AlertTriangle class="w-5 h-5" />
<span>{error}</span>
</div>
</Card.Content>
</Card.Root>
{:else if profile}
<div class="grid gap-6">
<!-- Success message -->
{#if formSuccess}
<div class="bg-green-500/10 text-green-600 dark:text-green-400 p-3 rounded-md flex items-center gap-2">
<Check class="w-4 h-4" />
{formSuccess}
</div>
{/if}
<!-- Account info card -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<User class="w-5 h-5" />
Account information
</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex items-start gap-6">
<!-- Hidden file input -->
<input
type="file"
accept="image/*"
bind:this={avatarFileInput}
onchange={handleAvatarFileSelect}
class="hidden"
/>
<!-- Avatar section -->
<div class="flex flex-col items-center gap-2">
<div class="relative group">
<button
type="button"
onclick={triggerAvatarUpload}
class="block rounded-full focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
disabled={avatarSaving}
>
<Avatar.Root class="w-24 h-24 border-2 border-border cursor-pointer hover:opacity-80 transition-opacity">
<Avatar.Image src={profile.avatar} alt={profile.displayName || profile.username} />
<Avatar.Fallback class="bg-primary/10 text-primary text-2xl">
{(profile.displayName || profile.username)?.slice(0, 2).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
</button>
<button
type="button"
onclick={triggerAvatarUpload}
class="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
disabled={avatarSaving}
>
<Camera class="w-6 h-6 text-white" />
</button>
{#if profile.avatar}
<button
type="button"
onclick={removeAvatar}
disabled={avatarSaving}
class="absolute -bottom-1 -right-1 p-1 rounded-full bg-background border border-border text-muted-foreground hover:text-destructive hover:border-destructive transition-colors"
title="Remove photo"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
{/if}
</div>
</div>
<!-- Account details -->
<div class="flex-1 space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<Label class="text-muted-foreground text-xs">Username</Label>
<p class="font-medium">{profile.username}</p>
</div>
<div>
<Label class="text-muted-foreground text-xs">Role</Label>
<div class="flex items-center gap-2">
{#if profile.isAdmin}
<Badge variant="default" class="gap-1 rounded-sm">
<Crown class="w-3 h-3" />
Admin
</Badge>
{:else}
<Badge variant="secondary" class="rounded-sm">User</Badge>
{/if}
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<Label class="text-muted-foreground text-xs">Created</Label>
<p class="text-sm flex items-center gap-1">
<Calendar class="w-3.5 h-3.5" />
{formatDate(profile.createdAt)}
</p>
</div>
<div>
<Label class="text-muted-foreground text-xs">Last login</Label>
<p class="text-sm flex items-center gap-1">
<Clock class="w-3.5 h-3.5" />
{formatDate(profile.lastLogin)}
</p>
</div>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
<!-- Profile details card -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<Mail class="w-5 h-5" />
Profile details
</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
{#if formError}
<Alert.Root variant="destructive">
<TriangleAlert class="h-4 w-4" />
<Alert.Description>{formError}</Alert.Description>
</Alert.Root>
{/if}
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label>Display name</Label>
<Input
bind:value={formDisplayName}
placeholder="Enter display name"
/>
</div>
<div class="space-y-2">
<Label>Email</Label>
<Input
type="email"
bind:value={formEmail}
placeholder="Enter email"
/>
</div>
</div>
<div class="flex justify-end">
<Button onclick={saveProfile} disabled={formSaving}>
{#if formSaving}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
{:else}
<Check class="w-4 h-4 mr-1" />
{/if}
Save changes
</Button>
</div>
</Card.Content>
</Card.Root>
<!-- Security card -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<Shield class="w-5 h-5" />
Security
</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<!-- Password - only show for local auth users -->
{#if profile.provider === 'local'}
<div class="flex items-center justify-between p-3 border rounded-lg">
<div class="flex items-center gap-3">
<Key class="w-5 h-5 text-muted-foreground" />
<div>
<p class="font-medium">Password</p>
<p class="text-sm text-muted-foreground">Change your password</p>
</div>
</div>
<Button variant="outline" onclick={() => showPasswordModal = true}>
Change password
</Button>
</div>
{:else}
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="flex items-center gap-3">
<Key class="w-5 h-5 text-muted-foreground" />
<div>
<p class="font-medium">Password</p>
<p class="text-sm text-muted-foreground">Managed by your SSO provider</p>
</div>
</div>
<Badge class="gap-1 rounded-sm bg-yellow-500/20 text-yellow-600 border-yellow-500/30 hover:bg-yellow-500/30">
<ShieldCheck class="w-3 h-3" />
SSO
</Badge>
</div>
{/if}
<!-- MFA - only show for local auth users -->
{#if profile.provider === 'local'}
<div class="flex items-center justify-between p-3 border rounded-lg">
<div class="flex items-center gap-3">
<Smartphone class="w-5 h-5 text-muted-foreground" />
<div>
<div class="flex items-center gap-2">
<p class="font-medium">Two-factor authentication</p>
{#if profile.mfaEnabled}
<Badge variant="default" class="bg-green-500 gap-1 rounded-sm">
<ShieldCheck class="w-3 h-3" />
Enabled
</Badge>
{:else}
<Badge variant="secondary" class="rounded-sm">Disabled</Badge>
{/if}
</div>
<p class="text-sm text-muted-foreground">
{#if profile.mfaEnabled}
MFA is enabled for your account
{:else}
Add an extra layer of security
{/if}
</p>
</div>
</div>
{#if $licenseStore.isEnterprise}
{#if profile.mfaEnabled}
<Button variant="outline" onclick={() => showDisableMfaModal = true}>
Disable MFA
</Button>
{:else}
<Button onclick={setupMfa} disabled={mfaLoading}>
{#if mfaLoading}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
{:else}
<QrCode class="w-4 h-4 mr-1" />
{/if}
Setup MFA
</Button>
{/if}
{:else}
<Badge variant="outline" class="gap-1 rounded-sm">
<Crown class="w-3 h-3" />
Enterprise
</Badge>
{/if}
</div>
{:else}
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="flex items-center gap-3">
<Smartphone class="w-5 h-5 text-muted-foreground" />
<div>
<p class="font-medium">Two-factor authentication</p>
<p class="text-sm text-muted-foreground">Managed by your SSO provider</p>
</div>
</div>
<Badge class="gap-1 rounded-sm bg-yellow-500/20 text-yellow-600 border-yellow-500/30 hover:bg-yellow-500/30">
<ShieldCheck class="w-3 h-3" />
SSO
</Badge>
</div>
{/if}
{#if mfaError && !showMfaSetupModal}
<Alert.Root variant="destructive">
<TriangleAlert class="h-4 w-4" />
<Alert.Description>{mfaError}</Alert.Description>
</Alert.Root>
{/if}
</Card.Content>
</Card.Root>
<!-- Appearance card -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<Palette class="w-5 h-5" />
Appearance
</Card.Title>
<Card.Description>Customize the look of the application</Card.Description>
</Card.Header>
<Card.Content>
<ThemeSelector userId={profile.id} />
</Card.Content>
</Card.Root>
</div>
{/if}
</div>
<!-- Change Password Modal -->
<ChangePasswordModal
bind:open={showPasswordModal}
onClose={() => showPasswordModal = false}
onSuccess={showSuccessMessage}
/>
<!-- MFA Setup Modal -->
{#if profile}
<MfaSetupModal
bind:open={showMfaSetupModal}
qrCode={mfaQrCode}
secret={mfaSecret}
userId={profile.id}
onClose={() => showMfaSetupModal = false}
onSuccess={handleMfaEnabled}
/>
<DisableMfaModal
bind:open={showDisableMfaModal}
userId={profile.id}
onClose={() => showDisableMfaModal = false}
onSuccess={handleMfaDisabled}
/>
{/if}
<!-- Avatar Cropper Modal -->
<AvatarCropper
show={showAvatarCropper}
imageUrl={imageForCrop}
onCancel={cancelAvatarCrop}
onSave={saveAvatar}
/>