Feat: System settings (#66)

* Format checked, contributing.md update

* Middleware setup

* IAP API, create user/roles in frontend

* RBAC using CASL library

* Switch to CASL, secure search, resource-level access control

* Remove inherent behavior, index userEmail, adding docs for IAM policies

* Format

* System settings setup

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
This commit is contained in:
Wei S.
2025-08-28 14:12:05 +03:00
committed by GitHub
parent f1da17e484
commit baff1195c7
20 changed files with 1692 additions and 134 deletions

View File

@@ -0,0 +1,25 @@
import type { Request, Response } from 'express';
import { SettingsService } from '../../services/SettingsService';
const settingsService = new SettingsService();
export const getSettings = async (req: Request, res: Response) => {
try {
const settings = await settingsService.getSettings();
res.status(200).json(settings);
} catch (error) {
// A more specific error could be logged here
res.status(500).json({ message: 'Failed to retrieve settings' });
}
};
export const updateSettings = async (req: Request, res: Response) => {
try {
// Basic validation can be performed here if necessary
const updatedSettings = await settingsService.updateSettings(req.body);
res.status(200).json(updatedSettings);
} catch (error) {
// A more specific error could be logged here
res.status(500).json({ message: 'Failed to update settings' });
}
};

View File

@@ -0,0 +1,22 @@
import { Router } from 'express';
import * as settingsController from '../controllers/settings.controller';
import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import { AuthService } from '../../services/AuthService';
export const createSettingsRouter = (authService: AuthService): Router => {
const router = Router();
// Public route to get non-sensitive settings. settings read should not be scoped with a permission because all end users need the settings data in the frontend. However, for sensitive settings data, we need to add a new permission subject to limit access. So this route should only expose non-sensitive settings data.
router.get('/', settingsController.getSettings);
// Protected route to update settings
router.put(
'/',
requireAuth(authService),
requirePermission('manage', 'settings', 'You do not have permission to update system settings.'),
settingsController.updateSettings
);
return router;
};

View File

@@ -1,6 +0,0 @@
import { Router } from 'express';
import { ingestionQueue } from '../../jobs/queues';
const router: Router = Router();
export default router;

View File

@@ -0,0 +1,4 @@
CREATE TABLE "system_settings" (
"id" serial PRIMARY KEY NOT NULL,
"config" jsonb NOT NULL
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,125 +1,132 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1752225352591,
"tag": "0000_amusing_namora",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1752326803882,
"tag": "0001_odd_night_thrasher",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1752332648392,
"tag": "0002_lethal_quentin_quire",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1752332967084,
"tag": "0003_petite_wrecker",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1752606108876,
"tag": "0004_sleepy_paper_doll",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1752606327253,
"tag": "0005_chunky_sue_storm",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1753112018514,
"tag": "0006_majestic_caretaker",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1753190159356,
"tag": "0007_handy_archangel",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1753370737317,
"tag": "0008_eminent_the_spike",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1754337938241,
"tag": "0009_late_lenny_balinger",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1754420780849,
"tag": "0010_perpetual_lightspeed",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1754422064158,
"tag": "0011_tan_blackheart",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1754476962901,
"tag": "0012_warm_the_stranger",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1754659373517,
"tag": "0013_classy_talkback",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1754831765718,
"tag": "0014_foamy_vapor",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1755443936046,
"tag": "0015_wakeful_norman_osborn",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1755780572342,
"tag": "0016_lonely_mariko_yashida",
"breakpoints": true
}
]
}
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1752225352591,
"tag": "0000_amusing_namora",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1752326803882,
"tag": "0001_odd_night_thrasher",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1752332648392,
"tag": "0002_lethal_quentin_quire",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1752332967084,
"tag": "0003_petite_wrecker",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1752606108876,
"tag": "0004_sleepy_paper_doll",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1752606327253,
"tag": "0005_chunky_sue_storm",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1753112018514,
"tag": "0006_majestic_caretaker",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1753190159356,
"tag": "0007_handy_archangel",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1753370737317,
"tag": "0008_eminent_the_spike",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1754337938241,
"tag": "0009_late_lenny_balinger",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1754420780849,
"tag": "0010_perpetual_lightspeed",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1754422064158,
"tag": "0011_tan_blackheart",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1754476962901,
"tag": "0012_warm_the_stranger",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1754659373517,
"tag": "0013_classy_talkback",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1754831765718,
"tag": "0014_foamy_vapor",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1755443936046,
"tag": "0015_wakeful_norman_osborn",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1755780572342,
"tag": "0016_lonely_mariko_yashida",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1755961566627,
"tag": "0017_tranquil_shooting_star",
"breakpoints": true
}
]
}

View File

@@ -5,3 +5,4 @@ export * from './schema/compliance';
export * from './schema/custodians';
export * from './schema/ingestion-sources';
export * from './schema/users';
export * from './schema/system-settings'

View File

