mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
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:
25
packages/backend/src/api/controllers/settings.controller.ts
Normal file
25
packages/backend/src/api/controllers/settings.controller.ts
Normal 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' });
|
||||
}
|
||||
};
|
||||
22
packages/backend/src/api/routes/settings.routes.ts
Normal file
22
packages/backend/src/api/routes/settings.routes.ts
Normal 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;
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { ingestionQueue } from '../../jobs/queues';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE "system_settings" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"config" jsonb NOT NULL
|
||||
);
|
||||
1166
packages/backend/src/database/migrations/meta/0017_snapshot.json
Normal file
1166
packages/backend/src/database/migrations/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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'
|
||||
7
packages/backend/src/database/schema/system-settings.ts
Normal file
7
packages/backend/src/database/schema/system-settings.ts
Normal 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(),
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
60
packages/backend/src/services/SettingsService.ts
Normal file
60
packages/backend/src/services/SettingsService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
10
packages/frontend/src/lib/components/ui/radio-group/index.ts
Normal file
10
packages/frontend/src/lib/components/ui/radio-group/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
{
|
||||
label: 'Settings',
|
||||
subMenu: [
|
||||
{
|
||||
href: '/dashboard/settings/system',
|
||||
label: 'System',
|
||||
},
|
||||
{
|
||||
href: '/dashboard/settings/users',
|
||||
label: 'Users',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
22
packages/types/src/system.types.ts
Normal file
22
packages/types/src/system.types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user