mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
Compare commits
4 Commits
v0.4.2-fix
...
v0.4.1-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
254c426a83 | ||
|
|
a97afcc827 | ||
|
|
9db8da9c8e | ||
|
|
ca6e516d32 |
@@ -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;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-archiver",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"private": true,
|
||||
"license": "SEE LICENSE IN LICENSE file",
|
||||
"scripts": {
|
||||
|
||||
@@ -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') });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user