@@ -0,0 +1,7 @@
import { pgTable, serial, jsonb } from 'drizzle-orm/pg-core';
import type { SystemSettings } from '@open-archiver/types';
export const systemSettings = pgTable('system_settings', {
id: serial('id').primaryKey(),
config: jsonb('config').$type<SystemSettings>().notNull(),
});

View File

@@ -16,7 +16,7 @@ import { createSearchRouter } from './api/routes/search.routes';
import { createDashboardRouter } from './api/routes/dashboard.routes';
import { createUploadRouter } from './api/routes/upload.routes';
import { createUserRouter } from './api/routes/user.routes';
import testRouter from './api/routes/test.routes';
import { createSettingsRouter } from './api/routes/settings.routes';
import { AuthService } from './services/AuthService';
import { UserService } from './services/UserService';
import { IamService } from './services/IamService';
@@ -62,6 +62,7 @@ const dashboardRouter = createDashboardRouter(authService);
const iamRouter = createIamRouter(iamController, authService);
const uploadRouter = createUploadRouter(authService);
const userRouter = createUserRouter(authService);
const settingsRouter = createSettingsRouter(authService);
// upload route is added before middleware because it doesn't use the json middleware.
app.use('/v1/upload', uploadRouter);
@@ -77,7 +78,7 @@ app.use('/v1/storage', storageRouter);
app.use('/v1/search', searchRouter);
app.use('/v1/dashboard', dashboardRouter);
app.use('/v1/users', userRouter);
app.use('/v1/test', testRouter);
app.use('/v1/settings', settingsRouter);
// Example of a protected route
app.get('/v1/protected', requireAuth(authService), (req, res) => {

View File

@@ -0,0 +1,60 @@
import { db } from '../database';
import { systemSettings } from '../database/schema/system-settings';
import type { SystemSettings } from '@open-archiver/types';
import { eq } from 'drizzle-orm';
const DEFAULT_SETTINGS: SystemSettings = {
language: 'en',
theme: 'system',
supportEmail: null,
};
export class SettingsService {
/**
* Retrieves the current system settings.
* If no settings exist, it initializes and returns the default settings.
* @returns The system settings.
*/
public async getSettings(): Promise<SystemSettings> {
const settings = await db.select().from(systemSettings).limit(1);
if (settings.length === 0) {
return this.createDefaultSettings();
}
return settings[0].config;
}
/**
* Updates the system settings by merging the new configuration with the existing one.
* @param newConfig - A partial object of the new settings configuration.
* @returns The updated system settings.
*/
public async updateSettings(
newConfig: Partial<SystemSettings>
): Promise<SystemSettings> {
const currentConfig = await this.getSettings();
const mergedConfig = { ...currentConfig, ...newConfig };
// Since getSettings ensures a record always exists, we can directly update.
const [result] = await db
.update(systemSettings)
.set({ config: mergedConfig })
.returning();
return result.config;
}
/**
* Creates and saves the default system settings.
* This is called internally when no settings are found.
* @returns The newly created default settings.
*/
private async createDefaultSettings(): Promise<SystemSettings> {
const [result] = await db
.insert(systemSettings)
.values({ config: DEFAULT_SETTINGS })
.returning();
return result.config;
}
}

View File

@@ -0,0 +1,10 @@
import Root from "./radio-group.svelte";
import Item from "./radio-group-item.svelte";
export {
Root,
Item,
//
Root as RadioGroup,
Item as RadioGroupItem,
};

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import CircleIcon from "@lucide/svelte/icons/circle";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<RadioGroupPrimitive.ItemProps> = $props();
</script>
<RadioGroupPrimitive.Item
bind:ref
data-slot="radio-group-item"
class={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 shadow-xs aspect-square size-4 shrink-0 rounded-full border outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<div data-slot="radio-group-indicator" class="relative flex items-center justify-center">
{#if checked}
<CircleIcon
class="fill-primary absolute left-1/2 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2"
/>
{/if}
</div>
{/snippet}
</RadioGroupPrimitive.Item>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
...restProps
}: RadioGroupPrimitive.RootProps = $props();
</script>
<RadioGroupPrimitive.Root
bind:ref
bind:value
data-slot="radio-group"
class={cn("grid gap-3", className)}
{...restProps}
/>

View File

@@ -20,9 +20,13 @@ export const load: LayoutServerLoad = async (event) => {
throw error;
}
const settingsResponse = await api('/settings', event);
const settings = settingsResponse.ok ? await settingsResponse.json() : null;
return {
user: locals.user,
accessToken: locals.accessToken,
isDemo: process.env.IS_DEMO === 'true',
settings,
};
};

View File

@@ -15,9 +15,16 @@
$effect(() => {
if (browser) {
let finalTheme = $theme;
if (finalTheme === 'system') {
finalTheme = data.settings?.theme || 'system';
}
const isDark =
$theme === 'dark' ||
($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
finalTheme === 'dark' ||
(finalTheme === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', isDark);
}
});

View File

@@ -20,6 +20,10 @@
{
label: 'Settings',
subMenu: [
{
href: '/dashboard/settings/system',
label: 'System',
},
{
href: '/dashboard/settings/users',
label: 'Users',

View File

@@ -0,0 +1,50 @@
import { api } from '$lib/server/api';
import type { SystemSettings } from '@open-archiver/types';
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const response = await api('/settings', event);
if (!response.ok) {
const { message } = await response.json();
throw error(response.status, message || 'Failed to fetch system settings');
}
const settings: SystemSettings = await response.json();
return {
settings,
};
};
export const actions: Actions = {
default: async (event) => {
const formData = await event.request.formData();
const language = formData.get('language');
const theme = formData.get('theme');
const supportEmail = formData.get('supportEmail');
const body: Partial<SystemSettings> = {
language: language as SystemSettings['language'],
theme: theme as SystemSettings['theme'],
supportEmail: supportEmail ? String(supportEmail) : null,
};
const response = await api('/settings', event, {
method: 'PUT',
body: JSON.stringify(body),
});
if (!response.ok) {
const { message } = await response.json();
return fail(response.status, { message: message || 'Failed to update settings' });
}
const updatedSettings: SystemSettings = await response.json();
return {
success: true,
settings: updatedSettings,
};
},
};

View File

@@ -0,0 +1,123 @@
<script lang="ts">
import type { PageData } from './$types';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import * as Label from '$lib/components/ui/label';
import * as RadioGroup from '$lib/components/ui/radio-group';
import * as Select from '$lib/components/ui/select';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
import type { SupportedLanguage } from '@open-archiver/types';
let { data, form }: { data: PageData; form: any } = $props();
let settings = $state(data.settings);
let isSaving = $state(false);
const languageOptions: { value: SupportedLanguage; label: string }[] = [
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Spanish' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'German' },
{ value: 'it', label: 'Italian' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'nl', label: 'Dutch' },
{ value: 'ja', label: 'Japanese' },
];
const languageTriggerContent = $derived(
languageOptions.find((lang) => lang.value === settings.language)?.label ??
'Select a language'
);
$effect(() => {
if (form?.success) {
settings = form.settings;
setAlert({
type: 'success',
title: 'Settings Updated',
message: 'Your changes have been saved successfully.',
duration: 3000,
show: true,
});
} else if (form?.message) {
setAlert({
type: 'error',
title: 'Update Failed',
message: form.message,
duration: 5000,
show: true,
});
}
});
</script>
<svelte:head>
<title>System Settings - OpenArchiver</title>
</svelte:head>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold">System Settings</h1>
<p class="text-muted-foreground">Manage global application settings.</p>
</div>
<form method="POST" class="space-y-8" onsubmit={() => (isSaving = true)}>
<Card.Root>
<Card.Content class="space-y-4">
<!-- Hide language setting for now -->
<!-- <div class="grid gap-2">
<Label.Root class="mb-1" for="language">Language</Label.Root>
<Select.Root name="language" bind:value={settings.language} type="single">
<Select.Trigger class="w-[280px]">
{languageTriggerContent}
</Select.Trigger>
<Select.Content>
{#each languageOptions as lang}
<Select.Item value={lang.value}>{lang.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div> -->
<div class="grid gap-2">
<Label.Root class="mb-1">Default theme</Label.Root>
<RadioGroup.Root
bind:value={settings.theme}
name="theme"
class="flex items-center gap-4"
>
<div class="flex items-center gap-2">
<RadioGroup.Item value="light" id="light" />
<Label.Root for="light">Light</Label.Root>
</div>
<div class="flex items-center gap-2">
<RadioGroup.Item value="dark" id="dark" />
<Label.Root for="dark">Dark</Label.Root>
</div>
<div class="flex items-center gap-2">
<RadioGroup.Item value="system" id="system" />
<Label.Root for="system">System</Label.Root>
</div>
</RadioGroup.Root>
</div>
<div class="grid gap-2">
<Label.Root class="mb-1" for="supportEmail">Support Email</Label.Root>
<Input
id="supportEmail"
name="supportEmail"
type="email"
placeholder="support@example.com"
bind:value={settings.supportEmail}
class="max-w-sm"
/>
</div>
</Card.Content>
<Card.Footer class="border-t px-6 py-4">
<Button type="submit" disabled={isSaving}>
{#if isSaving}Saving...{:else}Save Changes{/if}
</Button>
</Card.Footer>
</Card.Root>
</form>
</div>

View File

@@ -7,3 +7,4 @@ export * from './archived-emails.types';
export * from './search.types';
export * from './dashboard.types';
export * from './iam.types';
export * from './system.types';

View File

@@ -0,0 +1,22 @@
export type SupportedLanguage =
| 'en' // English
| 'es' // Spanish
| 'fr' // French
| 'de' // German
| 'it' // Italian
| 'pt' // Portuguese
| 'nl' // Dutch
| 'ja'; // Japanese
export type Theme = 'light' | 'dark' | 'system';
export interface SystemSettings {
/** The default display language for the application UI. */
language: SupportedLanguage;
/** The default color theme for the application. */
theme: Theme;
/** A public-facing email address for user support inquiries. */
supportEmail: string | null;
}