From 24afd13858cf90b1edd698ea61509e86ae7be375 Mon Sep 17 00:00:00 2001 From: "Wei S." <5291640+wayneshn@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:46:27 +0200 Subject: [PATCH] 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. --- docs/api/ingestion.md | 2 +- .../src/api/controllers/api-key.controller.ts | 12 +- .../src/api/controllers/user.controller.ts | 57 +++++ .../backend/src/api/routes/user.routes.ts | 4 + .../backend/src/services/ApiKeyService.ts | 22 +- packages/backend/src/services/UserService.ts | 42 +++- .../frontend/src/lib/translations/en.json | 18 ++ .../src/routes/api/[...slug]/+server.ts | 14 +- .../src/routes/dashboard/+layout.svelte | 4 + .../settings/account/+page.server.ts | 58 +++++ .../dashboard/settings/account/+page.svelte | 218 ++++++++++++++++++ 11 files changed, 432 insertions(+), 19 deletions(-) create mode 100644 packages/frontend/src/routes/dashboard/settings/account/+page.server.ts create mode 100644 packages/frontend/src/routes/dashboard/settings/account/+page.svelte diff --git a/docs/api/ingestion.md b/docs/api/ingestion.md index 9168d9e..4071bc4 100644 --- a/docs/api/ingestion.md +++ b/docs/api/ingestion.md @@ -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; } ``` diff --git a/packages/backend/src/api/controllers/api-key.controller.ts b/packages/backend/src/api/controllers/api-key.controller.ts index 97b2b95..7f27025 100644 --- a/packages/backend/src/api/controllers/api-key.controller.ts +++ b/packages/backend/src/api/controllers/api-key.controller.ts @@ -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') }); - } + }; } diff --git a/packages/backend/src/api/controllers/user.controller.ts b/packages/backend/src/api/controllers/user.controller.ts index 418c1de..f1df9cb 100644 --- a/packages/backend/src/api/controllers/user.controller.ts +++ b/packages/backend/src/api/controllers/user.controller.ts @@ -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; + } +}; diff --git a/packages/backend/src/api/routes/user.routes.ts b/packages/backend/src/api/routes/user.routes.ts index 794e3c1..1aeac87 100644 --- a/packages/backend/src/api/routes/user.routes.ts +++ b/packages/backend/src/api/routes/user.routes.ts @@ -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); /** diff --git a/packages/backend/src/services/ApiKeyService.ts b/packages/backend/src/services/ApiKeyService.ts index 64fe4fb..0f2a6a9 100644 --- a/packages/backend/src/services/ApiKeyService.ts +++ b/packages/backend/src/services/ApiKeyService.ts @@ -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 { diff --git a/packages/backend/src/services/UserService.ts b/packages/backend/src/services/UserService.ts index 037fa53..8fec724 100644 --- a/packages/backend/src/services/UserService.ts +++ b/packages/backend/src/services/UserService.ts @@ -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 { + 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. * diff --git a/packages/frontend/src/lib/translations/en.json b/packages/frontend/src/lib/translations/en.json index c040369..1452f45 100644 --- a/packages/frontend/src/lib/translations/en.json +++ b/packages/frontend/src/lib/translations/en.json @@ -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" }, diff --git a/packages/frontend/src/routes/api/[...slug]/+server.ts b/packages/frontend/src/routes/api/[...slug]/+server.ts index 297ff85..e8ace6e 100644 --- a/packages/frontend/src/routes/api/[...slug]/+server.ts +++ b/packages/frontend/src/routes/api/[...slug]/+server.ts @@ -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); diff --git a/packages/frontend/src/routes/dashboard/+layout.svelte b/packages/frontend/src/routes/dashboard/+layout.svelte index 4326ee1..42032b4 100644 --- a/packages/frontend/src/routes/dashboard/+layout.svelte +++ b/packages/frontend/src/routes/dashboard/+layout.svelte @@ -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, }, diff --git a/packages/frontend/src/routes/dashboard/settings/account/+page.server.ts b/packages/frontend/src/routes/dashboard/settings/account/+page.server.ts new file mode 100644 index 0000000..82feeec --- /dev/null +++ b/packages/frontend/src/routes/dashboard/settings/account/+page.server.ts @@ -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 }; + }, +}; diff --git a/packages/frontend/src/routes/dashboard/settings/account/+page.svelte b/packages/frontend/src/routes/dashboard/settings/account/+page.svelte new file mode 100644 index 0000000..bdddc13 --- /dev/null +++ b/packages/frontend/src/routes/dashboard/settings/account/+page.svelte @@ -0,0 +1,218 @@ + + + + {$t('app.account.title')} - OpenArchiver + + +
+
+

{$t('app.account.title')}

+

{$t('app.account.description')}

+
+ + + + + {$t('app.account.personal_info')} + + +
+
+ +

{user?.first_name} {user?.last_name}

+
+
+ +

{user?.email}

+
+
+ +

{user?.role?.name || '-'}

+
+
+
+ + + +
+ + + + + {$t('app.account.security')} + + +
+
+ +

*************

+
+
+
+ + + +
+
+ + + + + + {$t('app.account.edit_profile')} + {$t('app.account.edit_profile_desc')} + +
{ + isSubmitting = true; + return async ({ update }) => { + await update(); + isSubmitting = false; + }; + }} class="grid gap-4 py-4"> +
+ + +
+
+ + +
+
+ + +
+ + + +
+
+
+ + + + + + {$t('app.account.change_password')} + {$t('app.account.change_password_desc')} + +
{ + 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"> +
+ + +
+
+ + +
+
+ + +
+ + + +
+
+