mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
feat: Role based access control (#58)
* 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 --------- Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
"@aws-sdk/client-s3": "^3.844.0",
|
||||
"@aws-sdk/lib-storage": "^3.844.0",
|
||||
"@azure/msal-node": "^3.6.3",
|
||||
"@casl/ability": "^6.7.3",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@open-archiver/types": "workspace:*",
|
||||
"archiver": "^7.0.1",
|
||||
|
||||
@@ -8,11 +8,17 @@ export class ArchivedEmailController {
|
||||
const { ingestionSourceId } = req.params;
|
||||
const page = parseInt(req.query.page as string, 10) || 1;
|
||||
const limit = parseInt(req.query.limit as string, 10) || 10;
|
||||
const userId = req.user?.sub;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const result = await ArchivedEmailService.getArchivedEmails(
|
||||
ingestionSourceId,
|
||||
page,
|
||||
limit
|
||||
limit,
|
||||
userId
|
||||
);
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
@@ -24,7 +30,13 @@ export class ArchivedEmailController {
|
||||
public getArchivedEmailById = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const email = await ArchivedEmailService.getArchivedEmailById(id);
|
||||
const userId = req.user?.sub;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const email = await ArchivedEmailService.getArchivedEmailById(id, userId);
|
||||
if (!email) {
|
||||
return res.status(404).json({ message: 'Archived email not found' });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { IamService } from '../../services/IamService';
|
||||
import { PolicyValidator } from '../../iam-policy/policy-validator';
|
||||
import type { PolicyStatement } from '@open-archiver/types';
|
||||
import type { CaslPolicy } from '@open-archiver/types';
|
||||
import { logger } from '../../config/logger';
|
||||
|
||||
export class IamController {
|
||||
#iamService: IamService;
|
||||
@@ -12,10 +13,15 @@ export class IamController {
|
||||
|
||||
public getRoles = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const roles = await this.#iamService.getRoles();
|
||||
let roles = await this.#iamService.getRoles();
|
||||
if (!roles.some((r) => r.slug?.includes('predefined_'))) {
|
||||
// create pre defined roles
|
||||
logger.info({}, 'Creating predefined roles');
|
||||
await this.createDefaultRoles();
|
||||
}
|
||||
res.status(200).json(roles);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to get roles.' });
|
||||
res.status(500).json({ message: 'Failed to get roles.' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,34 +33,35 @@ export class IamController {
|
||||
if (role) {
|
||||
res.status(200).json(role);
|
||||
} else {
|
||||
res.status(404).json({ error: 'Role not found.' });
|
||||
res.status(404).json({ message: 'Role not found.' });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to get role.' });
|
||||
res.status(500).json({ message: 'Failed to get role.' });
|
||||
}
|
||||
};
|
||||
|
||||
public createRole = async (req: Request, res: Response): Promise<void> => {
|
||||
const { name, policy } = req.body;
|
||||
const { name, policies } = req.body;
|
||||
|
||||
if (!name || !policy) {
|
||||
res.status(400).json({ error: 'Missing required fields: name and policy.' });
|
||||
if (!name || !policies) {
|
||||
res.status(400).json({ message: 'Missing required fields: name and policy.' });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const statement of policy) {
|
||||
const { valid, reason } = PolicyValidator.isValid(statement as PolicyStatement);
|
||||
for (const statement of policies) {
|
||||
const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy);
|
||||
if (!valid) {
|
||||
res.status(400).json({ error: `Invalid policy statement: ${reason}` });
|
||||
res.status(400).json({ message: `Invalid policy statement: ${reason}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const role = await this.#iamService.createRole(name, policy);
|
||||
const role = await this.#iamService.createRole(name, policies);
|
||||
res.status(201).json(role);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to create role.' });
|
||||
console.log(error);
|
||||
res.status(500).json({ message: 'Failed to create role.' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -65,7 +72,74 @@ export class IamController {
|
||||
await this.#iamService.deleteRole(id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete role.' });
|
||||
res.status(500).json({ message: 'Failed to delete role.' });
|
||||
}
|
||||
};
|
||||
|
||||
public updateRole = async (req: Request, res: Response): Promise<void> => {
|
||||
const { id } = req.params;
|
||||
const { name, policies } = req.body;
|
||||
|
||||
if (!name && !policies) {
|
||||
res.status(400).json({ message: 'Missing fields to update: name or policies.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (policies) {
|
||||
for (const statement of policies) {
|
||||
const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy);
|
||||
if (!valid) {
|
||||
res.status(400).json({ message: `Invalid policy statement: ${reason}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const role = await this.#iamService.updateRole(id, { name, policies });
|
||||
res.status(200).json(role);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Failed to update role.' });
|
||||
}
|
||||
};
|
||||
|
||||
private createDefaultRoles = async () => {
|
||||
try {
|
||||
// end user who can manage its own data, and create new ingestions.
|
||||
await this.#iamService.createRole(
|
||||
'End user',
|
||||
[
|
||||
{
|
||||
action: 'create',
|
||||
subject: 'ingestion',
|
||||
},
|
||||
{
|
||||
action: 'read',
|
||||
subject: 'dashboard',
|
||||
},
|
||||
{
|
||||
action: 'manage',
|
||||
subject: 'ingestion',
|
||||
conditions: {
|
||||
userId: '${user.id}',
|
||||
},
|
||||
},
|
||||
],
|
||||
'predefined_end_user'
|
||||
);
|
||||
// read only
|
||||
await this.#iamService.createRole(
|
||||
'Read only',
|
||||
[
|
||||
{
|
||||
action: ['read', 'search'],
|
||||
subject: ['ingestion', 'archive', 'dashboard', 'users', 'roles'],
|
||||
},
|
||||
],
|
||||
'predefined_read_only_user'
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({}, 'Failed to create default roles');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,11 @@ export class IngestionController {
|
||||
}
|
||||
try {
|
||||
const dto: CreateIngestionSourceDto = req.body;
|
||||
const newSource = await IngestionService.create(dto);
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const newSource = await IngestionService.create(dto, userId);
|
||||
const safeSource = this.toSafeIngestionSource(newSource);
|
||||
return res.status(201).json(safeSource);
|
||||
} catch (error: any) {
|
||||
@@ -42,7 +46,11 @@ export class IngestionController {
|
||||
|
||||
public findAll = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const sources = await IngestionService.findAll();
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const sources = await IngestionService.findAll(userId);
|
||||
const safeSources = sources.map(this.toSafeIngestionSource);
|
||||
return res.status(200).json(safeSources);
|
||||
} catch (error) {
|
||||
|
||||
@@ -12,18 +12,27 @@ export class SearchController {
|
||||
public search = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { keywords, page, limit, matchingStrategy } = req.query;
|
||||
const userId = req.user?.sub;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!keywords) {
|
||||
res.status(400).json({ message: 'Keywords are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await this.searchService.searchEmails({
|
||||
query: keywords as string,
|
||||
page: page ? parseInt(page as string) : 1,
|
||||
limit: limit ? parseInt(limit as string) : 10,
|
||||
matchingStrategy: matchingStrategy as MatchingStrategies,
|
||||
});
|
||||
const results = await this.searchService.searchEmails(
|
||||
{
|
||||
query: keywords as string,
|
||||
page: page ? parseInt(page as string) : 1,
|
||||
limit: limit ? parseInt(limit as string) : 10,
|
||||
matchingStrategy: matchingStrategy as MatchingStrategies,
|
||||
},
|
||||
userId
|
||||
);
|
||||
|
||||
res.status(200).json(results);
|
||||
} catch (error) {
|
||||
|
||||
56
packages/backend/src/api/controllers/user.controller.ts
Normal file
56
packages/backend/src/api/controllers/user.controller.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { UserService } from '../../services/UserService';
|
||||
import * as schema from '../../database/schema';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { db } from '../../database';
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
export const getUsers = async (req: Request, res: Response) => {
|
||||
const users = await userService.findAll();
|
||||
res.json(users);
|
||||
};
|
||||
|
||||
export const getUser = async (req: Request, res: Response) => {
|
||||
const user = await userService.findById(req.params.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
res.json(user);
|
||||
};
|
||||
|
||||
export const createUser = async (req: Request, res: Response) => {
|
||||
const { email, first_name, last_name, password, roleId } = req.body;
|
||||
|
||||
const newUser = await userService.createUser(
|
||||
{ email, first_name, last_name, password },
|
||||
roleId
|
||||
);
|
||||
res.status(201).json(newUser);
|
||||
};
|
||||
|
||||
export const updateUser = async (req: Request, res: Response) => {
|
||||
const { email, first_name, last_name, roleId } = req.body;
|
||||
const updatedUser = await userService.updateUser(
|
||||
req.params.id,
|
||||
{ email, first_name, last_name },
|
||||
roleId
|
||||
);
|
||||
if (!updatedUser) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
res.json(updatedUser);
|
||||
};
|
||||
|
||||
export const deleteUser = async (req: Request, res: Response) => {
|
||||
const userCountResult = await db.select({ count: sql<number>`count(*)` }).from(schema.users);
|
||||
console.log('iusercount,', userCountResult[0].count);
|
||||
const isOnlyUser = Number(userCountResult[0].count) === 1;
|
||||
if (isOnlyUser) {
|
||||
return res.status(400).json({
|
||||
message: 'You are trying to delete the only user in the database, this is not allowed.',
|
||||
});
|
||||
}
|
||||
await userService.deleteUser(req.params.id);
|
||||
res.status(204).send();
|
||||
};
|
||||
36
packages/backend/src/api/middleware/requirePermission.ts
Normal file
36
packages/backend/src/api/middleware/requirePermission.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { AuthorizationService } from '../../services/AuthorizationService';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { AppActions, AppSubjects } from '@open-archiver/types';
|
||||
|
||||
export const requirePermission = (
|
||||
action: AppActions,
|
||||
subjectName: AppSubjects,
|
||||
rejectMessage?: string
|
||||
) => {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const userId = req.user?.sub;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
let resourceObject = undefined;
|
||||
// Logic to fetch resourceObject if needed for condition-based checks...
|
||||
const authorizationService = new AuthorizationService();
|
||||
const hasPermission = await authorizationService.can(
|
||||
userId,
|
||||
action,
|
||||
subjectName,
|
||||
resourceObject
|
||||
);
|
||||
|
||||
if (!hasPermission) {
|
||||
return res.status(403).json({
|
||||
message:
|
||||
rejectMessage || `You don't have the permission to perform the current action.`,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { ArchivedEmailController } from '../controllers/archived-email.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createArchivedEmailRouter = (
|
||||
@@ -12,11 +13,23 @@ export const createArchivedEmailRouter = (
|
||||
// Secure all routes in this module
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.get('/ingestion-source/:ingestionSourceId', archivedEmailController.getArchivedEmails);
|
||||
router.get(
|
||||
'/ingestion-source/:ingestionSourceId',
|
||||
requirePermission('read', 'archive'),
|
||||
archivedEmailController.getArchivedEmails
|
||||
);
|
||||
|
||||
router.get('/:id', archivedEmailController.getArchivedEmailById);
|
||||
router.get(
|
||||
'/:id',
|
||||
requirePermission('read', 'archive'),
|
||||
archivedEmailController.getArchivedEmailById
|
||||
);
|
||||
|
||||
router.delete('/:id', archivedEmailController.deleteArchivedEmail);
|
||||
router.delete(
|
||||
'/:id',
|
||||
requirePermission('delete', 'archive'),
|
||||
archivedEmailController.deleteArchivedEmail
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { dashboardController } from '../controllers/dashboard.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createDashboardRouter = (authService: AuthService): Router => {
|
||||
@@ -8,11 +9,51 @@ export const createDashboardRouter = (authService: AuthService): Router => {
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.get('/stats', dashboardController.getStats);
|
||||
router.get('/ingestion-history', dashboardController.getIngestionHistory);
|
||||
router.get('/ingestion-sources', dashboardController.getIngestionSources);
|
||||
router.get('/recent-syncs', dashboardController.getRecentSyncs);
|
||||
router.get('/indexed-insights', dashboardController.getIndexedInsights);
|
||||
router.get(
|
||||
'/stats',
|
||||
requirePermission(
|
||||
'read',
|
||||
'dashboard',
|
||||
'You need the dashboard read permission to view dashboard stats.'
|
||||
),
|
||||
dashboardController.getStats
|
||||
);
|
||||
router.get(
|
||||
'/ingestion-history',
|
||||
requirePermission(
|
||||
'read',
|
||||
'dashboard',
|
||||
'You need the dashboard read permission to view dashboard data.'
|
||||
),
|
||||
dashboardController.getIngestionHistory
|
||||
);
|
||||
router.get(
|
||||
'/ingestion-sources',
|
||||
requirePermission(
|
||||
'read',
|
||||
'dashboard',
|
||||
'You need the dashboard read permission to view dashboard data.'
|
||||
),
|
||||
dashboardController.getIngestionSources
|
||||
);
|
||||
router.get(
|
||||
'/recent-syncs',
|
||||
requirePermission(
|
||||
'read',
|
||||
'dashboard',
|
||||
'You need the dashboard read permission to view dashboard data.'
|
||||
),
|
||||
dashboardController.getRecentSyncs
|
||||
);
|
||||
router.get(
|
||||
'/indexed-insights',
|
||||
requirePermission(
|
||||
'read',
|
||||
'dashboard',
|
||||
'You need the dashboard read permission to view dashboard data.'
|
||||
),
|
||||
dashboardController.getIndexedInsights
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -1,36 +1,42 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import type { IamController } from '../controllers/iam.controller';
|
||||
import type { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createIamRouter = (iamController: IamController): Router => {
|
||||
export const createIamRouter = (iamController: IamController, authService: AuthService): Router => {
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
/**
|
||||
* @route GET /api/v1/iam/roles
|
||||
* @description Gets all roles.
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/roles', requireAuth, iamController.getRoles);
|
||||
router.get('/roles', requirePermission('read', 'roles'), iamController.getRoles);
|
||||
|
||||
router.get('/roles/:id', requirePermission('read', 'roles'), iamController.getRoleById);
|
||||
|
||||
/**
|
||||
* @route GET /api/v1/iam/roles/:id
|
||||
* @description Gets a role by ID.
|
||||
* @access Private
|
||||
* Only super admin has the ability to modify existing roles or create new roles.
|
||||
*/
|
||||
router.get('/roles/:id', requireAuth, iamController.getRoleById);
|
||||
router.post(
|
||||
'/roles',
|
||||
requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'),
|
||||
iamController.createRole
|
||||
);
|
||||
|
||||
/**
|
||||
* @route POST /api/v1/iam/roles
|
||||
* @description Creates a new role.
|
||||
* @access Private
|
||||
*/
|
||||
router.post('/roles', requireAuth, iamController.createRole);
|
||||
router.delete(
|
||||
'/roles/:id',
|
||||
requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'),
|
||||
iamController.deleteRole
|
||||
);
|
||||
|
||||
/**
|
||||
* @route DELETE /api/v1/iam/roles/:id
|
||||
* @description Deletes a role.
|
||||
* @access Private
|
||||
*/
|
||||
router.delete('/roles/:id', requireAuth, iamController.deleteRole);
|
||||
router.put(
|
||||
'/roles/:id',
|
||||
requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'),
|
||||
iamController.updateRole
|
||||
);
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { IngestionController } from '../controllers/ingestion.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createIngestionRouter = (
|
||||
@@ -12,21 +13,29 @@ export const createIngestionRouter = (
|
||||
// Secure all routes in this module
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.post('/', ingestionController.create);
|
||||
router.post('/', requirePermission('create', 'ingestion'), ingestionController.create);
|
||||
|
||||
router.get('/', ingestionController.findAll);
|
||||
router.get('/', requirePermission('read', 'ingestion'), ingestionController.findAll);
|
||||
|
||||
router.get('/:id', ingestionController.findById);
|
||||
router.get('/:id', requirePermission('read', 'ingestion'), ingestionController.findById);
|
||||
|
||||
router.put('/:id', ingestionController.update);
|
||||
router.put('/:id', requirePermission('update', 'ingestion'), ingestionController.update);
|
||||
|
||||
router.delete('/:id', ingestionController.delete);
|
||||
router.delete('/:id', requirePermission('delete', 'ingestion'), ingestionController.delete);
|
||||
|
||||
router.post('/:id/import', ingestionController.triggerInitialImport);
|
||||
router.post(
|
||||
'/:id/import',
|
||||
requirePermission('create', 'ingestion'),
|
||||
ingestionController.triggerInitialImport
|
||||
);
|
||||
|
||||
router.post('/:id/pause', ingestionController.pause);
|
||||
router.post('/:id/pause', requirePermission('update', 'ingestion'), ingestionController.pause);
|
||||
|
||||
router.post('/:id/sync', ingestionController.triggerForceSync);
|
||||
router.post(
|
||||
'/:id/sync',
|
||||
requirePermission('sync', 'ingestion'),
|
||||
ingestionController.triggerForceSync
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { SearchController } from '../controllers/search.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createSearchRouter = (
|
||||
@@ -11,7 +12,7 @@ export const createSearchRouter = (
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.get('/', searchController.search);
|
||||
router.get('/', requirePermission('search', 'archive'), searchController.search);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { StorageController } from '../controllers/storage.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createStorageRouter = (
|
||||
@@ -12,7 +13,7 @@ export const createStorageRouter = (
|
||||
// Secure all routes in this module
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.get('/download', storageController.downloadFile);
|
||||
router.get('/download', requirePermission('read', 'archive'), storageController.downloadFile);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -2,13 +2,14 @@ import { Router } from 'express';
|
||||
import { uploadFile } from '../controllers/upload.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
|
||||
export const createUploadRouter = (authService: AuthService): Router => {
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.post('/', uploadFile);
|
||||
router.post('/', requirePermission('create', 'ingestion'), uploadFile);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
38
packages/backend/src/api/routes/user.routes.ts
Normal file
38
packages/backend/src/api/routes/user.routes.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Router } from 'express';
|
||||
import * as userController from '../controllers/user.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createUserRouter = (authService: AuthService): Router => {
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.get('/', requirePermission('read', 'users'), userController.getUsers);
|
||||
|
||||
router.get('/:id', requirePermission('read', 'users'), userController.getUser);
|
||||
|
||||
/**
|
||||
* Only super admin has the ability to modify existing users or create new users.
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
requirePermission('manage', 'all', 'Super Admin role is required to manage users.'),
|
||||
userController.createUser
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:id',
|
||||
requirePermission('manage', 'all', 'Super Admin role is required to manage users.'),
|
||||
userController.updateUser
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
requirePermission('manage', 'all', 'Super Admin role is required to manage users.'),
|
||||
userController.deleteUser
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "ingestion_sources" ADD COLUMN "user_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "ingestion_sources" ADD CONSTRAINT "ingestion_sources_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "roles" ADD COLUMN "slug" text;--> statement-breakpoint
|
||||
ALTER TABLE "roles" ADD CONSTRAINT "roles_slug_unique" UNIQUE("slug");
|
||||
1067
packages/backend/src/database/migrations/meta/0015_snapshot.json
Normal file
1067
packages/backend/src/database/migrations/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1078
packages/backend/src/database/migrations/meta/0016_snapshot.json
Normal file
1078
packages/backend/src/database/migrations/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,20 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { jsonb, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { users } from './users';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
export const ingestionProviderEnum = pgEnum('ingestion_provider', [
|
||||
'google_workspace',
|
||||
@@ -21,6 +23,7 @@ export const ingestionStatusEnum = pgEnum('ingestion_status', [
|
||||
|
||||
export const ingestionSources = pgTable('ingestion_sources', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
provider: ingestionProviderEnum('provider').notNull(),
|
||||
credentials: text('credentials'),
|
||||
@@ -32,3 +35,10 @@ export const ingestionSources = pgTable('ingestion_sources', {
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const ingestionSourcesRelations = relations(ingestionSources, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [ingestionSources.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { relations, sql } from 'drizzle-orm';
|
||||
import { pgTable, text, timestamp, uuid, primaryKey, jsonb } from 'drizzle-orm/pg-core';
|
||||
import type { PolicyStatement } from '@open-archiver/types';
|
||||
import type { CaslPolicy } from '@open-archiver/types';
|
||||
|
||||
/**
|
||||
* The `users` table stores the core user information for authentication and identification.
|
||||
@@ -40,9 +40,10 @@ export const roles = pgTable('roles', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull().unique(),
|
||||
policies: jsonb('policies')
|
||||
.$type<PolicyStatement[]>()
|
||||
.$type<CaslPolicy[]>()
|
||||
.notNull()
|
||||
.default(sql`'[]'::jsonb`),
|
||||
slug: text('slug').unique(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
95
packages/backend/src/helpers/mongoToDrizzle.ts
Normal file
95
packages/backend/src/helpers/mongoToDrizzle.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { SQL, and, or, not, eq, gt, gte, lt, lte, inArray, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
const camelToSnakeCase = (str: string) =>
|
||||
str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
||||
|
||||
const relationToTableMap: Record<string, string> = {
|
||||
ingestionSource: 'ingestion_sources',
|
||||
// TBD: Add other relations here as needed
|
||||
};
|
||||
|
||||
function getDrizzleColumn(key: string): SQL {
|
||||
const keyParts = key.split('.');
|
||||
if (keyParts.length > 1) {
|
||||
const relationName = keyParts[0];
|
||||
const columnName = camelToSnakeCase(keyParts[1]);
|
||||
const tableName = relationToTableMap[relationName];
|
||||
if (tableName) {
|
||||
return sql.raw(`"${tableName}"."${columnName}"`);
|
||||
}
|
||||
}
|
||||
return sql`${sql.identifier(camelToSnakeCase(key))}`;
|
||||
}
|
||||
|
||||
export function mongoToDrizzle(query: Record<string, any>): SQL | undefined {
|
||||
const conditions: (SQL | undefined)[] = [];
|
||||
|
||||
for (const key in query) {
|
||||
const value = query[key];
|
||||
|
||||
if (key === '$or') {
|
||||
conditions.push(or(...(value as any[]).map(mongoToDrizzle).filter(Boolean)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === '$and') {
|
||||
conditions.push(and(...(value as any[]).map(mongoToDrizzle).filter(Boolean)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === '$not') {
|
||||
const subQuery = mongoToDrizzle(value);
|
||||
if (subQuery) {
|
||||
conditions.push(not(subQuery));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const column = getDrizzleColumn(key);
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const operator = Object.keys(value)[0];
|
||||
const operand = value[operator];
|
||||
|
||||
switch (operator) {
|
||||
case '$eq':
|
||||
conditions.push(eq(column, operand));
|
||||
break;
|
||||
case '$ne':
|
||||
conditions.push(not(eq(column, operand)));
|
||||
break;
|
||||
case '$gt':
|
||||
conditions.push(gt(column, operand));
|
||||
break;
|
||||
case '$gte':
|
||||
conditions.push(gte(column, operand));
|
||||
break;
|
||||
case '$lt':
|
||||
conditions.push(lt(column, operand));
|
||||
break;
|
||||
case '$lte':
|
||||
conditions.push(lte(column, operand));
|
||||
break;
|
||||
case '$in':
|
||||
conditions.push(inArray(column, operand));
|
||||
break;
|
||||
case '$nin':
|
||||
conditions.push(not(inArray(column, operand)));
|
||||
break;
|
||||
case '$exists':
|
||||
conditions.push(operand ? not(isNull(column)) : isNull(column));
|
||||
break;
|
||||
default:
|
||||
// Unsupported operator
|
||||
}
|
||||
} else {
|
||||
conditions.push(eq(column, value));
|
||||
}
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return and(...conditions.filter((c): c is SQL => c !== undefined));
|
||||
}
|
||||
100
packages/backend/src/helpers/mongoToMeli.ts
Normal file
100
packages/backend/src/helpers/mongoToMeli.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { db } from '../database';
|
||||
import { ingestionSources } from '../database/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
const snakeToCamelCase = (str: string): string => {
|
||||
return str.replace(/_([a-z])/g, (match, letter) => letter.toUpperCase());
|
||||
};
|
||||
|
||||
function getMeliColumn(key: string): string {
|
||||
const keyParts = key.split('.');
|
||||
if (keyParts.length > 1) {
|
||||
const relationName = keyParts[0];
|
||||
const columnName = keyParts[1];
|
||||
return `${relationName}.${columnName}`;
|
||||
}
|
||||
return snakeToCamelCase(key);
|
||||
}
|
||||
|
||||
function quoteIfString(value: any): any {
|
||||
if (typeof value === 'string') {
|
||||
return `"${value}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function mongoToMeli(query: Record<string, any>): Promise<string> {
|
||||
const conditions: string[] = [];
|
||||
for (const key of Object.keys(query)) {
|
||||
const value = query[key];
|
||||
|
||||
if (key === '$or') {
|
||||
const orConditions = await Promise.all(value.map(mongoToMeli));
|
||||
conditions.push(`(${orConditions.join(' OR ')})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === '$and') {
|
||||
const andConditions = await Promise.all(value.map(mongoToMeli));
|
||||
conditions.push(`(${andConditions.join(' AND ')})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === '$not') {
|
||||
conditions.push(`NOT (${await mongoToMeli(value)})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const column = getMeliColumn(key);
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const operator = Object.keys(value)[0];
|
||||
const operand = value[operator];
|
||||
|
||||
switch (operator) {
|
||||
case '$eq':
|
||||
conditions.push(`${column} = ${quoteIfString(operand)}`);
|
||||
break;
|
||||
case '$ne':
|
||||
conditions.push(`${column} != ${quoteIfString(operand)}`);
|
||||
break;
|
||||
case '$gt':
|
||||
conditions.push(`${column} > ${operand}`);
|
||||
break;
|
||||
case '$gte':
|
||||
conditions.push(`${column} >= ${operand}`);
|
||||
break;
|
||||
case '$lt':
|
||||
conditions.push(`${column} < ${operand}`);
|
||||
break;
|
||||
case '$lte':
|
||||
conditions.push(`${column} <= ${operand}`);
|
||||
break;
|
||||
case '$in':
|
||||
conditions.push(`${column} IN [${operand.map(quoteIfString).join(', ')}]`);
|
||||
break;
|
||||
case '$nin':
|
||||
conditions.push(`${column} NOT IN [${operand.map(quoteIfString).join(', ')}]`);
|
||||
break;
|
||||
case '$exists':
|
||||
conditions.push(`${column} ${operand ? 'EXISTS' : 'NOT EXISTS'}`);
|
||||
break;
|
||||
default:
|
||||
// Unsupported operator
|
||||
}
|
||||
} else {
|
||||
if (column === 'ingestionSource.userId') {
|
||||
// for the userId placeholder. (Await for a more elegant solution)
|
||||
const ingestionsIds = await db
|
||||
.select({ id: ingestionSources.id })
|
||||
.from(ingestionSources)
|
||||
.where(eq(ingestionSources.userId, value));
|
||||
conditions.push(
|
||||
`ingestionSourceId IN [${ingestionsIds.map((i) => quoteIfString(i.id)).join(', ')}]`
|
||||
);
|
||||
} else {
|
||||
conditions.push(`${column} = ${quoteIfString(value)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return conditions.join(' AND ');
|
||||
}
|
||||
114
packages/backend/src/iam-policy/ability.ts
Normal file
114
packages/backend/src/iam-policy/ability.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// packages/backend/src/iam-policy/ability.ts
|
||||
import { createMongoAbility, MongoAbility, RawRuleOf } from '@casl/ability';
|
||||
import { CaslPolicy, AppActions, AppSubjects } from '@open-archiver/types';
|
||||
import { ingestionSources, archivedEmails, users, roles } from '../database/schema';
|
||||
import { InferSelectModel } from 'drizzle-orm';
|
||||
|
||||
// Define the application's ability type
|
||||
export type AppAbility = MongoAbility<[AppActions, AppSubjects]>;
|
||||
|
||||
// Helper type for raw rules
|
||||
export type AppRawRule = RawRuleOf<AppAbility>;
|
||||
|
||||
// Represents the possible object types that can be passed as subjects for permission checks.
|
||||
export type SubjectObject =
|
||||
| InferSelectModel<typeof ingestionSources>
|
||||
| InferSelectModel<typeof archivedEmails>
|
||||
| InferSelectModel<typeof users>
|
||||
| InferSelectModel<typeof roles>
|
||||
| AppSubjects;
|
||||
/**
|
||||
* Translates conditions on an 'ingestion' subject to equivalent conditions on an 'archive' subject.
|
||||
* This is used to implement inherent permissions, where permission on an ingestion source
|
||||
* implies permission on the emails it has ingested.
|
||||
* @param conditions The original conditions object for the 'ingestion' subject.
|
||||
* @returns A new conditions object for the 'archive' subject.
|
||||
*/
|
||||
function translateIngestionConditionsToArchive(
|
||||
conditions: Record<string, any>
|
||||
): Record<string, any> {
|
||||
if (!conditions || typeof conditions !== 'object') {
|
||||
return conditions;
|
||||
}
|
||||
|
||||
const translated: Record<string, any> = {};
|
||||
for (const key in conditions) {
|
||||
const value = conditions[key];
|
||||
|
||||
// Handle logical operators recursively
|
||||
if (['$or', '$and', '$nor'].includes(key) && Array.isArray(value)) {
|
||||
translated[key] = value.map((v) => translateIngestionConditionsToArchive(v));
|
||||
continue;
|
||||
}
|
||||
if (key === '$not' && typeof value === 'object' && value !== null) {
|
||||
translated[key] = translateIngestionConditionsToArchive(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Translate field names
|
||||
let newKey = key;
|
||||
if (key === 'id') {
|
||||
newKey = 'ingestionSourceId';
|
||||
} else if (['userId', 'name', 'provider', 'status'].includes(key)) {
|
||||
newKey = `ingestionSource.${key}`;
|
||||
}
|
||||
|
||||
translated[newKey] = value;
|
||||
}
|
||||
return translated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands the given set of policies to include inherent permissions.
|
||||
* For example, a permission on an 'ingestion' source is expanded to grant
|
||||
* the same permission on 'archive' records related to that source.
|
||||
* @param policies The original array of CASL policies.
|
||||
* @returns A new array of policies including the expanded, inherent permissions.
|
||||
*/
|
||||
function expandPolicies(policies: CaslPolicy[]): CaslPolicy[] {
|
||||
const expandedPolicies: CaslPolicy[] = JSON.parse(JSON.stringify(policies));
|
||||
|
||||
// Create a set of all actions that are already explicitly defined for the 'archive' subject.
|
||||
const existingArchiveActions = new Set<string>();
|
||||
policies.forEach((p) => {
|
||||
if (p.subject === 'archive') {
|
||||
const actions = Array.isArray(p.action) ? p.action : [p.action];
|
||||
actions.forEach((a) => existingArchiveActions.add(a));
|
||||
}
|
||||
// Only expand `can` rules for the 'ingestion' subject.
|
||||
if (p.subject === 'ingestion' && !p.inverted) {
|
||||
const policyActions = Array.isArray(p.action) ? p.action : [p.action];
|
||||
|
||||
// Check if any action in the current ingestion policy already has an explicit archive policy.
|
||||
const hasExplicitArchiveRule = policyActions.some(
|
||||
(a) => existingArchiveActions.has(a) || existingArchiveActions.has('manage')
|
||||
);
|
||||
|
||||
// If a more specific rule for 'archive' already exists, do not expand this ingestion rule,
|
||||
// as it would create a conflicting, overly permissive rule.
|
||||
if (hasExplicitArchiveRule) {
|
||||
return;
|
||||
}
|
||||
|
||||
const archivePolicy: CaslPolicy = {
|
||||
...JSON.parse(JSON.stringify(p)),
|
||||
subject: 'archive',
|
||||
};
|
||||
if (p.conditions) {
|
||||
archivePolicy.conditions = translateIngestionConditionsToArchive(p.conditions);
|
||||
}
|
||||
expandedPolicies.push(archivePolicy);
|
||||
}
|
||||
});
|
||||
|
||||
policies.forEach((policy) => {});
|
||||
|
||||
return expandedPolicies;
|
||||
}
|
||||
|
||||
// Function to create an ability instance from policies stored in the database
|
||||
export function createAbilityFor(policies: CaslPolicy[]) {
|
||||
const allPolicies = expandPolicies(policies);
|
||||
|
||||
return createMongoAbility<AppAbility>(allPolicies as AppRawRule[]);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* @file This file serves as the single source of truth for all Identity and Access Management (IAM)
|
||||
* definitions within Open Archiver. Centralizing these definitions is an industry-standard practice
|
||||
* that offers several key benefits:
|
||||
*
|
||||
* 1. **Prevents "Magic Strings"**: Avoids the use of hardcoded strings for actions and resources
|
||||
* throughout the codebase, reducing the risk of typos and inconsistencies.
|
||||
* 2. **Single Source of Truth**: Provides a clear, comprehensive, and maintainable list of all
|
||||
* possible permissions in the system.
|
||||
* 3. **Enables Validation**: Allows for the creation of a robust validation function that can
|
||||
* programmatically check if a policy statement is valid before it is saved.
|
||||
* 4. **Simplifies Auditing**: Makes it easy to audit and understand the scope of permissions
|
||||
* that can be granted.
|
||||
*
|
||||
* The structure is inspired by AWS IAM, using a `service:operation` format for actions and a
|
||||
* hierarchical, slash-separated path for resources.
|
||||
*/
|
||||
|
||||
// ===================================================================================
|
||||
// SERVICE: archive
|
||||
// ===================================================================================
|
||||
|
||||
const ARCHIVE_ACTIONS = {
|
||||
READ: 'archive:read',
|
||||
SEARCH: 'archive:search',
|
||||
EXPORT: 'archive:export',
|
||||
} as const;
|
||||
|
||||
const ARCHIVE_RESOURCES = {
|
||||
ALL: 'archive/all',
|
||||
INGESTION_SOURCE: 'archive/ingestion-source/*',
|
||||
MAILBOX: 'archive/mailbox/*',
|
||||
CUSTODIAN: 'archive/custodian/*',
|
||||
} as const;
|
||||
|
||||
// ===================================================================================
|
||||
// SERVICE: ingestion
|
||||
// ===================================================================================
|
||||
|
||||
const INGESTION_ACTIONS = {
|
||||
CREATE_SOURCE: 'ingestion:createSource',
|
||||
READ_SOURCE: 'ingestion:readSource',
|
||||
UPDATE_SOURCE: 'ingestion:updateSource',
|
||||
DELETE_SOURCE: 'ingestion:deleteSource',
|
||||
MANAGE_SYNC: 'ingestion:manageSync', // Covers triggering, pausing, and forcing syncs
|
||||
} as const;
|
||||
|
||||
const INGESTION_RESOURCES = {
|
||||
ALL: 'ingestion-source/*',
|
||||
SOURCE: 'ingestion-source/{sourceId}',
|
||||
} as const;
|
||||
|
||||
// ===================================================================================
|
||||
// SERVICE: system
|
||||
// ===================================================================================
|
||||
|
||||
const SYSTEM_ACTIONS = {
|
||||
READ_SETTINGS: 'system:readSettings',
|
||||
UPDATE_SETTINGS: 'system:updateSettings',
|
||||
READ_USERS: 'system:readUsers',
|
||||
CREATE_USER: 'system:createUser',
|
||||
UPDATE_USER: 'system:updateUser',
|
||||
DELETE_USER: 'system:deleteUser',
|
||||
ASSIGN_ROLE: 'system:assignRole',
|
||||
} as const;
|
||||
|
||||
const SYSTEM_RESOURCES = {
|
||||
SETTINGS: 'system/settings',
|
||||
USERS: 'system/users',
|
||||
USER: 'system/user/{userId}',
|
||||
} as const;
|
||||
|
||||
// ===================================================================================
|
||||
// SERVICE: dashboard
|
||||
// ===================================================================================
|
||||
|
||||
const DASHBOARD_ACTIONS = {
|
||||
READ: 'dashboard:read',
|
||||
} as const;
|
||||
|
||||
const DASHBOARD_RESOURCES = {
|
||||
ALL: 'dashboard/*',
|
||||
} as const;
|
||||
|
||||
// ===================================================================================
|
||||
// EXPORTED DEFINITIONS
|
||||
// ===================================================================================
|
||||
|
||||
/**
|
||||
* A comprehensive set of all valid IAM actions in the system.
|
||||
* This is used by the policy validator to ensure that any action in a policy is recognized.
|
||||
*/
|
||||
export const ValidActions: Set<string> = new Set([
|
||||
...Object.values(ARCHIVE_ACTIONS),
|
||||
...Object.values(INGESTION_ACTIONS),
|
||||
...Object.values(SYSTEM_ACTIONS),
|
||||
...Object.values(DASHBOARD_ACTIONS),
|
||||
]);
|
||||
|
||||
/**
|
||||
* An object containing regular expressions for validating resource formats.
|
||||
* The validator uses these patterns to ensure that resource strings in a policy
|
||||
* conform to the expected structure.
|
||||
*
|
||||
* Logic:
|
||||
* - The key represents the service (e.g., 'archive').
|
||||
* - The value is a RegExp that matches all valid resource formats for that service.
|
||||
* - This allows for flexible validation. For example, `archive/*` is a valid pattern,
|
||||
* as is `archive/email/123-abc`.
|
||||
*/
|
||||
export const ValidResourcePatterns = {
|
||||
archive: /^archive\/(all|ingestion-source\/[^\/]+|mailbox\/[^\/]+|custodian\/[^\/]+)$/,
|
||||
ingestion: /^ingestion-source\/(\*|[^\/]+)$/,
|
||||
system: /^system\/(settings|users|user\/[^\/]+)$/,
|
||||
dashboard: /^dashboard\/\*$/,
|
||||
};
|
||||
@@ -1,106 +1,99 @@
|
||||
import type { PolicyStatement } from '@open-archiver/types';
|
||||
import { ValidActions, ValidResourcePatterns } from './iam-definitions';
|
||||
import type { CaslPolicy, AppActions, AppSubjects } from '@open-archiver/types';
|
||||
|
||||
// Create sets of valid actions and subjects for efficient validation
|
||||
const validActions: Set<AppActions> = new Set([
|
||||
'manage',
|
||||
'create',
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
'search',
|
||||
'export',
|
||||
'sync',
|
||||
]);
|
||||
|
||||
const validSubjects: Set<AppSubjects> = new Set([
|
||||
'archive',
|
||||
'ingestion',
|
||||
'settings',
|
||||
'users',
|
||||
'roles',
|
||||
'dashboard',
|
||||
'all',
|
||||
]);
|
||||
|
||||
/**
|
||||
* @class PolicyValidator
|
||||
*
|
||||
* This class provides a static method to validate an IAM policy statement.
|
||||
* This class provides a static method to validate a CASL policy.
|
||||
* It is designed to be used before a policy is saved to the database, ensuring that
|
||||
* only valid and well-formed policies are stored.
|
||||
*
|
||||
* The verification logic is based on the centralized definitions in `iam-definitions.ts`.
|
||||
* The verification logic is based on the centralized definitions in `packages/types/src/iam.types.ts`.
|
||||
*/
|
||||
export class PolicyValidator {
|
||||
/**
|
||||
* Validates a single policy statement to ensure its actions and resources are valid.
|
||||
* Validates a single policy statement to ensure its actions and subjects are valid.
|
||||
*
|
||||
* @param {PolicyStatement} statement - The policy statement to validate.
|
||||
* @param {CaslPolicy} policy - The policy to validate.
|
||||
* @returns {{valid: boolean; reason?: string}} - An object containing a boolean `valid` property
|
||||
* and an optional `reason` string if validation fails.
|
||||
*/
|
||||
public static isValid(statement: PolicyStatement): { valid: boolean; reason: string } {
|
||||
if (!statement || !statement.Action || !statement.Resource || !statement.Effect) {
|
||||
return { valid: false, reason: 'Policy statement is missing required fields.' };
|
||||
public static isValid(policy: CaslPolicy): { valid: boolean; reason: string } {
|
||||
if (!policy || !policy.action || !policy.subject) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Policy is missing required fields "action" or "subject".',
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Validate Actions
|
||||
for (const action of statement.Action) {
|
||||
const actions = Array.isArray(policy.action) ? policy.action : [policy.action];
|
||||
for (const action of actions) {
|
||||
const { valid, reason } = this.isActionValid(action);
|
||||
if (!valid) {
|
||||
return { valid: false, reason };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Validate Resources
|
||||
for (const resource of statement.Resource) {
|
||||
const { valid, reason } = this.isResourceValid(resource);
|
||||
// 2. Validate Subjects
|
||||
const subjects = Array.isArray(policy.subject) ? policy.subject : [policy.subject];
|
||||
for (const subject of subjects) {
|
||||
const { valid, reason } = this.isSubjectValid(subject);
|
||||
if (!valid) {
|
||||
return { valid: false, reason };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. (Optional) Validate Conditions, Fields, etc. in the future if needed.
|
||||
|
||||
return { valid: true, reason: 'valid' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a single action string is valid.
|
||||
*
|
||||
* Logic:
|
||||
* - If the action contains a wildcard (e.g., 'archive:*'), it checks if the service part
|
||||
* (e.g., 'archive') is a recognized service.
|
||||
* - If there is no wildcard, it checks if the full action string (e.g., 'archive:read')
|
||||
* exists in the `ValidActions` set.
|
||||
* Checks if a single action string is a valid AppAction.
|
||||
*
|
||||
* @param {string} action - The action string to validate.
|
||||
* @returns {{valid: boolean; reason?: string}} - An object indicating validity and a reason for failure.
|
||||
*/
|
||||
private static isActionValid(action: string): { valid: boolean; reason: string } {
|
||||
if (action === '*') {
|
||||
return { valid: true, reason: 'valid' };
|
||||
}
|
||||
if (action.endsWith(':*')) {
|
||||
const service = action.split(':')[0];
|
||||
if (service in ValidResourcePatterns) {
|
||||
return { valid: true, reason: 'valid' };
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Invalid service '${service}' in action wildcard '${action}'.`,
|
||||
};
|
||||
}
|
||||
if (ValidActions.has(action)) {
|
||||
private static isActionValid(action: AppActions): { valid: boolean; reason: string } {
|
||||
if (validActions.has(action)) {
|
||||
return { valid: true, reason: 'valid' };
|
||||
}
|
||||
return { valid: false, reason: `Action '${action}' is not a valid action.` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a single resource string has a valid format.
|
||||
* Checks if a single subject string is a valid AppSubject.
|
||||
*
|
||||
* Logic:
|
||||
* - It extracts the service name from the resource string (e.g., 'archive' from 'archive/all').
|
||||
* - It looks up the corresponding regular expression for that service in `ValidResourcePatterns`.
|
||||
* - It tests the resource string against the pattern. If the service does not exist or the
|
||||
* pattern does not match, the resource is considered invalid.
|
||||
*
|
||||
* @param {string} resource - The resource string to validate.
|
||||
* @param {string} subject - The subject string to validate.
|
||||
* @returns {{valid: boolean; reason?: string}} - An object indicating validity and a reason for failure.
|
||||
*/
|
||||
private static isResourceValid(resource: string): { valid: boolean; reason: string } {
|
||||
const service = resource.split('/')[0];
|
||||
if (service === '*') {
|
||||
private static isSubjectValid(subject: AppSubjects): { valid: boolean; reason: string } {
|
||||
if (validSubjects.has(subject)) {
|
||||
return { valid: true, reason: 'valid' };
|
||||
}
|
||||
if (service in ValidResourcePatterns) {
|
||||
const pattern = ValidResourcePatterns[service as keyof typeof ValidResourcePatterns];
|
||||
if (pattern.test(resource)) {
|
||||
return { valid: true, reason: 'valid' };
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Resource '${resource}' does not match the expected format for the '${service}' service.`,
|
||||
};
|
||||
}
|
||||
return { valid: false, reason: `Invalid service '${service}' in resource '${resource}'.` };
|
||||
|
||||
return { valid: false, reason: `Subject '${subject}' is not a valid subject.` };
|
||||
}
|
||||
}
|
||||
|
||||
6
packages/backend/src/iam-policy/test-policies/admin.json
Normal file
6
packages/backend/src/iam-policy/test-policies/admin.json
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "all"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"action": ["read", "search"],
|
||||
"subject": "ingestion",
|
||||
"conditions": {
|
||||
"id": "f16b7ed2-4e54-4283-9556-c633726f9405"
|
||||
}
|
||||
},
|
||||
{
|
||||
"inverted": true,
|
||||
"action": ["read", "search"],
|
||||
"subject": "archive",
|
||||
"conditions": {
|
||||
"userEmail": "dev@openarchiver.com"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"action": ["read", "search"],
|
||||
"subject": "ingestion",
|
||||
"conditions": {
|
||||
"id": {
|
||||
"$in": [
|
||||
"aeafbe44-d41c-4015-ac27-504f6e0c511a",
|
||||
"f16b7ed2-4e54-4283-9556-c633726f9405"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
17
packages/backend/src/iam-policy/test-policies/end-user.json
Normal file
17
packages/backend/src/iam-policy/test-policies/end-user.json
Normal file
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"action": "create",
|
||||
"subject": "ingestion"
|
||||
},
|
||||
{
|
||||
"action": "read",
|
||||
"subject": "dashboard"
|
||||
},
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "ingestion",
|
||||
"conditions": {
|
||||
"userId": "${user.id}"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "ingestion"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"action": ["read", "search"],
|
||||
"subject": ["ingestion", "archive", "dashboard", "users", "roles"]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "ingestion",
|
||||
"conditions": {
|
||||
"id": "f3d7c025-060f-4f1f-a0e6-cdd32e6e07af"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "users"
|
||||
},
|
||||
{
|
||||
"action": "read",
|
||||
"subject": "roles"
|
||||
}
|
||||
]
|
||||
@@ -15,6 +15,7 @@ import { createStorageRouter } from './api/routes/storage.routes';
|
||||
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 { AuthService } from './services/AuthService';
|
||||
import { UserService } from './services/UserService';
|
||||
@@ -58,8 +59,9 @@ const archivedEmailRouter = createArchivedEmailRouter(archivedEmailController, a
|
||||
const storageRouter = createStorageRouter(storageController, authService);
|
||||
const searchRouter = createSearchRouter(searchController, authService);
|
||||
const dashboardRouter = createDashboardRouter(authService);
|
||||
const iamRouter = createIamRouter(iamController);
|
||||
const iamRouter = createIamRouter(iamController, authService);
|
||||
const uploadRouter = createUploadRouter(authService);
|
||||
const userRouter = createUserRouter(authService);
|
||||
// upload route is added before middleware because it doesn't use the json middleware.
|
||||
app.use('/v1/upload', uploadRouter);
|
||||
|
||||
@@ -74,6 +76,7 @@ app.use('/v1/archived-emails', archivedEmailRouter);
|
||||
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);
|
||||
|
||||
// Example of a protected route
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { count, desc, eq, asc, and } from 'drizzle-orm';
|
||||
import { db } from '../database';
|
||||
import { archivedEmails, attachments, emailAttachments } from '../database/schema';
|
||||
import {
|
||||
archivedEmails,
|
||||
attachments,
|
||||
emailAttachments,
|
||||
ingestionSources,
|
||||
} from '../database/schema';
|
||||
import { FilterBuilder } from './FilterBuilder';
|
||||
import { AuthorizationService } from './AuthorizationService';
|
||||
import type {
|
||||
PaginatedArchivedEmails,
|
||||
ArchivedEmail,
|
||||
@@ -41,25 +48,41 @@ export class ArchivedEmailService {
|
||||
public static async getArchivedEmails(
|
||||
ingestionSourceId: string,
|
||||
page: number,
|
||||
limit: number
|
||||
limit: number,
|
||||
userId: string
|
||||
): Promise<PaginatedArchivedEmails> {
|
||||
const offset = (page - 1) * limit;
|
||||
const { drizzleFilter } = await FilterBuilder.create(userId, 'archive', 'read');
|
||||
const where = and(eq(archivedEmails.ingestionSourceId, ingestionSourceId), drizzleFilter);
|
||||
|
||||
const [total] = await db
|
||||
const countQuery = db
|
||||
.select({
|
||||
count: count(archivedEmails.id),
|
||||
})
|
||||
.from(archivedEmails)
|
||||
.where(eq(archivedEmails.ingestionSourceId, ingestionSourceId));
|
||||
.leftJoin(ingestionSources, eq(archivedEmails.ingestionSourceId, ingestionSources.id));
|
||||
|
||||
const items = await db
|
||||
if (where) {
|
||||
countQuery.where(where);
|
||||
}
|
||||
|
||||
const [total] = await countQuery;
|
||||
|
||||
const itemsQuery = db
|
||||
.select()
|
||||
.from(archivedEmails)
|
||||
.where(eq(archivedEmails.ingestionSourceId, ingestionSourceId))
|
||||
.leftJoin(ingestionSources, eq(archivedEmails.ingestionSourceId, ingestionSources.id))
|
||||
.orderBy(desc(archivedEmails.sentAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
if (where) {
|
||||
itemsQuery.where(where);
|
||||
}
|
||||
|
||||
const results = await itemsQuery;
|
||||
const items = results.map((r) => r.archived_emails);
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
@@ -73,16 +96,28 @@ export class ArchivedEmailService {
|
||||
};
|
||||
}
|
||||
|
||||
public static async getArchivedEmailById(emailId: string): Promise<ArchivedEmail | null> {
|
||||
const [email] = await db
|
||||
.select()
|
||||
.from(archivedEmails)
|
||||
.where(eq(archivedEmails.id, emailId));
|
||||
public static async getArchivedEmailById(
|
||||
emailId: string,
|
||||
userId: string
|
||||
): Promise<ArchivedEmail | null> {
|
||||
const email = await db.query.archivedEmails.findFirst({
|
||||
where: eq(archivedEmails.id, emailId),
|
||||
with: {
|
||||
ingestionSource: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorizationService = new AuthorizationService();
|
||||
const canRead = await authorizationService.can(userId, 'read', 'archive', email);
|
||||
|
||||
if (!canRead) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let threadEmails: ThreadEmail[] = [];
|
||||
|
||||
if (email.threadId) {
|
||||
|
||||
@@ -63,7 +63,13 @@ export class AuthService {
|
||||
roles: roles,
|
||||
});
|
||||
|
||||
return { accessToken, user: userWithoutPassword };
|
||||
return {
|
||||
accessToken,
|
||||
user: {
|
||||
...userWithoutPassword,
|
||||
role: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async verifyToken(token: string): Promise<AuthTokenPayload | null> {
|
||||
|
||||
25
packages/backend/src/services/AuthorizationService.ts
Normal file
25
packages/backend/src/services/AuthorizationService.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { IamService } from './IamService';
|
||||
import { createAbilityFor, SubjectObject } from '../iam-policy/ability';
|
||||
import { subject, Subject } from '@casl/ability';
|
||||
import { AppActions, AppSubjects } from '@open-archiver/types';
|
||||
|
||||
export class AuthorizationService {
|
||||
private iamService: IamService;
|
||||
|
||||
constructor() {
|
||||
this.iamService = new IamService();
|
||||
}
|
||||
|
||||
public async can(
|
||||
userId: string,
|
||||
action: AppActions,
|
||||
resource: AppSubjects,
|
||||
resourceObject?: SubjectObject
|
||||
): Promise<boolean> {
|
||||
const ability = await this.iamService.getAbilityForUser(userId);
|
||||
const subjectInstance = resourceObject
|
||||
? subject(resource, resourceObject as Record<PropertyKey, any>)
|
||||
: resource;
|
||||
return ability.can(action, subjectInstance as AppSubjects);
|
||||
}
|
||||
}
|
||||
58
packages/backend/src/services/FilterBuilder.ts
Normal file
58
packages/backend/src/services/FilterBuilder.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { SQL, sql } from 'drizzle-orm';
|
||||
import { IamService } from './IamService';
|
||||
import { rulesToQuery } from '@casl/ability/extra';
|
||||
import { mongoToDrizzle } from '../helpers/mongoToDrizzle';
|
||||
import { mongoToMeli } from '../helpers/mongoToMeli';
|
||||
import { AppActions, AppSubjects } from '@open-archiver/types';
|
||||
|
||||
export class FilterBuilder {
|
||||
public static async create(
|
||||
userId: string,
|
||||
resourceType: AppSubjects,
|
||||
action: AppActions
|
||||
): Promise<{
|
||||
drizzleFilter: SQL | undefined;
|
||||
searchFilter: string | undefined;
|
||||
}> {
|
||||
const iamService = new IamService();
|
||||
const ability = await iamService.getAbilityForUser(userId);
|
||||
|
||||
// If the user has an unconditional `can` rule and no `cannot` rules,
|
||||
// they have full access and we can skip building a complex query.
|
||||
const rules = ability.rulesFor(action, resourceType);
|
||||
|
||||
const hasUnconditionalCan = rules.some(
|
||||
(rule) => rule.inverted === false && !rule.conditions
|
||||
);
|
||||
const cannotConditions = rules
|
||||
.filter((rule) => rule.inverted === true && rule.conditions)
|
||||
.map((rule) => rule.conditions as object);
|
||||
|
||||
if (hasUnconditionalCan && cannotConditions.length === 0) {
|
||||
return { drizzleFilter: undefined, searchFilter: undefined }; // Full access
|
||||
}
|
||||
let query = rulesToQuery(ability, action, resourceType, (rule) => rule.conditions);
|
||||
|
||||
if (hasUnconditionalCan && cannotConditions.length > 0) {
|
||||
// If there's a broad `can` rule, the final query should be an AND of all
|
||||
// the `cannot` conditions, effectively excluding them.
|
||||
const andConditions = cannotConditions.map((condition) => {
|
||||
const newCondition: Record<string, any> = {};
|
||||
for (const key in condition) {
|
||||
newCondition[key] = { $ne: (condition as any)[key] };
|
||||
}
|
||||
return newCondition;
|
||||
});
|
||||
query = { $and: andConditions };
|
||||
}
|
||||
|
||||
if (query === null) {
|
||||
return { drizzleFilter: undefined, searchFilter: undefined }; // Full access
|
||||
}
|
||||
|
||||
if (Object.keys(query).length === 0) {
|
||||
return { drizzleFilter: sql`1=0`, searchFilter: 'ingestionSourceId = "-1"' }; // No access
|
||||
}
|
||||
return { drizzleFilter: mongoToDrizzle(query), searchFilter: await mongoToMeli(query) };
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,24 @@
|
||||
import { db } from '../database';
|
||||
import { roles } from '../database/schema/users';
|
||||
import type { Role, PolicyStatement } from '@open-archiver/types';
|
||||
import { roles, userRoles, users } from '../database/schema/users';
|
||||
import type { Role, CaslPolicy, User } from '@open-archiver/types';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { createAbilityFor, AppAbility } from '../iam-policy/ability';
|
||||
|
||||
export class IamService {
|
||||
/**
|
||||
* Retrieves all roles associated with a given user.
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of Role objects.
|
||||
*/
|
||||
public async getRolesForUser(userId: string): Promise<Role[]> {
|
||||
const userRolesResult = await db
|
||||
.select()
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.userId, userId))
|
||||
.leftJoin(roles, eq(userRoles.roleId, roles.id));
|
||||
|
||||
return userRolesResult.map((r) => r.roles).filter((r): r is Role => r !== null);
|
||||
}
|
||||
public async getRoles(): Promise<Role[]> {
|
||||
return db.select().from(roles);
|
||||
}
|
||||
@@ -13,12 +28,57 @@ export class IamService {
|
||||
return role;
|
||||
}
|
||||
|
||||
public async createRole(name: string, policy: PolicyStatement[]): Promise<Role> {
|
||||
const [role] = await db.insert(roles).values({ name, policies: policy }).returning();
|
||||
public async createRole(name: string, policy: CaslPolicy[], slug?: string): Promise<Role> {
|
||||
const [role] = await db
|
||||
.insert(roles)
|
||||
.values({
|
||||
name: name,
|
||||
slug: slug || name.toLocaleLowerCase().replaceAll('', '_'),
|
||||
policies: policy,
|
||||
})
|
||||
.returning();
|
||||
return role;
|
||||
}
|
||||
|
||||
public async deleteRole(id: string): Promise<void> {
|
||||
await db.delete(roles).where(eq(roles.id, id));
|
||||
}
|
||||
|
||||
public async updateRole(
|
||||
id: string,
|
||||
{ name, policies }: Partial<Pick<Role, 'name' | 'policies'>>
|
||||
): Promise<Role> {
|
||||
const [role] = await db
|
||||
.update(roles)
|
||||
.set({ name, policies })
|
||||
.where(eq(roles.id, id))
|
||||
.returning();
|
||||
return role;
|
||||
}
|
||||
|
||||
public async getAbilityForUser(userId: string): Promise<AppAbility> {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// Or handle this case as you see fit, maybe return an ability with no permissions
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const userRoles = await this.getRolesForUser(userId);
|
||||
const allPolicies = userRoles.flatMap((role) => role.policies || []);
|
||||
// Interpolate policies
|
||||
const interpolatedPolicies = this.interpolatePolicies(allPolicies, {
|
||||
...user,
|
||||
role: null,
|
||||
} as User);
|
||||
return createAbilityFor(interpolatedPolicies);
|
||||
}
|
||||
|
||||
private interpolatePolicies(policies: CaslPolicy[], user: User): CaslPolicy[] {
|
||||
const userPoliciesString = JSON.stringify(policies);
|
||||
const interpolatedPoliciesString = userPoliciesString.replace(/\$\{user\.id\}/g, user.id);
|
||||
return JSON.parse(interpolatedPoliciesString);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export class IndexingService {
|
||||
.where(eq(emailAttachments.emailId, emailId));
|
||||
}
|
||||
|
||||
const document = await this.createEmailDocument(email, emailAttachmentsResult);
|
||||
const document = await this.createEmailDocument(email, emailAttachmentsResult, email.userEmail);
|
||||
await this.searchService.addDocuments('emails', [document], 'id');
|
||||
}
|
||||
|
||||
@@ -92,8 +92,10 @@ export class IndexingService {
|
||||
email,
|
||||
attachments,
|
||||
ingestionSourceId,
|
||||
archivedEmailId
|
||||
archivedEmailId,
|
||||
email.userEmail || ''
|
||||
);
|
||||
console.log(document)
|
||||
await this.searchService.addDocuments('emails', [document], 'id');
|
||||
}
|
||||
|
||||
@@ -104,7 +106,8 @@ export class IndexingService {
|
||||
email: EmailObject,
|
||||
attachments: AttachmentsType,
|
||||
ingestionSourceId: string,
|
||||
archivedEmailId: string
|
||||
archivedEmailId: string,
|
||||
userEmail: string //the owner of the email inbox
|
||||
): Promise<EmailDocument> {
|
||||
const extractedAttachments = [];
|
||||
for (const attachment of attachments) {
|
||||
@@ -122,8 +125,10 @@ export class IndexingService {
|
||||
// skip attachment or fail the job
|
||||
}
|
||||
}
|
||||
console.log('email.userEmail', userEmail)
|
||||
return {
|
||||
id: archivedEmailId,
|
||||
userEmail: userEmail,
|
||||
from: email.from[0]?.address,
|
||||
to: email.to.map((i: EmailAddress) => i.address) || [],
|
||||
cc: email.cc?.map((i: EmailAddress) => i.address) || [],
|
||||
@@ -141,7 +146,8 @@ export class IndexingService {
|
||||
*/
|
||||
private async createEmailDocument(
|
||||
email: typeof archivedEmails.$inferSelect,
|
||||
attachments: Attachment[]
|
||||
attachments: Attachment[],
|
||||
userEmail: string,//the owner of the email inbox
|
||||
): Promise<EmailDocument> {
|
||||
const attachmentContents = await this.extractAttachmentContents(attachments);
|
||||
|
||||
@@ -155,9 +161,10 @@ export class IndexingService {
|
||||
'';
|
||||
|
||||
const recipients = email.recipients as DbRecipients;
|
||||
|
||||
console.log('email.userEmail', email.userEmail)
|
||||
return {
|
||||
id: email.id,
|
||||
userEmail: userEmail,
|
||||
from: email.senderEmail,
|
||||
to: recipients.to?.map((r) => r.address) || [],
|
||||
cc: recipients.cc?.map((r) => r.address) || [],
|
||||
|
||||
@@ -25,6 +25,7 @@ import { IndexingService } from './IndexingService';
|
||||
import { SearchService } from './SearchService';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { config } from '../config/index';
|
||||
import { FilterBuilder } from './FilterBuilder';
|
||||
|
||||
export class IngestionService {
|
||||
private static decryptSource(
|
||||
@@ -49,11 +50,15 @@ export class IngestionService {
|
||||
return ['pst_import', 'eml_import'];
|
||||
}
|
||||
|
||||
public static async create(dto: CreateIngestionSourceDto): Promise<IngestionSource> {
|
||||
public static async create(
|
||||
dto: CreateIngestionSourceDto,
|
||||
userId: string
|
||||
): Promise<IngestionSource> {
|
||||
const { providerConfig, ...rest } = dto;
|
||||
const encryptedCredentials = CryptoService.encryptObject(providerConfig);
|
||||
|
||||
const valuesToInsert = {
|
||||
userId,
|
||||
...rest,
|
||||
status: 'pending_auth' as const,
|
||||
credentials: encryptedCredentials,
|
||||
@@ -81,11 +86,15 @@ export class IngestionService {
|
||||
}
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<IngestionSource[]> {
|
||||
const sources = await db
|
||||
.select()
|
||||
.from(ingestionSources)
|
||||
.orderBy(desc(ingestionSources.createdAt));
|
||||
public static async findAll(userId: string): Promise<IngestionSource[]> {
|
||||
const { drizzleFilter } = await FilterBuilder.create(userId, 'ingestion', 'read');
|
||||
let query = db.select().from(ingestionSources).$dynamic();
|
||||
|
||||
if (drizzleFilter) {
|
||||
query = query.where(drizzleFilter);
|
||||
}
|
||||
|
||||
const sources = await query.orderBy(desc(ingestionSources.createdAt));
|
||||
return sources.flatMap((source) => {
|
||||
const decrypted = this.decryptSource(source);
|
||||
return decrypted ? [decrypted] : [];
|
||||
@@ -398,6 +407,8 @@ export class IngestionService {
|
||||
searchService,
|
||||
storageService
|
||||
);
|
||||
//assign userEmail
|
||||
email.userEmail = userEmail
|
||||
await indexingService.indexByEmail(email, source.id, archivedEmail.id);
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Index, MeiliSearch, SearchParams } from 'meilisearch';
|
||||
import { config } from '../config';
|
||||
import type { SearchQuery, SearchResult, EmailDocument, TopSender } from '@open-archiver/types';
|
||||
import { FilterBuilder } from './FilterBuilder';
|
||||
|
||||
export class SearchService {
|
||||
private client: MeiliSearch;
|
||||
@@ -47,7 +48,7 @@ export class SearchService {
|
||||
return index.deleteDocuments({ filter });
|
||||
}
|
||||
|
||||
public async searchEmails(dto: SearchQuery): Promise<SearchResult> {
|
||||
public async searchEmails(dto: SearchQuery, userId: string): Promise<SearchResult> {
|
||||
const { query, filters, page = 1, limit = 10, matchingStrategy = 'last' } = dto;
|
||||
const index = await this.getIndex<EmailDocument>('emails');
|
||||
|
||||
@@ -70,6 +71,20 @@ export class SearchService {
|
||||
searchParams.filter = filterStrings.join(' AND ');
|
||||
}
|
||||
|
||||
// Create a filter based on the user's permissions.
|
||||
// This ensures that the user can only search for emails they are allowed to see.
|
||||
const { searchFilter } = await FilterBuilder.create(userId, 'archive', 'read');
|
||||
if (searchFilter) {
|
||||
// Convert the MongoDB-style filter from CASL to a MeiliSearch filter string.
|
||||
if (searchParams.filter) {
|
||||
// If there are existing filters, append the access control filter.
|
||||
searchParams.filter = `${searchParams.filter} AND ${searchFilter}`;
|
||||
} else {
|
||||
// Otherwise, just use the access control filter.
|
||||
searchParams.filter = searchFilter;
|
||||
}
|
||||
}
|
||||
console.log('searchParams', searchParams);
|
||||
const searchResults = await index.search(query, searchParams);
|
||||
|
||||
return {
|
||||
@@ -116,8 +131,17 @@ export class SearchService {
|
||||
'bcc',
|
||||
'attachments.filename',
|
||||
'attachments.content',
|
||||
'userEmail',
|
||||
],
|
||||
filterableAttributes: [
|
||||
'from',
|
||||
'to',
|
||||
'cc',
|
||||
'bcc',
|
||||
'timestamp',
|
||||
'ingestionSourceId',
|
||||
'userEmail',
|
||||
],
|
||||
filterableAttributes: ['from', 'to', 'cc', 'bcc', 'timestamp', 'ingestionSourceId'],
|
||||
sortableAttributes: ['timestamp'],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { db } from '../database';
|
||||
import * as schema from '../database/schema';
|
||||
import { and, eq, asc, sql } from 'drizzle-orm';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { hash } from 'bcryptjs';
|
||||
import type { PolicyStatement, User } from '@open-archiver/types';
|
||||
import { PolicyValidator } from '../iam-policy/policy-validator';
|
||||
import type { CaslPolicy, User } from '@open-archiver/types';
|
||||
|
||||
export class UserService {
|
||||
/**
|
||||
@@ -23,11 +22,91 @@ export class UserService {
|
||||
* @param id The ID of the user to find.
|
||||
* @returns The user object if found, otherwise null.
|
||||
*/
|
||||
public async findById(id: string): Promise<typeof schema.users.$inferSelect | null> {
|
||||
public async findById(id: string): Promise<User | null> {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.id, id),
|
||||
with: {
|
||||
userRoles: {
|
||||
with: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return user || null;
|
||||
if (!user) return null;
|
||||
|
||||
return {
|
||||
...user,
|
||||
role: user.userRoles[0]?.role || null,
|
||||
};
|
||||
}
|
||||
|
||||
public async findAll(): Promise<User[]> {
|
||||
const users = await db.query.users.findMany({
|
||||
with: {
|
||||
userRoles: {
|
||||
with: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return users.map((u) => ({
|
||||
...u,
|
||||
role: u.userRoles[0]?.role || null,
|
||||
}));
|
||||
}
|
||||
|
||||
public async createUser(
|
||||
userDetails: Pick<User, 'email' | 'first_name' | 'last_name'> & { password?: string },
|
||||
roleId: string
|
||||
): Promise<typeof schema.users.$inferSelect> {
|
||||
const { email, first_name, last_name, password } = userDetails;
|
||||
const hashedPassword = password ? await hash(password, 10) : undefined;
|
||||
|
||||
const newUser = await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
password: hashedPassword,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db.insert(schema.userRoles).values({
|
||||
userId: newUser[0].id,
|
||||
roleId: roleId,
|
||||
});
|
||||
|
||||
return newUser[0];
|
||||
}
|
||||
|
||||
public async updateUser(
|
||||
id: string,
|
||||
userDetails: Partial<Pick<User, 'email' | 'first_name' | 'last_name'>>,
|
||||
roleId?: string
|
||||
): Promise<typeof schema.users.$inferSelect | null> {
|
||||
const updatedUser = await db
|
||||
.update(schema.users)
|
||||
.set(userDetails)
|
||||
.where(eq(schema.users.id, id))
|
||||
.returning();
|
||||
|
||||
if (roleId) {
|
||||
await db.delete(schema.userRoles).where(eq(schema.userRoles.userId, id));
|
||||
await db.insert(schema.userRoles).values({
|
||||
userId: id,
|
||||
roleId: roleId,
|
||||
});
|
||||
}
|
||||
|
||||
return updatedUser[0] || null;
|
||||
}
|
||||
|
||||
public async deleteUser(id: string): Promise<void> {
|
||||
await db.delete(schema.users).where(eq(schema.users.id, id));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,11 +151,10 @@ export class UserService {
|
||||
});
|
||||
|
||||
if (!superAdminRole) {
|
||||
const suerAdminPolicies: PolicyStatement[] = [
|
||||
const suerAdminPolicies: CaslPolicy[] = [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Action: ['*'],
|
||||
Resource: ['*'],
|
||||
action: 'manage',
|
||||
subject: 'all',
|
||||
},
|
||||
];
|
||||
superAdminRole = (
|
||||
@@ -84,6 +162,7 @@ export class UserService {
|
||||
.insert(schema.roles)
|
||||
.values({
|
||||
name: 'Super Admin',
|
||||
slug: 'predefined_super_admin',
|
||||
policies: suerAdminPolicies,
|
||||
})
|
||||
.returning()
|
||||
|
||||
47
packages/frontend/src/lib/components/custom/RoleForm.svelte
Normal file
47
packages/frontend/src/lib/components/custom/RoleForm.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import type { Role, CaslPolicy } from '@open-archiver/types';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
let { role, onSubmit }: { role: Role | null; onSubmit: (formData: Partial<Role>) => void } =
|
||||
$props();
|
||||
|
||||
let name = $state(role?.name || '');
|
||||
let policies = $state(JSON.stringify(role?.policies || [], null, 2));
|
||||
|
||||
const handleSubmit = () => {
|
||||
try {
|
||||
const parsedPolicies: CaslPolicy[] = JSON.parse(policies);
|
||||
onSubmit({ name, policies: parsedPolicies });
|
||||
} catch (error) {
|
||||
alert('Invalid JSON format for policies.');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="grid gap-4 py-4"
|
||||
>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="name" class="text-right">Name</Label>
|
||||
<Input id="name" bind:value={name} class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="policies" class="text-right">Policies (JSON)</Label>
|
||||
<Textarea
|
||||
id="policies"
|
||||
bind:value={policies}
|
||||
class="col-span-3 max-h-96 overflow-y-auto"
|
||||
rows={10}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
101
packages/frontend/src/lib/components/custom/UserForm.svelte
Normal file
101
packages/frontend/src/lib/components/custom/UserForm.svelte
Normal file
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import type { User, Role } from '@open-archiver/types';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
|
||||
let {
|
||||
user = null,
|
||||
roles,
|
||||
onSubmit,
|
||||
}: {
|
||||
user?: User | null;
|
||||
roles: Role[];
|
||||
onSubmit: (data: any) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let formData = $state({
|
||||
first_name: user?.first_name ?? '',
|
||||
last_name: user?.last_name ?? '',
|
||||
email: user?.email ?? '',
|
||||
password: '',
|
||||
roleId: user?.role?.id ?? roles[0]?.id ?? '',
|
||||
});
|
||||
|
||||
const triggerContent = $derived(
|
||||
roles.find((r) => r.id === formData.roleId)?.name ?? 'Select a role'
|
||||
);
|
||||
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
isSubmitting = true;
|
||||
try {
|
||||
const dataToSubmit: any = { ...formData };
|
||||
if (!user) {
|
||||
// only send password on create
|
||||
dataToSubmit.password = formData.password;
|
||||
} else {
|
||||
delete dataToSubmit.password;
|
||||
}
|
||||
if (dataToSubmit.password === '') {
|
||||
delete dataToSubmit.password;
|
||||
}
|
||||
await onSubmit(dataToSubmit);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} class="grid gap-4 py-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="first_name" class="text-left">First Name</Label>
|
||||
<Input id="first_name" bind:value={formData.first_name} class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="last_name" class="text-left">Last Name</Label>
|
||||
<Input id="last_name" bind:value={formData.last_name} class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="email" class="text-left">Email</Label>
|
||||
<Input id="email" type="email" bind:value={formData.email} class="col-span-3" />
|
||||
</div>
|
||||
{#if !user}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="password" class="text-left">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={formData.password}
|
||||
class="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="role" class="text-left">Role</Label>
|
||||
<Select.Root name="role" bind:value={formData.roleId} type="single">
|
||||
<Select.Trigger class="col-span-3">
|
||||
{triggerContent}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each roles as role}
|
||||
<Select.Item value={role.id}>{role.name}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{#if isSubmitting}
|
||||
Submitting...
|
||||
{:else}
|
||||
Submit
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
22
packages/frontend/src/routes/dashboard/+error.svelte
Normal file
22
packages/frontend/src/routes/dashboard/+error.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
|
||||
import * as Alert from '$lib/components/ui/alert/index.js';
|
||||
</script>
|
||||
|
||||
<div class="flex h-full w-full flex-col items-center justify-center space-y-4">
|
||||
<Alert.Root variant="destructive">
|
||||
<CircleAlertIcon class="size-4" />
|
||||
<Alert.Title>
|
||||
<h1 class=" font-bold">Error: {page.status}</h1>
|
||||
</Alert.Title>
|
||||
<Alert.Description>
|
||||
<div class=" space-y-2">
|
||||
<div>
|
||||
{page.error?.message}
|
||||
</div>
|
||||
</div>
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
</div>
|
||||
@@ -5,11 +5,31 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import ThemeSwitcher from '$lib/components/custom/ThemeSwitcher.svelte';
|
||||
const navItems = [
|
||||
const navItems: {
|
||||
href?: string;
|
||||
label: string;
|
||||
subMenu?: {
|
||||
href: string;
|
||||
label: string;
|
||||
}[];
|
||||
}[] = [
|
||||
{ href: '/dashboard', label: 'Dashboard' },
|
||||
{ href: '/dashboard/ingestions', label: 'Ingestions' },
|
||||
{ href: '/dashboard/archived-emails', label: 'Archived emails' },
|
||||
{ href: '/dashboard/search', label: 'Search' },
|
||||
{
|
||||
label: 'Settings',
|
||||
subMenu: [
|
||||
{
|
||||
href: '/dashboard/settings/users',
|
||||
label: 'Users',
|
||||
},
|
||||
{
|
||||
href: '/dashboard/settings/roles',
|
||||
label: 'Roles',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
let { children } = $props();
|
||||
function handleLogout() {
|
||||
@@ -24,16 +44,43 @@
|
||||
<img src="/logos/logo-sq.svg" alt="OpenArchiver Logo" class="h-8 w-8" />
|
||||
<span>Open Archiver</span>
|
||||
</a>
|
||||
<NavigationMenu.Root>
|
||||
<NavigationMenu.Root viewport={false}>
|
||||
<NavigationMenu.List class="flex items-center space-x-4">
|
||||
{#each navItems as item}
|
||||
<NavigationMenu.Item
|
||||
class={page.url.pathname === item.href ? 'bg-accent rounded-md' : ''}
|
||||
>
|
||||
<NavigationMenu.Link href={item.href}>
|
||||
{item.label}
|
||||
</NavigationMenu.Link>
|
||||
</NavigationMenu.Item>
|
||||
{#if item.subMenu && item.subMenu.length > 0}
|
||||
<NavigationMenu.Item
|
||||
class={item.subMenu.some((sub) =>
|
||||
page.url.pathname.startsWith(
|
||||
sub.href.substring(0, sub.href.lastIndexOf('/'))
|
||||
)
|
||||
)
|
||||
? 'bg-accent rounded-md'
|
||||
: ''}
|
||||
>
|
||||
<NavigationMenu.Trigger class="cursor-pointer font-normal">
|
||||
{item.label}
|
||||
</NavigationMenu.Trigger>
|
||||
<NavigationMenu.Content>
|
||||
<ul class="grid w-fit min-w-28 gap-1 p-1">
|
||||
{#each item.subMenu as subItem}
|
||||
<li>
|
||||
<NavigationMenu.Link href={subItem.href}>
|
||||
{subItem.label}
|
||||
</NavigationMenu.Link>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</NavigationMenu.Content>
|
||||
</NavigationMenu.Item>
|
||||
{:else if item.href}
|
||||
<NavigationMenu.Item
|
||||
class={page.url.pathname === item.href ? 'bg-accent rounded-md' : ''}
|
||||
>
|
||||
<NavigationMenu.Link href={item.href}>
|
||||
{item.label}
|
||||
</NavigationMenu.Link>
|
||||
</NavigationMenu.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</NavigationMenu.List>
|
||||
</NavigationMenu.Root>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { api } from '$lib/server/api';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type {
|
||||
DashboardStats,
|
||||
IngestionHistory,
|
||||
@@ -10,58 +11,57 @@ import type {
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const fetchStats = async (): Promise<DashboardStats | null> => {
|
||||
try {
|
||||
const response = await api('/dashboard/stats', event);
|
||||
if (!response.ok) throw new Error('Failed to fetch stats');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Dashboard Stats Error:', error);
|
||||
return null;
|
||||
const response = await api('/dashboard/stats', event);
|
||||
const responseText = await response.json();
|
||||
if (!response.ok) {
|
||||
throw error(response.status, responseText.message || 'Failed to fetch data');
|
||||
}
|
||||
return responseText;
|
||||
};
|
||||
|
||||
const fetchIngestionHistory = async (): Promise<IngestionHistory | null> => {
|
||||
try {
|
||||
const response = await api('/dashboard/ingestion-history', event);
|
||||
if (!response.ok) throw new Error('Failed to fetch ingestion history');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Ingestion History Error:', error);
|
||||
return null;
|
||||
const response = await api('/dashboard/ingestion-history', event);
|
||||
const responseText = await response.json();
|
||||
if (!response.ok) {
|
||||
return error(
|
||||
response.status,
|
||||
responseText.message || 'Failed to fetch ingestion history'
|
||||
);
|
||||
}
|
||||
return responseText;
|
||||
};
|
||||
|
||||
const fetchIngestionSources = async (): Promise<IngestionSourceStats[] | null> => {
|
||||
try {
|
||||
const response = await api('/dashboard/ingestion-sources', event);
|
||||
if (!response.ok) throw new Error('Failed to fetch ingestion sources');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Ingestion Sources Error:', error);
|
||||
return null;
|
||||
const response = await api('/dashboard/ingestion-sources', event);
|
||||
const responseText = await response.json();
|
||||
if (!response.ok) {
|
||||
return error(
|
||||
response.status,
|
||||
responseText.message || 'Failed to fetch ingestion sources'
|
||||
);
|
||||
}
|
||||
return responseText;
|
||||
};
|
||||
|
||||
const fetchRecentSyncs = async (): Promise<RecentSync[] | null> => {
|
||||
try {
|
||||
const response = await api('/dashboard/recent-syncs', event);
|
||||
if (!response.ok) throw new Error('Failed to fetch recent syncs');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Recent Syncs Error:', error);
|
||||
return null;
|
||||
const response = await api('/dashboard/recent-syncs', event);
|
||||
const responseText = await response.json();
|
||||
if (!response.ok) {
|
||||
return error(response.status, responseText.message || 'Failed to fetch recent syncs');
|
||||
}
|
||||
return responseText;
|
||||
};
|
||||
|
||||
const fetchIndexedInsights = async (): Promise<IndexedInsights | null> => {
|
||||
try {
|
||||
const response = await api('/dashboard/indexed-insights', event);
|
||||
if (!response.ok) throw new Error('Failed to fetch indexed insights');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Indexed Insights Error:', error);
|
||||
return null;
|
||||
const response = await api('/dashboard/indexed-insights', event);
|
||||
const responseText = await response.json();
|
||||
if (!response.ok) {
|
||||
return error(
|
||||
response.status,
|
||||
responseText.message || 'Failed to fetch indexed insights'
|
||||
);
|
||||
}
|
||||
return responseText;
|
||||
};
|
||||
|
||||
const [stats, ingestionHistory, ingestionSources, recentSyncs, indexedInsights] =
|
||||
|
||||
@@ -1,56 +1,52 @@
|
||||
import { api } from '$lib/server/api';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import type { IngestionSource, PaginatedArchivedEmails } from '@open-archiver/types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
try {
|
||||
const { url } = event;
|
||||
const ingestionSourceId = url.searchParams.get('ingestionSourceId');
|
||||
const page = url.searchParams.get('page') || '1';
|
||||
const limit = url.searchParams.get('limit') || '10';
|
||||
const { url } = event;
|
||||
const ingestionSourceId = url.searchParams.get('ingestionSourceId');
|
||||
const page = url.searchParams.get('page') || '1';
|
||||
const limit = url.searchParams.get('limit') || '10';
|
||||
|
||||
const sourcesResponse = await api('/ingestion-sources', event);
|
||||
if (!sourcesResponse.ok) {
|
||||
throw new Error(`Failed to fetch ingestion sources: ${sourcesResponse.statusText}`);
|
||||
}
|
||||
const ingestionSources: IngestionSource[] = await sourcesResponse.json();
|
||||
|
||||
let archivedEmails: PaginatedArchivedEmails = {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
const selectedIngestionSourceId = ingestionSourceId || ingestionSources[0]?.id;
|
||||
|
||||
if (selectedIngestionSourceId) {
|
||||
const emailsResponse = await api(
|
||||
`/archived-emails/ingestion-source/${selectedIngestionSourceId}?page=${page}&limit=${limit}`,
|
||||
event
|
||||
);
|
||||
if (!emailsResponse.ok) {
|
||||
throw new Error(`Failed to fetch archived emails: ${emailsResponse.statusText}`);
|
||||
}
|
||||
archivedEmails = await emailsResponse.json();
|
||||
}
|
||||
|
||||
return {
|
||||
ingestionSources,
|
||||
archivedEmails,
|
||||
selectedIngestionSourceId,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to load archived emails page:', error);
|
||||
return {
|
||||
ingestionSources: [],
|
||||
archivedEmails: {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
},
|
||||
error: 'Failed to load data',
|
||||
};
|
||||
const sourcesResponse = await api('/ingestion-sources', event);
|
||||
const sourcesResponseText = await sourcesResponse.json();
|
||||
if (!sourcesResponse.ok) {
|
||||
return error(
|
||||
sourcesResponseText.status,
|
||||
sourcesResponseText.message || 'Failed to load ingestion source.'
|
||||
);
|
||||
}
|
||||
const ingestionSources: IngestionSource[] = sourcesResponseText;
|
||||
|
||||
let archivedEmails: PaginatedArchivedEmails = {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
// Use the provided ingestionSourceId, or default to the first one if it's not provided.
|
||||
const selectedIngestionSourceId = ingestionSourceId || ingestionSources[0]?.id;
|
||||
|
||||
if (selectedIngestionSourceId) {
|
||||
const emailsResponse = await api(
|
||||
`/archived-emails/ingestion-source/${selectedIngestionSourceId}?page=${page}&limit=${limit}`,
|
||||
event
|
||||
);
|
||||
const responseText = await emailsResponse.json();
|
||||
if (!emailsResponse.ok) {
|
||||
return error(
|
||||
emailsResponse.status,
|
||||
responseText.message || 'Failed to load archived emails.'
|
||||
);
|
||||
}
|
||||
archivedEmails = responseText;
|
||||
}
|
||||
|
||||
return {
|
||||
ingestionSources,
|
||||
archivedEmails,
|
||||
selectedIngestionSourceId,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from '$lib/server/api';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import type { ArchivedEmail } from '@open-archiver/types';
|
||||
|
||||
@@ -6,10 +7,14 @@ export const load: PageServerLoad = async (event) => {
|
||||
try {
|
||||
const { id } = event.params;
|
||||
const response = await api(`/archived-emails/${id}`, event);
|
||||
const responseText = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch archived email: ${response.statusText}`);
|
||||
return error(
|
||||
response.status,
|
||||
responseText.message || 'You do not have permission to read this email.'
|
||||
);
|
||||
}
|
||||
const email: ArchivedEmail = await response.json();
|
||||
const email: ArchivedEmail = responseText;
|
||||
return {
|
||||
email,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { formatBytes } from '$lib/utils';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
let email = $derived(data.email);
|
||||
@@ -51,7 +52,13 @@
|
||||
const errorData = await response.json().catch(() => null);
|
||||
const message = errorData?.message || 'Failed to delete email';
|
||||
console.error('Delete failed:', message);
|
||||
alert(message);
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: 'Failed to delete archived email',
|
||||
message: message,
|
||||
duration: 5000,
|
||||
show: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await goto('/dashboard/archived-emails', { invalidateAll: true });
|
||||
@@ -64,6 +71,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{email?.subject} | Archived emails - OpenArchiver</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if email}
|
||||
<div class="grid grid-cols-3 gap-6">
|
||||
<div class="col-span-3 md:col-span-2">
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { api } from '$lib/server/api';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import type { IngestionSource } from '@open-archiver/types';
|
||||
|
||||
import { error } from '@sveltejs/kit';
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
try {
|
||||
const response = await api('/ingestion-sources', event);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ingestion sources: ${response.statusText}`);
|
||||
}
|
||||
const ingestionSources: IngestionSource[] = await response.json();
|
||||
return {
|
||||
ingestionSources,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to load ingestion sources:', error);
|
||||
return {
|
||||
ingestionSources: [],
|
||||
error: 'Failed to load ingestion sources',
|
||||
};
|
||||
const response = await api('/ingestion-sources', event);
|
||||
const responseText = await response.json();
|
||||
if (!response.ok) {
|
||||
throw error(response.status, responseText.message || 'Failed to fetch ingestions.');
|
||||
}
|
||||
const ingestionSources: IngestionSource[] = responseText;
|
||||
return {
|
||||
ingestionSources,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -103,12 +103,22 @@
|
||||
throw Error('This operation is not allowed in demo mode.');
|
||||
}
|
||||
if (newStatus === 'paused') {
|
||||
await api(`/ingestion-sources/${source.id}/pause`, { method: 'POST' });
|
||||
const response = await api(`/ingestion-sources/${source.id}/pause`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const responseText = await response.json();
|
||||
if (!response.ok) {
|
||||
throw Error(responseText.message || 'Operation failed');
|
||||
}
|
||||
} else {
|
||||
await api(`/ingestion-sources/${source.id}`, {
|
||||
const response = await api(`/ingestion-sources/${source.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: 'active' }),
|
||||
});
|
||||
const responseText = await response.json();
|
||||
if (!response.ok) {
|
||||
throw Error(responseText.message || 'Operation failed');
|
||||
}
|
||||
}
|
||||
|
||||
ingestionSources = ingestionSources.map((s) => {
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import type { MatchingStrategy } from '@open-archiver/types';
|
||||
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
|
||||
import * as Alert from '$lib/components/ui/alert/index.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
let searchResult = $derived(data.searchResult);
|
||||
@@ -208,7 +210,11 @@
|
||||
</form>
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-500">{error}</p>
|
||||
<Alert.Root variant="destructive">
|
||||
<CircleAlertIcon class="size-4" />
|
||||
<Alert.Title>Error</Alert.Title>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
{#if searchResult}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { api } from '$lib/server/api';
|
||||
import type { Role } from '@open-archiver/types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const rolesResponse = await api('/iam/roles', event);
|
||||
|
||||
if (!rolesResponse.ok) {
|
||||
const { message } = await rolesResponse.json();
|
||||
throw error(rolesResponse.status, message || 'Failed to fetch roles');
|
||||
}
|
||||
|
||||
const roles: Role[] = await rolesResponse.json();
|
||||
return {
|
||||
roles,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { MoreHorizontal, Trash, Eye, Edit } from 'lucide-svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
|
||||
import RoleForm from '$lib/components/custom/RoleForm.svelte';
|
||||
import { api } from '$lib/api.client';
|
||||
import type { Role } from '@open-archiver/types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
let roles = $state(data.roles);
|
||||
let isViewPolicyDialogOpen = $state(false);
|
||||
let isFormDialogOpen = $state(false);
|
||||
let isDeleteDialogOpen = $state(false);
|
||||
let selectedRole = $state<Role | null>(null);
|
||||
let roleToDelete = $state<Role | null>(null);
|
||||
let isDeleting = $state(false);
|
||||
|
||||
const openCreateDialog = () => {
|
||||
selectedRole = null;
|
||||
isFormDialogOpen = true;
|
||||
};
|
||||
|
||||
const openEditDialog = (role: Role) => {
|
||||
selectedRole = role;
|
||||
isFormDialogOpen = true;
|
||||
};
|
||||
|
||||
const openViewPolicyDialog = (role: Role) => {
|
||||
selectedRole = role;
|
||||
isViewPolicyDialogOpen = true;
|
||||
};
|
||||
|
||||
const openDeleteDialog = (role: Role) => {
|
||||
roleToDelete = role;
|
||||
isDeleteDialogOpen = true;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!roleToDelete) return;
|
||||
isDeleting = true;
|
||||
try {
|
||||
const res = await api(`/iam/roles/${roleToDelete.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.json();
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: 'Failed to delete role',
|
||||
message: errorBody.message || JSON.stringify(errorBody),
|
||||
duration: 5000,
|
||||
show: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
roles = roles.filter((r) => r.id !== roleToDelete!.id);
|
||||
isDeleteDialogOpen = false;
|
||||
roleToDelete = null;
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: Partial<Role>) => {
|
||||
try {
|
||||
if (selectedRole) {
|
||||
// Update
|
||||
const response = await api(`/iam/roles/${selectedRole.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to update role.');
|
||||
}
|
||||
const updatedRole: Role = await response.json();
|
||||
roles = roles.map((r: Role) => (r.id === updatedRole.id ? updatedRole : r));
|
||||
} else {
|
||||
// Create
|
||||
const response = await api('/iam/roles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to create role.');
|
||||
}
|
||||
const newRole: Role = await response.json();
|
||||
roles = [...roles, newRole];
|
||||
}
|
||||
isFormDialogOpen = false;
|
||||
} catch (error) {
|
||||
let message = 'An unknown error occurred.';
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
}
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: 'Operation Failed',
|
||||
message,
|
||||
duration: 5000,
|
||||
show: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Role Management - OpenArchiver</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">Role Management</h1>
|
||||
<Button onclick={openCreateDialog}>Create New</Button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>Created At</Table.Head>
|
||||
<Table.Head class="text-right">Actions</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if roles.length > 0}
|
||||
{#each roles as role (role.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>{role.name}</Table.Cell>
|
||||
<Table.Cell>{new Date(role.createdAt).toLocaleDateString()}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Open menu</span>
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Label>Actions</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
onclick={() => openViewPolicyDialog(role)}
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<Eye class="mr-2 h-4 w-4" />
|
||||
View Policy
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onclick={() => openEditDialog(role)}
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<Edit class="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
class="text-destructive cursor-pointer"
|
||||
onclick={() => openDeleteDialog(role)}
|
||||
>
|
||||
<Trash class="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={3} class="h-24 text-center">No roles found.</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={isViewPolicyDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Role Policy</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Viewing policy for role: {selectedRole?.name}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div
|
||||
class=" max-h-98 overflow-x-auto overflow-y-auto rounded-md bg-gray-900 p-2 text-white"
|
||||
>
|
||||
<pre>{JSON.stringify(selectedRole?.policies, null, 2)}</pre>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Dialog.Root bind:open={isFormDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{selectedRole ? 'Edit' : 'Create'} Role</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{selectedRole ? 'Make changes to the role here.' : 'Add a new role to the system.'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<RoleForm role={selectedRole} onSubmit={handleFormSubmit} />
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Dialog.Root bind:open={isDeleteDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Are you sure you want to delete this role?</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
This action cannot be undone. This will permanently delete the role.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer class="sm:justify-start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onclick={confirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{#if isDeleting}Deleting...{:else}Confirm{/if}
|
||||
</Button>
|
||||
<Dialog.Close>
|
||||
<Button type="button" variant="secondary">Cancel</Button>
|
||||
</Dialog.Close>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { api } from '$lib/server/api';
|
||||
import type { User, Role } from '@open-archiver/types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const [usersResponse, rolesResponse] = await Promise.all([
|
||||
api('/users', event),
|
||||
api('/iam/roles', event),
|
||||
]);
|
||||
|
||||
if (!usersResponse.ok) {
|
||||
const { message } = await usersResponse.json();
|
||||
throw error(usersResponse.status, message || 'Failed to fetch users');
|
||||
}
|
||||
if (!rolesResponse.ok) {
|
||||
const { message } = await rolesResponse.json();
|
||||
throw error(rolesResponse.status, message || 'Failed to fetch roles');
|
||||
}
|
||||
|
||||
const users: User[] = await usersResponse.json();
|
||||
const roles: Role[] = await rolesResponse.json();
|
||||
return {
|
||||
users,
|
||||
roles,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { MoreHorizontal, Trash, Edit } from 'lucide-svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
|
||||
import UserForm from '$lib/components/custom/UserForm.svelte';
|
||||
import { api } from '$lib/api.client';
|
||||
import type { User } from '@open-archiver/types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
let users = $state(data.users);
|
||||
let roles = $state(data.roles);
|
||||
let isDialogOpen = $state(false);
|
||||
let isDeleteDialogOpen = $state(false);
|
||||
let selectedUser = $state<User | null>(null);
|
||||
let userToDelete = $state<User | null>(null);
|
||||
let isDeleting = $state(false);
|
||||
|
||||
const openCreateDialog = () => {
|
||||
selectedUser = null;
|
||||
isDialogOpen = true;
|
||||
};
|
||||
|
||||
const openEditDialog = (user: User) => {
|
||||
selectedUser = user;
|
||||
isDialogOpen = true;
|
||||
};
|
||||
|
||||
const openDeleteDialog = (user: User) => {
|
||||
userToDelete = user;
|
||||
isDeleteDialogOpen = true;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!userToDelete) return;
|
||||
isDeleting = true;
|
||||
try {
|
||||
const res = await api(`/users/${userToDelete.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.json();
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: 'Failed to delete user',
|
||||
message: errorBody.message || JSON.stringify(errorBody),
|
||||
duration: 5000,
|
||||
show: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
users = users.filter((u) => u.id !== userToDelete!.id);
|
||||
isDeleteDialogOpen = false;
|
||||
userToDelete = null;
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: Partial<User> & { roleId: string }) => {
|
||||
try {
|
||||
if (selectedUser) {
|
||||
// Update
|
||||
const response = await api(`/users/${selectedUser.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to update user.');
|
||||
}
|
||||
const updatedUser: User = await response.json();
|
||||
users = users.map((u: User) => (u.id === updatedUser.id ? updatedUser : u));
|
||||
} else {
|
||||
// Create
|
||||
const response = await api('/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to create user.');
|
||||
}
|
||||
const newUser: User = await response.json();
|
||||
users = [...users, newUser];
|
||||
}
|
||||
isDialogOpen = false;
|
||||
} catch (error) {
|
||||
let message = 'An unknown error occurred.';
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
}
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: 'Operation Failed',
|
||||
message,
|
||||
duration: 5000,
|
||||
show: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>User Management - OpenArchiver</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">User Management</h1>
|
||||
<Button onclick={openCreateDialog}>Create New</Button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>Email</Table.Head>
|
||||
<Table.Head>Role</Table.Head>
|
||||
<Table.Head>Created At</Table.Head>
|
||||
<Table.Head class="text-right">Actions</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if users.length > 0}
|
||||
{#each users as user (user.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>{user.first_name} {user.last_name}</Table.Cell>
|
||||
<Table.Cell>{user.email}</Table.Cell>
|
||||
<Table.Cell>{user.role?.name || 'N/A'}</Table.Cell>
|
||||
<Table.Cell>{new Date(user.createdAt).toLocaleDateString()}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Open menu</span>
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Label>Actions</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
onclick={() => openEditDialog(user)}
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<Edit class="mr-2 h-4 w-4" />
|
||||
Edit</DropdownMenu.Item
|
||||
>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
class="text-destructive cursor-pointer"
|
||||
onclick={() => openDeleteDialog(user)}
|
||||
>
|
||||
<Trash class="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="h-24 text-center">No users found.</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={isDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{selectedUser ? 'Edit' : 'Create'} User</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{selectedUser ? 'Make changes to the user here.' : 'Add a new user to the system.'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<UserForm {roles} user={selectedUser} onSubmit={handleFormSubmit} />
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Dialog.Root bind:open={isDeleteDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Are you sure you want to delete this user?</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
This action cannot be undone. This will permanently delete the user and remove their
|
||||
data from our servers.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer class="sm:justify-start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onclick={confirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{#if isDeleting}Deleting...{:else}Confirm{/if}
|
||||
</Button>
|
||||
<Dialog.Close>
|
||||
<Button type="button" variant="secondary">Cancel</Button>
|
||||
</Dialog.Close>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -58,6 +58,7 @@ export interface EmailObject {
|
||||
// Define the structure of the document to be indexed in Meilisearch
|
||||
export interface EmailDocument {
|
||||
id: string; // The unique ID of the email
|
||||
userEmail: string;
|
||||
from: string;
|
||||
to: string[];
|
||||
cc: string[];
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
export type Action = string;
|
||||
// Define all possible actions and subjects for type safety
|
||||
export type AppActions =
|
||||
| 'manage'
|
||||
| 'create'
|
||||
| 'read'
|
||||
| 'update'
|
||||
| 'delete'
|
||||
| 'search'
|
||||
| 'export'
|
||||
| 'sync';
|
||||
|
||||
export type Resource = string;
|
||||
export type AppSubjects =
|
||||
| 'archive'
|
||||
| 'ingestion'
|
||||
| 'settings'
|
||||
| 'users'
|
||||
| 'roles'
|
||||
| 'dashboard'
|
||||
| 'all';
|
||||
|
||||
export interface PolicyStatement {
|
||||
Effect: 'Allow' | 'Deny';
|
||||
Action: Action[];
|
||||
Resource: Resource[];
|
||||
// This structure will be stored in the `roles.policies` column
|
||||
export interface CaslPolicy {
|
||||
action: AppActions | AppActions[];
|
||||
subject: AppSubjects | AppSubjects[];
|
||||
/**
|
||||
* Conditions will be written using MongoDB query syntax (e.g., { status: { $in: ['active'] } })
|
||||
* This leverages the full power of CASL's ucast library.
|
||||
*/
|
||||
conditions?: Record<string, any>;
|
||||
fields?: string[];
|
||||
inverted?: boolean; // true represents a 'Deny' effect
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PolicyStatement } from './iam.types';
|
||||
import { CaslPolicy } from './iam.types';
|
||||
|
||||
/**
|
||||
* Represents a user account in the system.
|
||||
@@ -9,6 +9,8 @@ export interface User {
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
email: string;
|
||||
role: Role | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,8 +29,9 @@ export interface Session {
|
||||
*/
|
||||
export interface Role {
|
||||
id: string;
|
||||
slug: string | null;
|
||||
name: string;
|
||||
policies: PolicyStatement[];
|
||||
policies: CaslPolicy[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user