User profile/account page, change password, API

This commit is contained in:
wayneshn
2025-12-31 15:59:45 +02:00
parent ca6e516d32
commit 9db8da9c8e
7 changed files with 400 additions and 1 deletions

View File

@@ -79,3 +79,60 @@ export const deleteUser = async (req: Request, res: Response) => {
await userService.deleteUser(req.params.id, actor, req.ip || 'unknown');
res.status(204).send();
};
export const getProfile = async (req: Request, res: Response) => {
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
}
const user = await userService.findById(req.user.sub);
if (!user) {
return res.status(404).json({ message: req.t('user.notFound') });
}
res.json(user);
};
export const updateProfile = async (req: Request, res: Response) => {
const { email, first_name, last_name } = req.body;
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
}
const actor = await userService.findById(req.user.sub);
if (!actor) {
return res.status(401).json({ message: 'Unauthorized' });
}
const updatedUser = await userService.updateUser(
req.user.sub,
{ email, first_name, last_name },
undefined,
actor,
req.ip || 'unknown'
);
res.json(updatedUser);
};
export const updatePassword = async (req: Request, res: Response) => {
const { currentPassword, newPassword } = req.body;
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
}
const actor = await userService.findById(req.user.sub);
if (!actor) {
return res.status(401).json({ message: 'Unauthorized' });
}
try {
await userService.updatePassword(
req.user.sub,
currentPassword,
newPassword,
actor,
req.ip || 'unknown'
);
res.status(200).json({ message: 'Password updated successfully' });
} catch (e: any) {
if (e.message === 'Invalid current password') {
return res.status(400).json({ message: e.message });
}
throw e;
}
};

View File

