V0.4.1: API key generation fix, change password, account profile (#273)

* fix(api): correct API key generation and proxy handling

This commit resolves an issue where generating a new API key would fail. The root cause was improper handling of POST request bodies in the frontend proxy server.

- Refactored `ApiKeyController` methods to use arrow functions to ensure correct `this` binding.

* User profile/account page, change password, API

* docs(api): update ingestion source provider values

Update the `CreateIngestionSourceDto` documentation in `ingestion.md` to reflect the current set of supported providers.
This commit is contained in:
Wei S.
2026-01-17 02:46:27 +02:00
committed by GitHub
parent c2006dfa94
commit 24afd13858
11 changed files with 432 additions and 19 deletions

View File

@@ -19,7 +19,7 @@ The request body should be a `CreateIngestionSourceDto` object.
```typescript
interface CreateIngestionSourceDto {
name: string;
provider: 'google' | 'microsoft' | 'generic_imap';
provider: 'google_workspace' | 'microsoft_365' | 'generic_imap' | 'pst_import' | 'eml_import' | 'mbox_import';
providerConfig: IngestionCredentials;
}
```

View File

@@ -16,7 +16,7 @@ const generateApiKeySchema = z.object({
});
export class ApiKeyController {
private userService = new UserService();
public async generateApiKey(req: Request, res: Response) {
public generateApiKey = async (req: Request, res: Response) => {
try {
const { name, expiresInDays } = generateApiKeySchema.parse(req.body);
if (!req.user || !req.user.sub) {
@@ -45,9 +45,9 @@ export class ApiKeyController {
}
res.status(500).json({ message: req.t('errors.internalServerError') });
}
}
};
public async getApiKeys(req: Request, res: Response) {
public getApiKeys = async (req: Request, res: Response) => {
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
}
@@ -55,9 +55,9 @@ export class ApiKeyController {
const keys = await ApiKeyService.getKeys(userId);
res.status(200).json(keys);
}
};
public async deleteApiKey(req: Request, res: Response) {
public deleteApiKey = async (req: Request, res: Response) => {
const { id } = req.params;
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
@@ -70,5 +70,5 @@ export class ApiKeyController {
await ApiKeyService.deleteKey(id, userId, actor, req.ip || 'unknown');
res.status(204).send({ message: req.t('apiKeys.deleteSuccess') });
}
};
}

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

@@ -20,16 +20,17 @@ export class ApiKeyService {
expiresAt.setDate(expiresAt.getDate() + expiresInDays);
const keyHash = createHash('sha256').update(key).digest('hex');
await db.insert(apiKeys).values({
userId,
name,
key: CryptoService.encrypt(key),
keyHash,
expiresAt,
});
try {
await db.insert(apiKeys).values({
userId,
name,
key: CryptoService.encrypt(key),
keyHash,
expiresAt,
});
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'GENERATE',
targetType: 'ApiKey',
targetId: name,
@@ -40,6 +41,9 @@ export class ApiKeyService {
});
return key;
} catch (error) {
throw error;
}
}
public static async getKeys(userId: string): Promise<ApiKey[]> {

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

@@ -10,10 +10,20 @@ const handleRequest: RequestHandler = async ({ request, params, fetch }) => {
const targetUrl = `${BACKEND_URL}/${slug}${url.search}`;
try {
let body: ArrayBuffer | null = null;
const headers = new Headers(request.headers);
if (request.method !== 'GET' && request.method !== 'HEAD') {
body = await request.arrayBuffer();
if (body.byteLength > 0) {
headers.set('Content-Length', String(body.byteLength));
}
}
const proxyRequest = new Request(targetUrl, {
method: request.method,
headers: request.headers,
body: request.body,
headers: headers,
body: body,
duplex: 'half',
} as RequestInit);

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>