From 9fdba4cd61af4eb29d0ff5493ef0c54f8be4564d Mon Sep 17 00:00:00 2001 From: "Wei S." <5291640+wayneshn@users.noreply.github.com> Date: Sun, 24 Aug 2025 15:52:08 +0300 Subject: [PATCH 1/4] Role based access: Adding docs to docs site (#67) * 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 * Adding IAM policy documentation to Docs site --------- Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com> --- docs/.vitepress/config.mts | 6 + docs/services/iam-service/iam-policy.md | 289 ++++++++++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 docs/services/iam-service/iam-policy.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 0a7d459..12f9aee 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -10,6 +10,7 @@ export default defineConfig({ 'data-website-id': '2c8b452e-eab5-4f82-8ead-902d8f8b976f', }, ], + ['link', { rel: 'icon', href: '/logo-sq.svg' }], ], title: 'Open Archiver', description: 'Official documentation for the Open Archiver project.', @@ -73,6 +74,11 @@ export default defineConfig({ items: [ { text: 'Overview', link: '/services/' }, { text: 'Storage Service', link: '/services/storage-service' }, + { + text: 'IAM Service', items: [ + { text: 'IAM Policies', link: '/services/iam-service/iam-policy' } + ] + }, ], }, ], diff --git a/docs/services/iam-service/iam-policy.md b/docs/services/iam-service/iam-policy.md new file mode 100644 index 0000000..5f34243 --- /dev/null +++ b/docs/services/iam-service/iam-policy.md @@ -0,0 +1,289 @@ +# IAM Policy + +This document provides a guide to creating and managing IAM policies in Open Archiver. It is intended for developers and administrators who need to configure granular access control for users and roles. + +## Policy Structure + +IAM policies are defined as an array of JSON objects, where each object represents a single permission rule. The structure of a policy object is as follows: + +```json +{ + "action": "read" OR ["read", "create"], + "subject": "ingestion" OR ["ingestion", "dashboard"], + "conditions": { + "field_name": "value" + }, + "inverted": false OR true, +} +``` + +- `action`: The action(s) to be performed on the subject. Can be a single string or an array of strings. +- `subject`: The resource(s) or entity on which the action is to be performed. Can be a single string or an array of strings. +- `conditions`: (Optional) A set of conditions that must be met for the permission to be granted. +- `inverted`: (Optional) When set to `true`, this inverts the rule, turning it from a "can" rule into a "cannot" rule. This is useful for creating exceptions to broader permissions. + +## Actions + +The following actions are available for use in IAM policies: + +- `manage`: A wildcard action that grants all permissions on a subject (`create`, `read`, `update`, `delete`, `search`, `sync`). +- `create`: Allows the user to create a new resource. +- `read`: Allows the user to view a resource. +- `update`: Allows the user to modify an existing resource. +- `delete`: Allows the user to delete a resource. +- `search`: Allows the user to search for resources. +- `sync`: Allows the user to synchronize a resource. + +## Subjects + +The following subjects are available for use in IAM policies: + +- `all`: A wildcard subject that represents all resources. +- `archive`: Represents archived emails. +- `ingestion`: Represents ingestion sources. +- `settings`: Represents system settings. +- `users`: Represents user accounts. +- `roles`: Represents user roles. +- `dashboard`: Represents the dashboard. + +## Advanced Conditions with MongoDB-Style Queries + +Conditions are the key to creating fine-grained access control rules. They are defined as a JSON object where each key represents a field on the subject, and the value defines the criteria for that field. + +All conditions within a single rule are implicitly joined with an **AND** logic. This means that for a permission to be granted, the resource must satisfy _all_ specified conditions. + +The power of this system comes from its use of a subset of [MongoDB's query language](https://www.mongodb.com/docs/manual/), which provides a flexible and expressive way to define complex rules. These rules are translated into native queries for both the PostgreSQL database (via Drizzle ORM) and the Meilisearch engine. + +### Supported Operators and Examples + +Here is a detailed breakdown of the supported operators with examples. + +#### `$eq` (Equal) + +This is the default operator. If you provide a simple key-value pair, it is treated as an equality check. + +```json +// This rule... +{ "status": "active" } + +// ...is equivalent to this: +{ "status": { "$eq": "active" } } +``` + +**Use Case**: Grant access to an ingestion source only if its status is `active`. + +#### `$ne` (Not Equal) + +Matches documents where the field value is not equal to the specified value. + +```json +{ "provider": { "$ne": "pst_import" } } +``` + +**Use Case**: Allow a user to see all ingestion sources except for PST imports. + +#### `$in` (In Array) + +Matches documents where the field value is one of the values in the specified array. + +```json +{ + "id": { + "$in": ["INGESTION_ID_1", "INGESTION_ID_2"] + } +} +``` + +**Use Case**: Grant an auditor access to a specific list of ingestion sources. + +#### `$nin` (Not In Array) + +Matches documents where the field value is not one of the values in the specified array. + +```json +{ "provider": { "$nin": ["pst_import", "eml_import"] } } +``` + +**Use Case**: Hide all manual import sources from a specific user role. + +#### `$lt` / `$lte` (Less Than / Less Than or Equal) + +Matches documents where the field value is less than (`$lt`) or less than or equal to (`$lte`) the specified value. This is useful for numeric or date-based comparisons. + +```json +{ "sentAt": { "$lt": "2024-01-01T00:00:00.000Z" } } +``` + +#### `$gt` / `$gte` (Greater Than / Greater Than or Equal) + +Matches documents where the field value is greater than (`$gt`) or greater than or equal to (`$gte`) the specified value. + +```json +{ "sentAt": { "$lt": "2024-01-01T00:00:00.000Z" } } +``` + +#### `$exists` + +Matches documents that have (or do not have) the specified field. + +```json +// Grant access only if a 'lastSyncStatusMessage' exists +{ "lastSyncStatusMessage": { "$exists": true } } +``` + +## Inverted Rules: Creating Exceptions with `cannot` + +By default, all rules are "can" rules, meaning they grant permissions. However, you can create a "cannot" rule by adding `"inverted": true` to a policy object. This is extremely useful for creating exceptions to broader permissions. + +A common pattern is to grant broad access and then use an inverted rule to carve out a specific restriction. + +**Use Case**: Grant a user access to all ingestion sources _except_ for one specific source. + +This is achieved with two rules: + +1. A "can" rule that grants `read` access to the `ingestion` subject. +2. An inverted "cannot" rule that denies `read` access for the specific ingestion `id`. + +```json +[ + { + "action": "read", + "subject": "ingestion" + }, + { + "inverted": true, + "action": "read", + "subject": "ingestion", + "conditions": { + "id": "SPECIFIC_INGESTION_ID_TO_EXCLUDE" + } + } +] +``` + +## Policy Evaluation Logic + +The system evaluates policies by combining all relevant rules for a user. The logic is simple: + +- A user has permission if at least one `can` rule allows it. +- A permission is denied if a `cannot` (`"inverted": true`) rule explicitly forbids it, even if a `can` rule allows it. `cannot` rules always take precedence. + +### Dynamic Policies with Placeholders + +To create dynamic policies that are specific to the current user, you can use the `${user.id}` placeholder in the `conditions` object. This placeholder will be replaced with the ID of the current user at runtime. + +## Special Permissions for User and Role Management + +It is important to note that while `read` access to `users` and `roles` can be granted granularly, any actions that modify these resources (`create`, `update`, `delete`) are restricted to Super Admins. + +A user must have the `{ "action": "manage", "subject": "all" }` permission (Typically a Super Admin role) to manage users and roles. This is a security measure to prevent unauthorized changes to user accounts and permissions. + +## Policy Examples + +Here are several examples based on the default roles in the system, demonstrating how to combine actions, subjects, and conditions to achieve specific access control scenarios. + +### Administrator + +This policy grants a user full access to all resources using wildcards. + +```json +[ + { + "action": "manage", + "subject": "all" + } +] +``` + +### End-User + +This policy allows a user to view the dashboard, create new ingestion sources, and fully manage the ingestion sources they own. + +```json +[ + { + "action": "read", + "subject": "dashboard" + }, + { + "action": "create", + "subject": "ingestion" + }, + { + "action": "manage", + "subject": "ingestion", + "conditions": { + "userId": "${user.id}" + } + }, + { + "action": "manage", + "subject": "archive", + "conditions": { + "ingestionSource.userId": "${user.id}" // also needs to give permission to archived emails created by the user + } + } +] +``` + +### Global Read-Only Auditor + +This policy grants read and search access across most of the application's resources, making it suitable for an auditor who needs to view data without modifying it. + +```json +[ + { + "action": ["read", "search"], + "subject": ["ingestion", "archive", "dashboard", "users", "roles"] + } +] +``` + +### Ingestion Admin + +This policy grants full control over all ingestion sources and archives, but no other resources. + +```json +[ + { + "action": "manage", + "subject": "ingestion" + } +] +``` + +### Auditor for Specific Ingestion Sources + +This policy demonstrates how to grant access to a specific list of ingestion sources using the `$in` operator. + +```json +[ + { + "action": ["read", "search"], + "subject": "ingestion", + "conditions": { + "id": { + "$in": ["INGESTION_ID_1", "INGESTION_ID_2"] + } + } + } +] +``` + +### Limit Access to a Specific Mailbox + +This policy grants a user access to a specific ingestion source, but only allows them to see emails belonging to a single user within that source. + +This is achieved by defining two specific `can` rules: The rule grants `read` and `search` access to the `archive` subject, but the `userEmail` must match. + +```json +[ + { + "action": ["read", "search"], + "subject": "archive", + "conditions": { + "userEmail": "user1@example.com" + } + } +] +``` From a2c55f36eee80f0f55e71854948a2ac76202b9d4 Mon Sep 17 00:00:00 2001 From: "Wei S." <5291640+wayneshn@users.noreply.github.com> Date: Sun, 24 Aug 2025 16:03:05 +0300 Subject: [PATCH 2/4] Cla v2 (#68) * 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 * CLA v2 * cla-v2 --------- Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com> From f1da17e484c36a7afbb8aca6c684cdb4dd07c9b7 Mon Sep 17 00:00:00 2001 From: "Wei S." <5291640+wayneshn@users.noreply.github.com> Date: Sun, 24 Aug 2025 17:10:24 +0300 Subject: [PATCH 3/4] Fix: storage chart legend overflow (#70) * Fix storage chart legend overflow * fix storage legend overflow --------- Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com> --- .../lib/components/custom/charts/StorageBySourceChart.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/lib/components/custom/charts/StorageBySourceChart.svelte b/packages/frontend/src/lib/components/custom/charts/StorageBySourceChart.svelte index 3e916c2..76813ac 100644 --- a/packages/frontend/src/lib/components/custom/charts/StorageBySourceChart.svelte +++ b/packages/frontend/src/lib/components/custom/charts/StorageBySourceChart.svelte @@ -14,7 +14,10 @@ } satisfies ChartConfig; - + Date: Thu, 28 Aug 2025 14:12:05 +0300 Subject: [PATCH 4/4] 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> --- .../api/controllers/settings.controller.ts | 25 + .../backend/src/api/routes/settings.routes.ts | 22 + .../backend/src/api/routes/test.routes.ts | 6 - .../0017_tranquil_shooting_star.sql | 4 + .../migrations/meta/0017_snapshot.json | 1166 +++++++++++++++++ .../database/migrations/meta/_journal.json | 255 ++-- packages/backend/src/database/schema.ts | 1 + .../src/database/schema/system-settings.ts | 7 + packages/backend/src/index.ts | 5 +- .../backend/src/services/SettingsService.ts | 60 + .../lib/components/ui/radio-group/index.ts | 10 + .../ui/radio-group/radio-group-item.svelte | 31 + .../ui/radio-group/radio-group.svelte | 19 + .../frontend/src/routes/+layout.server.ts | 4 + packages/frontend/src/routes/+layout.svelte | 11 +- .../src/routes/dashboard/+layout.svelte | 4 + .../dashboard/settings/system/+page.server.ts | 50 + .../dashboard/settings/system/+page.svelte | 123 ++ packages/types/src/index.ts | 1 + packages/types/src/system.types.ts | 22 + 20 files changed, 1692 insertions(+), 134 deletions(-) create mode 100644 packages/backend/src/api/controllers/settings.controller.ts create mode 100644 packages/backend/src/api/routes/settings.routes.ts delete mode 100644 packages/backend/src/api/routes/test.routes.ts create mode 100644 packages/backend/src/database/migrations/0017_tranquil_shooting_star.sql create mode 100644 packages/backend/src/database/migrations/meta/0017_snapshot.json create mode 100644 packages/backend/src/database/schema/system-settings.ts create mode 100644 packages/backend/src/services/SettingsService.ts create mode 100644 packages/frontend/src/lib/components/ui/radio-group/index.ts create mode 100644 packages/frontend/src/lib/components/ui/radio-group/radio-group-item.svelte create mode 100644 packages/frontend/src/lib/components/ui/radio-group/radio-group.svelte create mode 100644 packages/frontend/src/routes/dashboard/settings/system/+page.server.ts create mode 100644 packages/frontend/src/routes/dashboard/settings/system/+page.svelte create mode 100644 packages/types/src/system.types.ts diff --git a/packages/backend/src/api/controllers/settings.controller.ts b/packages/backend/src/api/controllers/settings.controller.ts new file mode 100644 index 0000000..c6044a2 --- /dev/null +++ b/packages/backend/src/api/controllers/settings.controller.ts @@ -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' }); + } +}; diff --git a/packages/backend/src/api/routes/settings.routes.ts b/packages/backend/src/api/routes/settings.routes.ts new file mode 100644 index 0000000..f95a379 --- /dev/null +++ b/packages/backend/src/api/routes/settings.routes.ts @@ -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; +}; diff --git a/packages/backend/src/api/routes/test.routes.ts b/packages/backend/src/api/routes/test.routes.ts deleted file mode 100644 index 0af4873..0000000 --- a/packages/backend/src/api/routes/test.routes.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Router } from 'express'; -import { ingestionQueue } from '../../jobs/queues'; - -const router: Router = Router(); - -export default router; diff --git a/packages/backend/src/database/migrations/0017_tranquil_shooting_star.sql b/packages/backend/src/database/migrations/0017_tranquil_shooting_star.sql new file mode 100644 index 0000000..0a425ce --- /dev/null +++ b/packages/backend/src/database/migrations/0017_tranquil_shooting_star.sql @@ -0,0 +1,4 @@ +CREATE TABLE "system_settings" ( + "id" serial PRIMARY KEY NOT NULL, + "config" jsonb NOT NULL +); diff --git a/packages/backend/src/database/migrations/meta/0017_snapshot.json b/packages/backend/src/database/migrations/meta/0017_snapshot.json new file mode 100644 index 0000000..0b8efc7 --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0017_snapshot.json @@ -0,0 +1,1166 @@ +{ + "id": "74921769-c190-4fbd-b239-dea052f9bc99", + "prevId": "535faba9-e8ae-4096-899f-ed9ae242394d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.archived_emails": { + "name": "archived_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id_header": { + "name": "message_id_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_hash_sha256": { + "name": "storage_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "is_indexed": { + "name": "is_indexed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_on_legal_hold": { + "name": "is_on_legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "thread_id_idx": { + "name": "thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archived_emails_ingestion_source_id_ingestion_sources_id_fk": { + "name": "archived_emails_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "archived_emails", + "tableTo": "ingestion_sources", + "columnsFrom": [ + "ingestion_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content_hash_sha256": { + "name": "content_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_content_hash_sha256_unique": { + "name": "attachments_content_hash_sha256_unique", + "nullsNotDistinct": false, + "columns": [ + "content_hash_sha256" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_attachments": { + "name": "email_attachments", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_attachments_email_id_archived_emails_id_fk": { + "name": "email_attachments_email_id_archived_emails_id_fk", + "tableFrom": "email_attachments", + "tableTo": "archived_emails", + "columnsFrom": [ + "email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_attachment_id_attachments_id_fk": { + "name": "email_attachments_attachment_id_attachments_id_fk", + "tableFrom": "email_attachments", + "tableTo": "attachments", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_attachments_email_id_attachment_id_pk": { + "name": "email_attachments_email_id_attachment_id_pk", + "columns": [ + "email_id", + "attachment_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_identifier": { + "name": "actor_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_tamper_evident": { + "name": "is_tamper_evident", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ediscovery_cases": { + "name": "ediscovery_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ediscovery_cases_name_unique": { + "name": "ediscovery_cases_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.export_jobs": { + "name": "export_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "query": { + "name": "query", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "export_jobs_case_id_ediscovery_cases_id_fk": { + "name": "export_jobs_case_id_ediscovery_cases_id_fk", + "tableFrom": "export_jobs", + "tableTo": "ediscovery_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legal_holds": { + "name": "legal_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "custodian_id": { + "name": "custodian_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "hold_criteria": { + "name": "hold_criteria", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_by_identifier": { + "name": "applied_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "legal_holds_case_id_ediscovery_cases_id_fk": { + "name": "legal_holds_case_id_ediscovery_cases_id_fk", + "tableFrom": "legal_holds", + "tableTo": "ediscovery_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "legal_holds_custodian_id_custodians_id_fk": { + "name": "legal_holds_custodian_id_custodians_id_fk", + "tableFrom": "legal_holds", + "tableTo": "custodians", + "columnsFrom": [ + "custodian_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action_on_expiry": { + "name": "action_on_expiry", + "type": "retention_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "retention_policies_name_unique": { + "name": "retention_policies_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custodians": { + "name": "custodians", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custodians_email_unique": { + "name": "custodians_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingestion_sources": { + "name": "ingestion_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "ingestion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_auth'" + }, + "last_sync_started_at": { + "name": "last_sync_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_finished_at": { + "name": "last_sync_finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_status_message": { + "name": "last_sync_status_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_state": { + "name": "sync_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ingestion_sources_user_id_users_id_fk": { + "name": "ingestion_sources_user_id_users_id_fk", + "tableFrom": "ingestion_sources", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policies": { + "name": "policies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "roles_slug_unique": { + "name": "roles_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.retention_action": { + "name": "retention_action", + "schema": "public", + "values": [ + "delete_permanently", + "notify_admin" + ] + }, + "public.ingestion_provider": { + "name": "ingestion_provider", + "schema": "public", + "values": [ + "google_workspace", + "microsoft_365", + "generic_imap", + "pst_import", + "eml_import" + ] + }, + "public.ingestion_status": { + "name": "ingestion_status", + "schema": "public", + "values": [ + "active", + "paused", + "error", + "pending_auth", + "syncing", + "importing", + "auth_success", + "imported" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/database/migrations/meta/_journal.json b/packages/backend/src/database/migrations/meta/_journal.json index 7155541..70859a7 100644 --- a/packages/backend/src/database/migrations/meta/_journal.json +++ b/packages/backend/src/database/migrations/meta/_journal.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/packages/backend/src/database/schema.ts b/packages/backend/src/database/schema.ts index 557f1e8..46dc265 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -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' \ No newline at end of file diff --git a/packages/backend/src/database/schema/system-settings.ts b/packages/backend/src/database/schema/system-settings.ts new file mode 100644 index 0000000..1058c09 --- /dev/null +++ b/packages/backend/src/database/schema/system-settings.ts @@ -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().notNull(), +}); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 395287b..1f1b1b3 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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) => { diff --git a/packages/backend/src/services/SettingsService.ts b/packages/backend/src/services/SettingsService.ts new file mode 100644 index 0000000..1559c39 --- /dev/null +++ b/packages/backend/src/services/SettingsService.ts @@ -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 { + 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 + ): Promise { + 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 { + const [result] = await db + .insert(systemSettings) + .values({ config: DEFAULT_SETTINGS }) + .returning(); + return result.config; + } +} diff --git a/packages/frontend/src/lib/components/ui/radio-group/index.ts b/packages/frontend/src/lib/components/ui/radio-group/index.ts new file mode 100644 index 0000000..90b33fe --- /dev/null +++ b/packages/frontend/src/lib/components/ui/radio-group/index.ts @@ -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, +}; diff --git a/packages/frontend/src/lib/components/ui/radio-group/radio-group-item.svelte b/packages/frontend/src/lib/components/ui/radio-group/radio-group-item.svelte new file mode 100644 index 0000000..2cb0710 --- /dev/null +++ b/packages/frontend/src/lib/components/ui/radio-group/radio-group-item.svelte @@ -0,0 +1,31 @@ + + + + {#snippet children({ checked })} +
+ {#if checked} + + {/if} +
+ {/snippet} +
diff --git a/packages/frontend/src/lib/components/ui/radio-group/radio-group.svelte b/packages/frontend/src/lib/components/ui/radio-group/radio-group.svelte new file mode 100644 index 0000000..da2912b --- /dev/null +++ b/packages/frontend/src/lib/components/ui/radio-group/radio-group.svelte @@ -0,0 +1,19 @@ + + + diff --git a/packages/frontend/src/routes/+layout.server.ts b/packages/frontend/src/routes/+layout.server.ts index ee23e91..6e48b7a 100644 --- a/packages/frontend/src/routes/+layout.server.ts +++ b/packages/frontend/src/routes/+layout.server.ts @@ -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, }; }; diff --git a/packages/frontend/src/routes/+layout.svelte b/packages/frontend/src/routes/+layout.svelte index b7e6b43..51bb87c 100644 --- a/packages/frontend/src/routes/+layout.svelte +++ b/packages/frontend/src/routes/+layout.svelte @@ -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); } }); diff --git a/packages/frontend/src/routes/dashboard/+layout.svelte b/packages/frontend/src/routes/dashboard/+layout.svelte index 251bda2..100cb70 100644 --- a/packages/frontend/src/routes/dashboard/+layout.svelte +++ b/packages/frontend/src/routes/dashboard/+layout.svelte @@ -20,6 +20,10 @@ { label: 'Settings', subMenu: [ + { + href: '/dashboard/settings/system', + label: 'System', + }, { href: '/dashboard/settings/users', label: 'Users', diff --git a/packages/frontend/src/routes/dashboard/settings/system/+page.server.ts b/packages/frontend/src/routes/dashboard/settings/system/+page.server.ts new file mode 100644 index 0000000..f7e2d4f --- /dev/null +++ b/packages/frontend/src/routes/dashboard/settings/system/+page.server.ts @@ -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 = { + 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, + }; + }, +}; diff --git a/packages/frontend/src/routes/dashboard/settings/system/+page.svelte b/packages/frontend/src/routes/dashboard/settings/system/+page.svelte new file mode 100644 index 0000000..49a5d5d --- /dev/null +++ b/packages/frontend/src/routes/dashboard/settings/system/+page.svelte @@ -0,0 +1,123 @@ + + + + System Settings - OpenArchiver + + +
+
+

System Settings

+

Manage global application settings.

+
+ +
(isSaving = true)}> + + + + + +
+ Default theme + +
+ + Light +
+
+ + Dark +
+
+ + System +
+
+
+ +
+ Support Email + +
+
+ + + +
+
+
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d1a16af..c22cb39 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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'; diff --git a/packages/types/src/system.types.ts b/packages/types/src/system.types.ts new file mode 100644 index 0000000..661e231 --- /dev/null +++ b/packages/types/src/system.types.ts @@ -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; +}