@@ -11,6 +11,10 @@ export const createUserRouter = (authService: AuthService): Router => {
router.get('/', requirePermission('read', 'users'), userController.getUsers);
router.get('/profile', userController.getProfile);
router.patch('/profile', userController.updateProfile);
router.post('/profile/password', userController.updatePassword);
router.get('/:id', requirePermission('read', 'users'), userController.getUser);
/**

View File

@@ -1,7 +1,7 @@
import { db } from '../database';
import * as schema from '../database/schema';
import { eq, sql } from 'drizzle-orm';
import { hash } from 'bcryptjs';
import { hash, compare } from 'bcryptjs';
import type { CaslPolicy, User } from '@open-archiver/types';
import { AuditService } from './AuditService';
@@ -152,6 +152,46 @@ export class UserService {
});
}
public async updatePassword(
id: string,
currentPassword: string,
newPassword: string,
actor: User,
actorIp: string
): Promise<void> {
const user = await db.query.users.findFirst({
where: eq(schema.users.id, id),
});
if (!user || !user.password) {
throw new Error('User not found');
}
const isPasswordValid = await compare(currentPassword, user.password);
if (!isPasswordValid) {
throw new Error('Invalid current password');
}
const hashedPassword = await hash(newPassword, 10);
await db
.update(schema.users)
.set({ password: hashedPassword })
.where(eq(schema.users.id, id));
await UserService.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'UPDATE',
targetType: 'User',
targetId: id,
actorIp,
details: {
field: 'password',
},
});
}
/**
* Creates an admin user in the database. The user created will be assigned the 'Super Admin' role.
*

View File

@@ -118,6 +118,23 @@
"confirm": "Confirm",
"cancel": "Cancel"
},
"account": {
"title": "Account Settings",
"description": "Manage your profile and security settings.",
"personal_info": "Personal Information",
"personal_info_desc": "Update your personal details.",
"security": "Security",
"security_desc": "Manage your password and security preferences.",
"edit_profile": "Edit Profile",
"change_password": "Change Password",
"edit_profile_desc": "Make changes to your profile here.",
"change_password_desc": "Change your password. You will need to enter your current password.",
"current_password": "Current Password",
"new_password": "New Password",
"confirm_new_password": "Confirm New Password",
"operation_successful": "Operation successful",
"passwords_do_not_match": "Passwords do not match"
},
"system_settings": {
"title": "System Settings",
"system_settings": "System Settings",
@@ -234,6 +251,7 @@
"users": "Users",
"roles": "Roles",
"api_keys": "API Keys",
"account": "Account",
"logout": "Logout",
"admin": "Admin"
},

View File

@@ -64,6 +64,10 @@
href: '/dashboard/settings/api-keys',
label: $t('app.layout.api_keys'),
},
{
href: '/dashboard/settings/account',
label: $t('app.layout.account'),
},
],
position: 5,
},

View File

@@ -0,0 +1,58 @@
import type { PageServerLoad, Actions } from './$types';
import { api } from '$lib/server/api';
import { fail } from '@sveltejs/kit';
import type { User } from '@open-archiver/types';
export const load: PageServerLoad = async (event) => {
const response = await api('/users/profile', event);
if (!response.ok) {
const error = await response.json();
console.error('Failed to fetch profile:', error);
// Return null user if failed, handle in UI
return { user: null };
}
const user: User = await response.json();
return { user };
};
export const actions: Actions = {
updateProfile: async (event) => {
const data = await event.request.formData();
const first_name = data.get('first_name');
const last_name = data.get('last_name');
const email = data.get('email');
const response = await api('/users/profile', event, {
method: 'PATCH',
body: JSON.stringify({ first_name, last_name, email }),
});
if (!response.ok) {
const error = await response.json();
return fail(response.status, {
profileError: true,
message: error.message || 'Failed to update profile',
});
}
return { success: true };
},
updatePassword: async (event) => {
const data = await event.request.formData();
const currentPassword = data.get('currentPassword');
const newPassword = data.get('newPassword');
const response = await api('/users/profile/password', event, {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
});
if (!response.ok) {
const error = await response.json();
return fail(response.status, {
passwordError: true,
message: error.message || 'Failed to update password',
});
}
return { success: true };
},
};

View File

@@ -0,0 +1,218 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { t } from '$lib/translations';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Dialog from '$lib/components/ui/dialog';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
let { data, form } = $props();
let user = $derived(data.user);
let isProfileDialogOpen = $state(false);
let isPasswordDialogOpen = $state(false);
let isSubmitting = $state(false);
// Profile form state
let profileFirstName = $state('');
let profileLastName = $state('');
let profileEmail = $state('');
// Password form state
let currentPassword = $state('');
let newPassword = $state('');
let confirmNewPassword = $state('');
// Preload profile form
$effect(() => {
if (user && isProfileDialogOpen) {
profileFirstName = user.first_name || '';
profileLastName = user.last_name || '';
profileEmail = user.email || '';
}
});
// Handle form actions result
$effect(() => {
if (form) {
isSubmitting = false;
if (form.success) {
isProfileDialogOpen = false;
isPasswordDialogOpen = false;
setAlert({
type: 'success',
title: $t('app.account.operation_successful'),
message: $t('app.account.operation_successful'),
duration: 3000,
show: true
});
} else if (form.profileError || form.passwordError) {
setAlert({
type: 'error',
title: $t('app.search.error'),
message: form.message,
duration: 3000,
show: true
});
}
}
});
function openProfileDialog() {
isProfileDialogOpen = true;
}
function openPasswordDialog() {
currentPassword = '';
newPassword = '';
confirmNewPassword = '';
isPasswordDialogOpen = true;
}
</script>
<svelte:head>
<title>{$t('app.account.title')} - OpenArchiver</title>
</svelte:head>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold">{$t('app.account.title')}</h1>
<p class="text-muted-foreground">{$t('app.account.description')}</p>
</div>
<!-- Personal Information -->
<Card.Root>
<Card.Header>
<Card.Title>{$t('app.account.personal_info')}</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<Label class="text-muted-foreground">{$t('app.users.name')}</Label>
<p class="text-sm font-medium">{user?.first_name} {user?.last_name}</p>
</div>
<div>
<Label class="text-muted-foreground">{$t('app.users.email')}</Label>
<p class="text-sm font-medium">{user?.email}</p>
</div>
<div>
<Label class="text-muted-foreground">{$t('app.users.role')}</Label>
<p class="text-sm font-medium">{user?.role?.name || '-'}</p>
</div>
</div>
</Card.Content>
<Card.Footer>
<Button variant="outline" onclick={openProfileDialog}>{$t('app.account.edit_profile')}</Button>
</Card.Footer>
</Card.Root>
<!-- Security -->
<Card.Root>
<Card.Header>
<Card.Title>{$t('app.account.security')}</Card.Title>
</Card.Header>
<Card.Content>
<div class="flex items-center justify-between">
<div>
<Label class="text-muted-foreground">{$t('app.auth.password')}</Label>
<p class="text-sm">*************</p>
</div>
</div>
</Card.Content>
<Card.Footer>
<Button variant="outline" onclick={openPasswordDialog}>{$t('app.account.change_password')}</Button>
</Card.Footer>
</Card.Root>
</div>
<!-- Profile Edit Dialog -->
<Dialog.Root bind:open={isProfileDialogOpen}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>{$t('app.account.edit_profile')}</Dialog.Title>
<Dialog.Description>{$t('app.account.edit_profile_desc')}</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/updateProfile" use:enhance={() => {
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
};
}} class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="first_name" class="text-right">{$t('app.setup.first_name')}</Label>
<Input id="first_name" name="first_name" bind:value={profileFirstName} class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="last_name" class="text-right">{$t('app.setup.last_name')}</Label>
<Input id="last_name" name="last_name" bind:value={profileLastName} class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="email" class="text-right">{$t('app.users.email')}</Label>
<Input id="email" name="email" type="email" bind:value={profileEmail} class="col-span-3" />
</div>
<Dialog.Footer>
<Button type="submit" disabled={isSubmitting}>
{#if isSubmitting}
{$t('app.components.common.submitting')}
{:else}
{$t('app.components.common.save')}
{/if}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<!-- Change Password Dialog -->
<Dialog.Root bind:open={isPasswordDialogOpen}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>{$t('app.account.change_password')}</Dialog.Title>
<Dialog.Description>{$t('app.account.change_password_desc')}</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/updatePassword" use:enhance={({ cancel }) => {
if (newPassword !== confirmNewPassword) {
setAlert({
type: 'error',
title: $t('app.search.error'),
message: $t('app.account.passwords_do_not_match'),
duration: 3000,
show: true
});
cancel();
return;
}
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
};
}} class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="currentPassword" class="text-right">{$t('app.account.current_password')}</Label>
<Input id="currentPassword" name="currentPassword" type="password" bind:value={currentPassword} class="col-span-3" required />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="newPassword" class="text-right">{$t('app.account.new_password')}</Label>
<Input id="newPassword" name="newPassword" type="password" bind:value={newPassword} class="col-span-3" required />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="confirmNewPassword" class="text-right">{$t('app.account.confirm_new_password')}</Label>
<Input id="confirmNewPassword" type="password" bind:value={confirmNewPassword} class="col-span-3" required />
</div>
<Dialog.Footer>
<Button type="submit" disabled={isSubmitting}>
{#if isSubmitting}
{$t('app.components.common.submitting')}
{:else}
{$t('app.components.common.save')}
{/if}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>