mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3ff29501b | |||
| f5c3fe2bc8 | |||
| bc8264b979 | |||
| 6c8d03c35f | |||
| 7ced0a54e1 | |||
| 32a2819ebf | |||
| 9bba904c16 | |||
| b24f829a6b | |||
| 1be3dac90e | |||
| af63530390 | |||
| 8d9276830f | |||
| 8eab7c2573 | |||
| d06266fb93 | |||
| 4ec6b143ca | |||
| e3e896e159 | |||
| de26871aff | |||
| 3aff4c77f2 | |||
| 1d6af3ab7d | |||
| 5600d261bc | |||
| 35776d9eb5 | |||
| 9f0cfccdc6 | |||
| ba17eff713 | |||
| 08ee2af865 |
+7
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.32.0",
|
||||
"version": "1.42.5",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch ./server.ts",
|
||||
"start": "NODE_ENV=production node ./dist/taskview-server.js",
|
||||
@@ -28,6 +28,7 @@
|
||||
"@types/passport-apple": "^2.0.3",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"aws-sdk": "^2.1691.0",
|
||||
"mock-aws-s3": "^4.0.2",
|
||||
"nock": "^13.5.5",
|
||||
@@ -41,13 +42,15 @@
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-saml/node-saml": "^5.1.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/passport-github2": "^1.2.9",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"@vitejs/plugin-legacy": "^5.4.2",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"axios": "^1.7.7",
|
||||
"@xmldom/xmldom": "^0.9.9",
|
||||
"axios": "1.13.5",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
@@ -58,6 +61,7 @@
|
||||
"firebase-admin": "^12.7.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"openid-client": "^6.8.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-apple": "^2.0.2",
|
||||
"passport-github2": "^0.1.12",
|
||||
@@ -69,6 +73,7 @@
|
||||
"semver": "^7.6.3",
|
||||
"taskview-db-schemas": "workspace:^",
|
||||
"terser": "^5.36.0",
|
||||
"ua-parser-js": "^2.0.9",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export default class App {
|
||||
this.app.use(cors({
|
||||
credentials: true,
|
||||
origin(origin, cb) {
|
||||
if (!origin) return cb(null, true);
|
||||
if (!origin || origin === 'null') return cb(null, true);
|
||||
if (allow.has(origin)) return cb(null, true);
|
||||
return cb(new Error(`CORS blocked origin: ${origin}`), false);
|
||||
},
|
||||
|
||||
@@ -9,6 +9,8 @@ import { StartManager } from '../tv-modules/start/StartManager';
|
||||
import { TagsManager } from '../tv-modules/tags/TagsManager';
|
||||
import { IntegrationsManager } from '../tv-modules/integrations/IntegrationsManager';
|
||||
import { NotificationsManager } from '../tv-modules/notifications/NotificationsManager';
|
||||
import { OrganizationManager } from '../tv-modules/organizations/OrganizationManager';
|
||||
import { SsoManager } from '../tv-modules/sso/SsoManager';
|
||||
import { TasksManager } from '../tv-modules/tasks/TasksManager';
|
||||
import type { UserDbRecord, UserJwtPayload } from '../types/auth.types';
|
||||
import { GoalPermissionsFetcher } from './GoalPermissionsFetcher';
|
||||
@@ -24,12 +26,17 @@ export class AppUser {
|
||||
public readonly tagsManager: TagsManager;
|
||||
public readonly authManager: AuthManager;
|
||||
private hasActiveToken: boolean = false;
|
||||
private apiTokenAuth: boolean = false;
|
||||
private tokenPermissions?: string[];
|
||||
private allowedGoalIds?: number[];
|
||||
private userDataFromDb?: UserDbRecord;
|
||||
public readonly startManager: StartManager;
|
||||
public readonly kanbanManager: KanbanManager;
|
||||
public readonly graphManager: GraphManager;
|
||||
public readonly integrationsManager: IntegrationsManager;
|
||||
public readonly notificationsManager: NotificationsManager;
|
||||
public readonly organizationManager: OrganizationManager;
|
||||
public readonly ssoManager: SsoManager;
|
||||
|
||||
constructor(userData?: UserJwtPayload) {
|
||||
this.userData = userData;
|
||||
@@ -46,6 +53,8 @@ export class AppUser {
|
||||
this.graphManager = new GraphManager(this);
|
||||
this.integrationsManager = new IntegrationsManager(this);
|
||||
this.notificationsManager = new NotificationsManager(this);
|
||||
this.organizationManager = new OrganizationManager(this);
|
||||
this.ssoManager = new SsoManager(this);
|
||||
}
|
||||
|
||||
getTokenId(): number | undefined {
|
||||
@@ -75,4 +84,26 @@ export class AppUser {
|
||||
isBlocked(): boolean {
|
||||
return this.userDataFromDb?.block !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use it for API token authentication.
|
||||
* @param permissions Permissions that the API token has
|
||||
*/
|
||||
setApiTokenAuth(permissions: string[], goalIds: number[]) {
|
||||
this.apiTokenAuth = true;
|
||||
this.tokenPermissions = permissions;
|
||||
this.allowedGoalIds = goalIds;
|
||||
}
|
||||
|
||||
isApiTokenAuth(): boolean {
|
||||
return this.apiTokenAuth;
|
||||
}
|
||||
|
||||
getAllowedGoalIds(): number[] | undefined {
|
||||
return this.allowedGoalIds;
|
||||
}
|
||||
|
||||
getTokenPermissions(): string[] | undefined {
|
||||
return this.tokenPermissions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ export interface AppEvents {
|
||||
'task.updated': { task: TasksSchemaTypeForSelect; changes: Record<string, unknown>; initiatorId: number };
|
||||
'task.assigneesChanged': { taskId: number; userIds: number[]; initiatorId: number };
|
||||
'task.deleted': { taskId: number; goalId: number; initiatorId: number };
|
||||
'collaboration.userAdded': { goalId: number; email: string; initiatorId: number };
|
||||
'collaboration.userRemoved': { goalId: number; collaborationUserId: number; initiatorId: number };
|
||||
'collaboration.rolesChanged': { goalId: number; collaborationUserId: number; initiatorId: number };
|
||||
}
|
||||
|
||||
type EventName = keyof AppEvents;
|
||||
|
||||
@@ -39,14 +39,25 @@ export class GoalPermissionsFetcher {
|
||||
|
||||
if (!goalId) { return new GoalPermissionsChecker([]); }
|
||||
|
||||
//Token authentication check
|
||||
const allowedGoalIds = this.user.getAllowedGoalIds();
|
||||
if (allowedGoalIds && allowedGoalIds.length > 0 && !allowedGoalIds.includes(goalId)) {
|
||||
return new GoalPermissionsChecker([]);
|
||||
}
|
||||
|
||||
if (this.isCacheValid(goalId)) {
|
||||
return this.checkerCache[goalId].checker;
|
||||
}
|
||||
|
||||
let permissions = await this.goalPermissionsRepository.fetchPermissionsForGoal(goalId, this.user);
|
||||
|
||||
const tokenPerms = this.user.getTokenPermissions();
|
||||
if (tokenPerms && tokenPerms.length > 0) {
|
||||
permissions = permissions.filter(p => tokenPerms.includes(p.permissionName));
|
||||
}
|
||||
|
||||
this.checkerCache[goalId] = {
|
||||
checker: new GoalPermissionsChecker(
|
||||
await this.goalPermissionsRepository.fetchPermissionsForGoal(goalId, this.user)
|
||||
),
|
||||
checker: new GoalPermissionsChecker(permissions),
|
||||
timestamp: performance.now(),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { startJobQueue } from './JobQueue';
|
||||
import type { Dispatcher } from './Dispatcher';
|
||||
import { NotificationDispatcher } from '../tv-modules/notifications/NotificationDispatcher';
|
||||
import { RealtimeDispatcher } from '../tv-modules/realtime/RealtimeDispatcher';
|
||||
import { WebhooksDispatcher } from '../tv-modules/webhooks/WebhooksDispatcher';
|
||||
|
||||
const dispatchers: Dispatcher[] = [
|
||||
new NotificationDispatcher(),
|
||||
new RealtimeDispatcher(),
|
||||
new WebhooksDispatcher(),
|
||||
];
|
||||
|
||||
|
||||
@@ -2,20 +2,45 @@ import type { NextFunction, Request, Response } from 'express';
|
||||
import { AppUser } from '../core/AppUser';
|
||||
import { $logger } from '../modules/logget';
|
||||
import AuthController from '../tv-modules/auth/AuthController';
|
||||
import { getApiTokensManager } from '../tv-modules/api-tokens/ApiTokensManager';
|
||||
import { TOKEN_PREFIX } from '../tv-modules/api-tokens/types';
|
||||
|
||||
export const appUserMiddleware = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const token = req.headers['authorization']?.split(' ')[1];
|
||||
|
||||
if (token && token.startsWith(TOKEN_PREFIX)) {
|
||||
const record = await getApiTokensManager().validateToken(token);
|
||||
if (record) {
|
||||
const authManager = new AppUser().authManager;
|
||||
const userData = await authManager.repository.fetchUserById(record.userId);
|
||||
if (userData && userData.block === 0) {
|
||||
req.appUser = new AppUser({
|
||||
id: 0,
|
||||
userData: { id: userData.id, login: userData.login, email: userData.email },
|
||||
});
|
||||
req.appUser.setUserDataFromDb(userData);
|
||||
req.appUser.setHasActiveToken(true);
|
||||
req.appUser.setApiTokenAuth(record.allowedPermissions, record.allowedGoalIds);
|
||||
} else {
|
||||
req.appUser = new AppUser();
|
||||
}
|
||||
} else {
|
||||
req.appUser = new AppUser();
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
if (token) {
|
||||
const userPayload = await AuthController.validateTokens(token);
|
||||
if (userPayload) {
|
||||
req.appUser = new AppUser(userPayload);
|
||||
try {
|
||||
const [tokens, userData] = await Promise.allSettled([
|
||||
req.appUser.authManager.jwtStorage.fetchTokens(userPayload.id),
|
||||
const [sessionActive, userData] = await Promise.allSettled([
|
||||
req.appUser.authManager.sessionStorage.isSessionActive(userPayload.id),
|
||||
req.appUser.authManager.repository.fetchUserById(userPayload.userData.id),
|
||||
]);
|
||||
|
||||
if (tokens.status === 'fulfilled') {
|
||||
if (sessionActive.status === 'fulfilled' && sessionActive.value) {
|
||||
req.appUser.setHasActiveToken(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
export const IsOrgMemberIfProvided = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const organizationId = Number(
|
||||
req.query.organizationId || req.body.organizationId || req.params.organizationId
|
||||
)
|
||||
|
||||
if (!organizationId) return next()
|
||||
|
||||
const member = await req.appUser.organizationManager.getCurrentUserMember(organizationId)
|
||||
|
||||
if (!member) return res.status(403).end()
|
||||
|
||||
next()
|
||||
}
|
||||
@@ -372,5 +372,105 @@
|
||||
"description": [
|
||||
"Added notification_preferences table with JSONB settings"
|
||||
]
|
||||
},
|
||||
"30": {
|
||||
"version": "1.27.0",
|
||||
"name": "Release 1.27.0",
|
||||
"releaseDate": "20260322",
|
||||
"scripts": [
|
||||
"/1.27.0/0.1.27.0.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added webhooks and webhook_deliveries tables"
|
||||
]
|
||||
},
|
||||
"31": {
|
||||
"version": "1.28.0",
|
||||
"name": "Fix missing 1.25.0 migrations",
|
||||
"releaseDate": "20260323",
|
||||
"scripts": [
|
||||
"/1.28.0/0.fix-missing-1.25.0-migrations.sql"
|
||||
],
|
||||
"description": [
|
||||
"Fix: apply missing migrations from 1.25.0 - convert TIMETZ to TIME and add timezone column to device_tokens"
|
||||
]
|
||||
},
|
||||
"32": {
|
||||
"version": "1.29.0",
|
||||
"name": "Release 1.29.0",
|
||||
"releaseDate": "20260328",
|
||||
"scripts": [
|
||||
"/1.29.0/0.1.29.0.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added api_tokens table for personal access tokens"
|
||||
]
|
||||
},
|
||||
"33": {
|
||||
"version": "1.30.0",
|
||||
"name": "Release 1.30.0",
|
||||
"releaseDate": "20260328",
|
||||
"scripts": [
|
||||
"/1.30.0/0.1.30.0.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added allowed_goal_ids column to api_tokens for project-scoped tokens"
|
||||
]
|
||||
},
|
||||
"34": {
|
||||
"version": "1.31.0",
|
||||
"name": "Release 1.31.0",
|
||||
"releaseDate": "20260328",
|
||||
"scripts": [
|
||||
"/1.31.0/0.1.31.0.sql",
|
||||
"/1.31.0/all-triggers.sql"
|
||||
],
|
||||
"description": [
|
||||
"Remove JWT storage from user_tokens, add session metadata (device_name, user_agent, last_used_at)",
|
||||
"Add trigger to remove user from task assignees when removed from project collaboration"
|
||||
]
|
||||
},
|
||||
"35": {
|
||||
"version": "1.32.0",
|
||||
"name": "Release 1.32.0",
|
||||
"releaseDate": "20260404",
|
||||
"scripts": [
|
||||
"/1.32.0/0.1.32.0.sql",
|
||||
"/1.32.0/1.migrate-organizations.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added organizations and organization_members tables",
|
||||
"Migrate existing users to personal workspaces",
|
||||
"Added organization_id column to goals"
|
||||
]
|
||||
},
|
||||
"36": {
|
||||
"version": "1.33.0",
|
||||
"name": "Release 1.33.0",
|
||||
"releaseDate": "20260411",
|
||||
"scripts": [
|
||||
"/1.33.0/0.1.33.0.sql",
|
||||
"/1.33.0/1.add-saml-signing-fields.sql",
|
||||
"/1.33.0/2.add-saml-logout-url.sql",
|
||||
"/1.33.0/3.add-scim-fields.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added SSO support (SAML 2.0 + OIDC)",
|
||||
"Added sso_configs, sso_identities, saml_request_cache tables",
|
||||
"Added SAML AuthnRequest signing support",
|
||||
"Added SAML logout URL for SLO",
|
||||
"Added SCIM provisioning support"
|
||||
]
|
||||
},
|
||||
"37": {
|
||||
"version": "1.44.0",
|
||||
"name": "Release 1.44.0",
|
||||
"releaseDate": "20260418",
|
||||
"scripts": [
|
||||
"/1.44.0/0.create-missing-personal-orgs.sql"
|
||||
],
|
||||
"description": [
|
||||
"Create personal organizations for users without any organization membership"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Fix: these migrations were missing from 1.25.0 migrate.json scripts list
|
||||
|
||||
-- Convert TIMETZ columns to TIME (without timezone)
|
||||
-- Existing values are converted to UTC automatically by "AT TIME ZONE 'UTC'"
|
||||
ALTER TABLE tasks.tasks
|
||||
ALTER COLUMN start_time TYPE TIME USING start_time AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN end_time TYPE TIME USING end_time AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE tasks.device_tokens
|
||||
ADD COLUMN IF NOT EXISTS timezone VARCHAR(50) NOT NULL DEFAULT 'UTC';
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.api_tokens (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
allowed_permissions VARCHAR[] NOT NULL DEFAULT '{}',
|
||||
last_used_at TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON tv_auth.api_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON tv_auth.api_tokens(user_id);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE tv_auth.api_tokens ADD COLUMN IF NOT EXISTS allowed_goal_ids INTEGER[] NOT NULL DEFAULT '{}';
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE tv_auth.user_tokens
|
||||
DROP COLUMN IF EXISTS access_token,
|
||||
DROP COLUMN IF EXISTS refresh_token,
|
||||
ADD COLUMN IF NOT EXISTS device_name varchar(200),
|
||||
ADD COLUMN IF NOT EXISTS user_agent text,
|
||||
ADD COLUMN IF NOT EXISTS last_used_at timestamp;
|
||||
@@ -0,0 +1,610 @@
|
||||
--1.
|
||||
--Trigger set previous version
|
||||
create or replace function app.trigger_set_previous_version()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
new.prev_version = old.version;
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_previous_version on app.version;
|
||||
create trigger trigger_set_previous_version
|
||||
before insert
|
||||
on app.version
|
||||
for each row
|
||||
execute procedure app.trigger_set_previous_version();
|
||||
|
||||
--2.
|
||||
--Trigger for adding owner for taskList from goal
|
||||
create or replace function tasks.trigger_set_owner_for_component()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
new.owner = (select owner from tasks.goals where id = new.goal_id);
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_owner_for_component on tasks.goal_lists;
|
||||
create trigger trigger_set_owner_for_component
|
||||
before insert
|
||||
on tasks.goal_lists
|
||||
for each row
|
||||
execute procedure tasks.trigger_set_owner_for_component();
|
||||
|
||||
--3.
|
||||
--Trigger for updating date_complete for task
|
||||
create or replace function tasks.update_date_complete()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
if new.complete != old.complete
|
||||
then
|
||||
if new.complete = true
|
||||
then
|
||||
update tasks.tasks set date_complete = now() where id = old.id;
|
||||
else
|
||||
update tasks.tasks set date_complete = null where id = old.id;
|
||||
end if;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists tr_update_date_complete on tasks.tasks;
|
||||
create trigger tr_update_date_complete
|
||||
after update
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.update_date_complete();
|
||||
|
||||
--4.
|
||||
-- Delete user from collaboration if not assigned to any goal
|
||||
create or replace function collaboration.delete_user_if_not_assigned_to_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
count int;
|
||||
begin
|
||||
if not exists (
|
||||
select 1
|
||||
from collaboration.users_to_goals
|
||||
where user_id = old.user_id
|
||||
limit 1
|
||||
) then
|
||||
delete from collaboration.users where id = old.user_id;
|
||||
end if;
|
||||
|
||||
return old;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_delete_user_if_not_assigned_to_goal on collaboration.users_to_goals;
|
||||
create trigger trigger_delete_user_if_not_assigned_to_goal
|
||||
after delete
|
||||
on collaboration.users_to_goals
|
||||
for each row
|
||||
execute function collaboration.delete_user_if_not_assigned_to_goal();
|
||||
|
||||
--5.
|
||||
--Trigger for checking task graph relation goal to avoid connection between tasks from different goals
|
||||
create or replace function tasks.check_task_graph_relation_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
from_goal int;
|
||||
to_goal int;
|
||||
begin
|
||||
select goal_id into from_goal from tasks.tasks where id = new.from_task_id;
|
||||
select goal_id into to_goal from tasks.tasks where id = new.to_task_id;
|
||||
|
||||
if from_goal is null or to_goal is null then
|
||||
raise exception 'Invalid task reference in relation';
|
||||
end if;
|
||||
|
||||
if from_goal <> to_goal then
|
||||
raise exception 'Relation goal_id must match both tasks'' goal_id';
|
||||
end if;
|
||||
|
||||
new.goal_id := from_goal;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_task_relation_goal on tasks.task_relations;
|
||||
create trigger trigger_task_relation_goal
|
||||
before insert or update on tasks.task_relations
|
||||
for each row execute function tasks.check_task_graph_relation_goal();
|
||||
|
||||
--6.
|
||||
--Trigger for logging changes in taskList to history table
|
||||
create or replace function tasks.log_changes_tasks_goal_lists()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_goal_lists (goal_list_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_goal_lists (goal_list_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_goal_lists on tasks.goal_lists;
|
||||
create trigger trigger_log_changes_tasks_goal_lists
|
||||
before update or delete
|
||||
on tasks.goal_lists
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_goal_lists();
|
||||
|
||||
--7.
|
||||
--Trigger for logging changes in goal to history table
|
||||
create or replace function tasks.log_changes_tasks_goals()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_goals (goal_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_goals (goal_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_goals on tasks.goals;
|
||||
create trigger trigger_log_changes_tasks_goals
|
||||
before update or delete
|
||||
on tasks.goals
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_goals();
|
||||
|
||||
--8.
|
||||
--Trigger for logging changes in task to history table
|
||||
create or replace function tasks.log_changes_tasks_tasks()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_tasks (task_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_tasks (task_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_tasks on tasks.tasks;
|
||||
create trigger trigger_log_changes_tasks_tasks
|
||||
before update or delete
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_tasks();
|
||||
|
||||
--9.
|
||||
--Trigger for setting goal_id default for task
|
||||
CREATE OR REPLACE FUNCTION tasks.set_goal_id_default_for_task()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
goal_id INT;
|
||||
BEGIN
|
||||
|
||||
SELECT gl.goal_id
|
||||
INTO goal_id
|
||||
FROM tasks.goal_lists gl
|
||||
WHERE gl.id = NEW.goal_list_id;
|
||||
|
||||
IF goal_id IS NOT NULL THEN
|
||||
NEW.goal_id := goal_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists before_insert_set_goal_id_for_task on tasks.tasks;
|
||||
|
||||
CREATE TRIGGER before_insert_set_goal_id_for_task
|
||||
BEFORE INSERT OR UPDATE
|
||||
ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.set_goal_id_default_for_task();
|
||||
|
||||
|
||||
--10.
|
||||
--Trigger for adding default roles and permissions for goal
|
||||
CREATE OR REPLACE FUNCTION tasks.add_roles_and_permissions()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
editor_role_id INTEGER;
|
||||
executor_role_id INTEGER;
|
||||
BEGIN
|
||||
-- 1. Create role "editor"
|
||||
INSERT INTO collaboration.roles (name, goal_id)
|
||||
VALUES ('editor', NEW.id)
|
||||
RETURNING id INTO editor_role_id;
|
||||
|
||||
-- 2. Create role "executor"
|
||||
INSERT INTO collaboration.roles (name, goal_id)
|
||||
VALUES ('executor', NEW.id)
|
||||
RETURNING id INTO executor_role_id;
|
||||
|
||||
-- 3. Add permissions for role "editor"
|
||||
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
|
||||
SELECT editor_role_id, id
|
||||
FROM tv_auth.permissions
|
||||
WHERE name IN (
|
||||
'goal_can_watch_content',
|
||||
'goal_can_edit',
|
||||
'goal_can_add_task_list',
|
||||
'goal_can_manage_users',
|
||||
'component_can_watch_content',
|
||||
'component_can_edit',
|
||||
'component_can_delete',
|
||||
'component_can_add_tasks',
|
||||
'task_can_edit_deadline',
|
||||
'task_can_watch_subtasks',
|
||||
'task_can_watch_note',
|
||||
'task_can_recovery_history',
|
||||
'task_can_watch_assigned_users',
|
||||
'task_can_edit_priority',
|
||||
'task_can_delete',
|
||||
'task_can_watch_details',
|
||||
'task_can_assign_users',
|
||||
'task_can_add_subtasks',
|
||||
'task_can_watch_tags',
|
||||
'task_can_watch_priority',
|
||||
'task_can_access_history',
|
||||
'task_can_edit_tags',
|
||||
'task_can_edit_description',
|
||||
'task_can_edit_status',
|
||||
'task_can_edit_note',
|
||||
'kanban_can_manage',
|
||||
'kanban_can_view',
|
||||
'graph_can_manage',
|
||||
'graph_can_view'
|
||||
);
|
||||
|
||||
-- 4. Add permissions for role "viewver"
|
||||
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
|
||||
SELECT executor_role_id, id
|
||||
FROM tv_auth.permissions
|
||||
WHERE name IN (
|
||||
'goal_can_watch_content',
|
||||
'component_can_watch_content',
|
||||
'component_can_add_tasks',
|
||||
'task_can_watch_subtasks',
|
||||
'task_can_watch_note',
|
||||
'task_can_watch_assigned_users',
|
||||
'task_can_watch_details',
|
||||
'task_can_add_subtasks',
|
||||
'task_can_watch_tags',
|
||||
'task_can_watch_priority'
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
drop trigger if exists add_roles_after_insert on tasks.goals;
|
||||
|
||||
CREATE TRIGGER add_roles_after_insert
|
||||
AFTER INSERT
|
||||
ON tasks.goals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.add_roles_and_permissions();
|
||||
|
||||
|
||||
--11.
|
||||
--Trigger for adjusting start and end dates for task
|
||||
CREATE OR REPLACE FUNCTION tasks.adjust_start_and_end_dates()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
start_timestamp TIMESTAMPTZ;
|
||||
end_timestamp TIMESTAMPTZ;
|
||||
BEGIN
|
||||
-- If start_date is NULL, then start_time should be NULL
|
||||
IF NEW.start_date IS NULL THEN
|
||||
NEW.start_time := NULL;
|
||||
END IF;
|
||||
|
||||
-- If end_date is NULL, then end_time should be NULL
|
||||
IF NEW.end_date IS NULL THEN
|
||||
NEW.end_time := NULL;
|
||||
END IF;
|
||||
|
||||
-- If both dates are set
|
||||
IF NEW.start_date IS NOT NULL AND NEW.end_date IS NOT NULL THEN
|
||||
-- Adjust dates
|
||||
IF NEW.start_date > NEW.end_date THEN
|
||||
-- If start_date is greater than end_date, set end_date to start_date
|
||||
NEW.end_date := NEW.start_date;
|
||||
-- end_time remains unchanged
|
||||
ELSIF NEW.end_date < NEW.start_date THEN
|
||||
-- If end_date is less than start_date, set start_date to end_date
|
||||
NEW.start_date := NEW.end_date;
|
||||
-- start_time remains unchanged
|
||||
END IF;
|
||||
|
||||
-- Prepare timestamps for comparison
|
||||
start_timestamp := (NEW.start_date::text || ' ' || COALESCE(NEW.start_time::text, '00:00:00+00'))::timestamptz;
|
||||
end_timestamp := (NEW.end_date::text || ' ' || COALESCE(NEW.end_time::text, '00:00:00+00'))::timestamptz;
|
||||
|
||||
-- If start_timestamp is greater than end_timestamp, adjust end_date and end_time
|
||||
IF start_timestamp > end_timestamp THEN
|
||||
NEW.end_date := NEW.start_date;
|
||||
-- Assign end_time only if start_time is not NULL
|
||||
IF NEW.start_time IS NOT NULL AND NEW.end_time IS NOT NULL THEN
|
||||
NEW.end_time := NEW.start_time;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
drop trigger if exists adjust_dates_and_times_trigger on tasks.tasks;
|
||||
CREATE TRIGGER adjust_dates_and_times_trigger
|
||||
BEFORE INSERT OR UPDATE
|
||||
ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.adjust_start_and_end_dates();
|
||||
|
||||
--12.
|
||||
--Trigger for adding self/owner to collaboration table to be able to assign tasks to self
|
||||
create or replace function tasks.add_self_to_collaboration()
|
||||
returns trigger as $$
|
||||
DECLARE
|
||||
owner_email TEXT;
|
||||
BEGIN
|
||||
|
||||
select email into owner_email
|
||||
from tv_auth.users
|
||||
where id = NEW.owner;
|
||||
|
||||
if owner_email is not null then
|
||||
insert into collaboration.users (email) values (owner_email) ON CONFLICT (email) DO NOTHING;
|
||||
insert into collaboration.users_to_goals (goal_id, user_id) values (NEW.id, (select id from collaboration.users where email = owner_email));
|
||||
end if;
|
||||
|
||||
return NEW;
|
||||
END;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists add_selt_to_collaboration_trg on tasks.goals;
|
||||
|
||||
create trigger add_selt_to_collaboration_trg
|
||||
after insert on tasks.goals
|
||||
for each row
|
||||
execute function tasks.add_self_to_collaboration();
|
||||
|
||||
--13.
|
||||
--Trigger for adding default kanban columns for new goal
|
||||
CREATE OR REPLACE FUNCTION tasks.kanban_add_default_columns()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Add default columns for new goal
|
||||
INSERT INTO tasks.statuses (name, goal_id, view_order)
|
||||
VALUES
|
||||
('TODO', NEW.id, 1),
|
||||
('In Progress', NEW.id, 2),
|
||||
('Done', NEW.id, 3);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS kanban_add_default_columns_trg ON tasks.goals;
|
||||
|
||||
CREATE TRIGGER kanban_add_default_columns_trg
|
||||
AFTER INSERT ON tasks.goals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.kanban_add_default_columns();
|
||||
|
||||
--14.
|
||||
--Trigger for validating the correct statusId for the inserted value. To avoid assigning a status that does not belong to the goal.
|
||||
CREATE OR REPLACE FUNCTION tasks.check_task_status_goal()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Check if there is a record in tasks.statuses with the same goal_id
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM tasks.statuses s
|
||||
WHERE s.id = NEW.status_id AND s.goal_id = NEW.goal_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Status ID % is not valid for goal ID %', NEW.status_id, NEW.goal_id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists enforce_task_status_goal on tasks.tasks;
|
||||
|
||||
CREATE TRIGGER enforce_task_status_goal
|
||||
BEFORE INSERT OR UPDATE ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.status_id IS NOT NULL)
|
||||
EXECUTE FUNCTION tasks.check_task_status_goal();
|
||||
|
||||
--15.
|
||||
--Trigger for setting default orders value for task
|
||||
CREATE OR REPLACE FUNCTION tasks.set_order_value()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.task_order IS NULL THEN
|
||||
NEW.task_order := NEW.id;
|
||||
END IF;
|
||||
IF NEW.kanban_order IS NULL THEN
|
||||
NEW.kanban_order := NEW.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists set_order_trigger on tasks.tasks;
|
||||
CREATE TRIGGER set_order_trigger
|
||||
BEFORE INSERT ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.set_order_value();
|
||||
|
||||
--16.
|
||||
--Trigger for setting default view order for new status
|
||||
CREATE OR REPLACE FUNCTION tasks.status_set_default_view_order()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
new_view_order INT;
|
||||
BEGIN
|
||||
-- Determine the next view_order for the given goal_id
|
||||
SELECT COALESCE(MAX(view_order), 0) + 1 INTO new_view_order
|
||||
FROM tasks.statuses
|
||||
WHERE goal_id = NEW.goal_id;
|
||||
|
||||
-- Assign the calculated value to the view_order field
|
||||
NEW.view_order := new_view_order;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists set_default_status_view_order on tasks.statuses;
|
||||
|
||||
CREATE TRIGGER set_default_status_view_order
|
||||
BEFORE INSERT ON tasks.statuses
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.status_set_default_view_order();
|
||||
|
||||
--17.
|
||||
--Trigger for validating the correct user_id for the inserted value. To avoid assigning a user that does not belong to the goal.
|
||||
CREATE OR REPLACE FUNCTION tasks_auth.control_user_id_is_from_same_goal_as_task()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
user_exists BOOLEAN;
|
||||
BEGIN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM tasks.tasks tt
|
||||
LEFT JOIN collaboration.users_to_goals utg ON utg.goal_id = tt.goal_id
|
||||
WHERE tt.id = NEW.task_id AND utg.user_id = NEW.collab_user_id
|
||||
) INTO user_exists;
|
||||
|
||||
IF NOT user_exists THEN
|
||||
RAISE EXCEPTION 'User % is not associated with the goal of task %', NEW.collab_user_id, NEW.task_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists trigger_control_user_id_is_from_same_goal_as_task on tasks_auth.task_assignee;
|
||||
|
||||
CREATE TRIGGER trigger_control_user_id_is_from_same_goal_as_task
|
||||
BEFORE INSERT ON tasks_auth.task_assignee
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks_auth.control_user_id_is_from_same_goal_as_task();
|
||||
|
||||
--18.
|
||||
--Trigger for adding owner for task, extend owner from goal or taskList
|
||||
|
||||
--delete old function with wrong name
|
||||
drop trigger if exists trigger_set_owner_for_task on tasks.tasks;
|
||||
drop function if exists tasks.trigger_set_owner_for_task();
|
||||
|
||||
CREATE OR REPLACE FUNCTION tasks.fn_set_owner_for_task()
|
||||
RETURNS TRIGGER AS
|
||||
$body$
|
||||
BEGIN
|
||||
NEW.owner := COALESCE(
|
||||
(SELECT owner FROM tasks.goal_lists WHERE id = NEW.goal_list_id),
|
||||
(SELECT owner FROM tasks.goals WHERE id = NEW.goal_id)
|
||||
);
|
||||
|
||||
IF NEW.owner IS NULL THEN
|
||||
RAISE EXCEPTION 'Can not insert task without owner';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$body$
|
||||
LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_owner_for_task on tasks.tasks;
|
||||
create trigger trigger_set_owner_for_task
|
||||
before insert
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.fn_set_owner_for_task();
|
||||
|
||||
--19.
|
||||
--Trigger for validating that tag and task belong to the same project (goal_id)
|
||||
drop trigger if exists trigger_check_tag_task_same_goal on tasks.tasks_to_tags;
|
||||
drop function if exists tasks.check_tag_task_same_goal();
|
||||
|
||||
create or replace function tasks.check_tag_task_same_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
v_tag_goal_id integer;
|
||||
v_task_goal_id integer;
|
||||
begin
|
||||
select goal_id into v_tag_goal_id from tasks.tags where id = new.tag_id;
|
||||
select goal_id into v_task_goal_id from tasks.tasks where id = new.task_id;
|
||||
|
||||
if v_tag_goal_id is null or v_tag_goal_id != v_task_goal_id then
|
||||
raise exception 'Tag (id=%) and task (id=%) belong to different projects', new.tag_id, new.task_id;
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_check_tag_task_same_goal on tasks.tasks_to_tags;
|
||||
create trigger trigger_check_tag_task_same_goal
|
||||
before insert on tasks.tasks_to_tags
|
||||
for each row
|
||||
execute function tasks.check_tag_task_same_goal();
|
||||
|
||||
--20.
|
||||
-- Remove user from task assignees when removed from project collaboration
|
||||
|
||||
drop trigger if exists trigger_remove_user_from_task_assignees on collaboration.users_to_goals;
|
||||
drop function if exists collaboration.remove_user_from_task_assignees();
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION collaboration.remove_user_from_task_assignees()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM tasks_auth.task_assignee
|
||||
WHERE collab_user_id = OLD.user_id
|
||||
AND task_id IN (SELECT id FROM tasks.tasks WHERE goal_id = OLD.goal_id);
|
||||
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_remove_user_from_task_assignees ON collaboration.users_to_goals;
|
||||
|
||||
CREATE TRIGGER trigger_remove_user_from_task_assignees
|
||||
BEFORE DELETE
|
||||
ON collaboration.users_to_goals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION collaboration.remove_user_from_task_assignees();
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Organizations
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.organizations (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
name VARCHAR NOT NULL,
|
||||
slug VARCHAR NOT NULL UNIQUE,
|
||||
owner_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
logo_url VARCHAR,
|
||||
is_personal INTEGER NOT NULL DEFAULT 0,
|
||||
plan VARCHAR NOT NULL DEFAULT 'free',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_organizations_owner_id ON tv_auth.organizations(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_organizations_slug ON tv_auth.organizations(slug);
|
||||
|
||||
-- Organization members (by email, not user_id)
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.organization_members (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
|
||||
email VARCHAR NOT NULL,
|
||||
role VARCHAR NOT NULL DEFAULT 'member',
|
||||
invited_by INTEGER REFERENCES tv_auth.users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(organization_id, email)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_org_members_organization_id ON tv_auth.organization_members(organization_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_org_members_email ON tv_auth.organization_members(email);
|
||||
|
||||
-- Add organization_id to goals (nullable for migration, will be set NOT NULL after data migration)
|
||||
ALTER TABLE tasks.goals ADD COLUMN IF NOT EXISTS organization_id INTEGER;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'goals_organization_id_fkey'
|
||||
AND table_schema = 'tasks'
|
||||
AND table_name = 'goals'
|
||||
) THEN
|
||||
ALTER TABLE tasks.goals
|
||||
ADD CONSTRAINT goals_organization_id_fkey
|
||||
FOREIGN KEY (organization_id)
|
||||
REFERENCES tv_auth.organizations(id)
|
||||
ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_goals_organization_id ON tasks.goals(organization_id);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Step 1: Create personal organization for each user who owns goals
|
||||
INSERT INTO tv_auth.organizations (name, slug, owner_id, is_personal)
|
||||
SELECT
|
||||
u.login || '''s workspace',
|
||||
'org-' || substr(md5(random()::text), 1, 8),
|
||||
u.id,
|
||||
1
|
||||
FROM tv_auth.users u
|
||||
WHERE EXISTS (SELECT 1 FROM tasks.goals g WHERE g.owner = u.id)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Step 2: Link goals to their owner's organization
|
||||
UPDATE tasks.goals g
|
||||
SET organization_id = o.id
|
||||
FROM tv_auth.organizations o
|
||||
WHERE g.owner = o.owner_id
|
||||
AND g.organization_id IS NULL;
|
||||
|
||||
-- Step 3: Add owner as org member with role 'owner' (by email)
|
||||
INSERT INTO tv_auth.organization_members (organization_id, email, role)
|
||||
SELECT o.id, u.email, 'owner'
|
||||
FROM tv_auth.organizations o
|
||||
JOIN tv_auth.users u ON u.id = o.owner_id
|
||||
ON CONFLICT (organization_id, email) DO NOTHING;
|
||||
|
||||
-- Step 4: Add existing goal collaborators as org members (by email)
|
||||
INSERT INTO tv_auth.organization_members (organization_id, email, role)
|
||||
SELECT DISTINCT g.organization_id, cu.email, 'member'
|
||||
FROM collaboration.users_to_goals cutg
|
||||
JOIN collaboration.users cu ON cu.id = cutg.user_id
|
||||
JOIN tasks.goals g ON g.id = cutg.goal_id
|
||||
WHERE g.organization_id IS NOT NULL
|
||||
ON CONFLICT (organization_id, email) DO NOTHING;
|
||||
|
||||
-- Step 5: Make organization_id NOT NULL
|
||||
ALTER TABLE tasks.goals ALTER COLUMN organization_id SET NOT NULL;
|
||||
@@ -0,0 +1,45 @@
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.sso_configs (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
|
||||
protocol VARCHAR NOT NULL,
|
||||
display_name VARCHAR NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
saml_entry_point VARCHAR,
|
||||
saml_issuer VARCHAR,
|
||||
saml_cert TEXT,
|
||||
saml_callback_url VARCHAR,
|
||||
|
||||
oidc_issuer VARCHAR,
|
||||
oidc_client_id VARCHAR,
|
||||
oidc_client_secret VARCHAR,
|
||||
oidc_callback_url VARCHAR,
|
||||
oidc_scope VARCHAR,
|
||||
|
||||
default_org_role VARCHAR NOT NULL DEFAULT 'member',
|
||||
email_domain_restriction VARCHAR NOT NULL,
|
||||
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(email_domain_restriction)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.sso_identities (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
sso_config_id INTEGER NOT NULL REFERENCES tv_auth.sso_configs(id) ON DELETE CASCADE,
|
||||
external_id VARCHAR NOT NULL,
|
||||
email VARCHAR NOT NULL,
|
||||
last_login_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(sso_config_id, external_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sso_identities_user_id ON tv_auth.sso_identities(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sso_identities_email ON tv_auth.sso_identities(email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.saml_request_cache (
|
||||
key VARCHAR PRIMARY KEY,
|
||||
value VARCHAR NOT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS saml_signing_key TEXT;
|
||||
ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS saml_signing_cert TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS saml_logout_url VARCHAR;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS scim_token VARCHAR;
|
||||
ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS scim_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Create personal organizations for users who don't have any organization membership
|
||||
-- This covers the default seed user (login: 'user', email: 'test@mail.dest')
|
||||
-- and any other users who may exist without an organization
|
||||
INSERT INTO tv_auth.organizations (name, slug, owner_id, is_personal)
|
||||
SELECT
|
||||
u.login || '''s workspace',
|
||||
'org-' || substr(md5(random()::text), 1, 8),
|
||||
u.id,
|
||||
1
|
||||
FROM tv_auth.users u
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM tv_auth.organization_members om WHERE om.email = u.email
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Add these users as owners of their new personal organizations
|
||||
INSERT INTO tv_auth.organization_members (organization_id, email, role)
|
||||
SELECT o.id, u.email, 'owner'
|
||||
FROM tv_auth.organizations o
|
||||
JOIN tv_auth.users u ON u.id = o.owner_id
|
||||
WHERE o.is_personal = 1
|
||||
ON CONFLICT (organization_id, email) DO NOTHING;
|
||||
@@ -6,11 +6,16 @@ import GraphRoutes from '../tv-modules/graph/GraphRoutes';
|
||||
import IntegrationsRoutes from '../tv-modules/integrations/IntegrationsRoutes';
|
||||
import NotificationsRoutes from '../tv-modules/notifications/NotificationsRoutes';
|
||||
import WebhooksRoutes from '../tv-modules/webhooks/WebhooksRoutes';
|
||||
import ApiTokensRoutes from '../tv-modules/api-tokens/ApiTokensRoutes';
|
||||
import SessionsRoutes from '../tv-modules/sessions/SessionsRoutes';
|
||||
import KanbanRoutes from '../tv-modules/kanban/KanbanRoutes';
|
||||
import GoalListRoutes from '../tv-modules/lists/GoalListRoutes';
|
||||
import StartRoutes from '../tv-modules/start/StartRoutes';
|
||||
import TagsRouter from '../tv-modules/tags/TagsRouter';
|
||||
import TasksRoutes from '../tv-modules/tasks/TasksRoutes';
|
||||
import OrganizationRoutes from '../tv-modules/organizations/OrganizationRoutes';
|
||||
import SsoRoutes from '../tv-modules/sso/SsoRoutes';
|
||||
import ScimRoutes from '../tv-modules/scim/ScimRoutes';
|
||||
import type { Routable } from '../types/routable.type';
|
||||
|
||||
type RoutableConstructor = new (...args: any[]) => Routable;
|
||||
@@ -29,6 +34,11 @@ const routes: Record<string, RoutableConstructor> = {
|
||||
'/module/integrations': IntegrationsRoutes,
|
||||
'/module/notifications': NotificationsRoutes,
|
||||
'/module/webhooks': WebhooksRoutes,
|
||||
'/module/api-tokens': ApiTokensRoutes,
|
||||
'/module/sessions': SessionsRoutes,
|
||||
'/module/organizations': OrganizationRoutes,
|
||||
'/module/sso': SsoRoutes,
|
||||
'/scim/v2': ScimRoutes,
|
||||
};
|
||||
|
||||
export default routes;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { ArkErrors } from 'arktype';
|
||||
import { getApiTokensManager } from './ApiTokensManager';
|
||||
import { ApiTokenArkTypeCreate, ApiTokenArkTypeDelete } from './types';
|
||||
import { Database } from '../../modules/db';
|
||||
|
||||
export class ApiTokensController {
|
||||
private get manager() { return getApiTokensManager(); }
|
||||
|
||||
create = async (req: Request, res: Response) => {
|
||||
const data = ApiTokenArkTypeCreate(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).end();
|
||||
|
||||
const result = await this.manager.create(userId, data);
|
||||
if (!result) return res.status(500).end();
|
||||
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
delete = async (req: Request, res: Response) => {
|
||||
const data = ApiTokenArkTypeDelete(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).end();
|
||||
|
||||
const result = await this.manager.delete(data.id, userId);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
fetch = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).end();
|
||||
|
||||
const result = await this.manager.fetchAll(userId);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
fetchPermissions = async (_req: Request, res: Response) => {
|
||||
const db = Database.getInstance();
|
||||
const result = await db.query<{ id: number; name: string; description: string; permissionGroup: number }>(
|
||||
`SELECT id, name, description, permission_group as "permissionGroup" FROM tv_auth.permissions WHERE permission_group <> 1 ORDER BY permission_group, id`
|
||||
);
|
||||
return res.tvJson(result?.rows ?? []);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { ApiTokensRepository } from './ApiTokensRepository';
|
||||
import { TOKEN_PREFIX, type ApiTokenArgCreate } from './types';
|
||||
import type { ApiTokensSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
|
||||
export type ApiTokenForClient = Omit<ApiTokensSchemaTypeForSelect, 'tokenHash'>;
|
||||
|
||||
export class ApiTokensManager {
|
||||
public readonly repository: ApiTokensRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new ApiTokensRepository();
|
||||
}
|
||||
|
||||
async create(userId: number, data: ApiTokenArgCreate): Promise<{ token: string; item: ApiTokenForClient } | null> {
|
||||
const raw = randomBytes(32).toString('hex');
|
||||
const fullToken = TOKEN_PREFIX + raw;
|
||||
const tokenHash = createHash('sha256').update(fullToken).digest('hex');
|
||||
|
||||
const expiresAt = data.expiresAt ? new Date(data.expiresAt) : null;
|
||||
|
||||
const record = await this.repository.create({
|
||||
userId,
|
||||
name: data.name,
|
||||
tokenHash,
|
||||
allowedPermissions: data.allowedPermissions ?? [],
|
||||
allowedGoalIds: data.allowedGoalIds ?? [],
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
return { token: fullToken, item: this.toClient(record) };
|
||||
}
|
||||
|
||||
async delete(id: number, userId: number): Promise<boolean> {
|
||||
return this.repository.delete(id, userId);
|
||||
}
|
||||
|
||||
async fetchAll(userId: number): Promise<ApiTokenForClient[]> {
|
||||
const tokens = await this.repository.fetchByUserId(userId);
|
||||
return tokens.map((t) => this.toClient(t));
|
||||
}
|
||||
|
||||
async validateToken(fullToken: string): Promise<ApiTokensSchemaTypeForSelect | null> {
|
||||
const tokenHash = createHash('sha256').update(fullToken).digest('hex');
|
||||
const record = await this.repository.findByTokenHash(tokenHash);
|
||||
|
||||
if (!record) return null;
|
||||
if (record.expiresAt && record.expiresAt < new Date()) return null;
|
||||
|
||||
this.repository.updateLastUsedAt(record.id).catch(() => {});
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
private toClient(token: ApiTokensSchemaTypeForSelect): ApiTokenForClient {
|
||||
const { tokenHash, ...rest } = token;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: ApiTokensManager | null = null;
|
||||
|
||||
export function getApiTokensManager(): ApiTokensManager {
|
||||
if (!_instance) _instance = new ApiTokensManager();
|
||||
return _instance;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { ApiTokensSchema, type ApiTokensSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { Database } from '../../modules/db';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
|
||||
export class ApiTokensRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async create(data: { userId: number; name: string; tokenHash: string; allowedPermissions: string[]; allowedGoalIds: number[]; expiresAt: Date | null }): Promise<ApiTokensSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(ApiTokensSchema).values(data).returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async delete(id: number, userId: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(ApiTokensSchema).where(
|
||||
and(eq(ApiTokensSchema.id, id), eq(ApiTokensSchema.userId, userId))
|
||||
)
|
||||
);
|
||||
return !!result?.rowCount;
|
||||
}
|
||||
|
||||
async fetchByUserId(userId: number): Promise<ApiTokensSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(ApiTokensSchema).where(eq(ApiTokensSchema.userId, userId))
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async findByTokenHash(tokenHash: string): Promise<ApiTokensSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(ApiTokensSchema).where(eq(ApiTokensSchema.tokenHash, tokenHash))
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async updateLastUsedAt(id: number): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(ApiTokensSchema)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(ApiTokensSchema.id, id))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import { ApiTokensController } from './ApiTokensController';
|
||||
import { RejectApiTokenAuth } from './middlewares/RejectApiTokenAuth';
|
||||
|
||||
export default class ApiTokensRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly controller: ApiTokensController;
|
||||
|
||||
constructor() {
|
||||
this.router = Router();
|
||||
this.controller = new ApiTokensController();
|
||||
this.initRoutes();
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router;
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('', [IsLoggedIn, RejectApiTokenAuth], this.controller.fetch);
|
||||
this.router.post('', [IsLoggedIn, RejectApiTokenAuth], this.controller.create);
|
||||
this.router.delete('', [IsLoggedIn, RejectApiTokenAuth], this.controller.delete);
|
||||
this.router.get('/permissions', [IsLoggedIn, RejectApiTokenAuth], this.controller.fetchPermissions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
export const RejectApiTokenAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.appUser.isApiTokenAuth()) {
|
||||
return res.status(403).end();
|
||||
}
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { type } from 'arktype';
|
||||
|
||||
export const ApiTokenArkTypeCreate = type({
|
||||
name: 'string',
|
||||
'allowedPermissions?': 'string[]',
|
||||
'allowedGoalIds?': 'number[]',
|
||||
'expiresAt?': 'string|null',
|
||||
});
|
||||
|
||||
export type ApiTokenArgCreate = typeof ApiTokenArkTypeCreate.infer;
|
||||
|
||||
export const ApiTokenArkTypeDelete = type({
|
||||
id: 'number',
|
||||
});
|
||||
|
||||
export type ApiTokenArgDelete = typeof ApiTokenArkTypeDelete.infer;
|
||||
|
||||
export const TOKEN_PREFIX = 'tvk_';
|
||||
@@ -1,4 +1,5 @@
|
||||
import { compare, hashSync } from 'bcryptjs';
|
||||
import { randomInt } from 'crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
import jwt, { type Algorithm, decode } from 'jsonwebtoken';
|
||||
import { z } from 'zod';
|
||||
@@ -17,6 +18,7 @@ import { generateString, isEmail, time } from '../../utils/helpers';
|
||||
import EnEmailTemplate from './mail/confirm-email-en';
|
||||
import RuEmailTemplate from './mail/confirm-email-ru';
|
||||
import type { ExternalAuthUser } from './strategies/external-auth.types';
|
||||
import { OrganizationRepository } from '../organizations/OrganizationRepository';
|
||||
|
||||
export default class AuthController {
|
||||
private readonly jwtAlg: Algorithm = process.env.JWT_ALG as Algorithm;
|
||||
@@ -24,6 +26,15 @@ export default class AuthController {
|
||||
private readonly jwtRefreshExp: string = process.env.REFRESH_LIFE_TIME!;
|
||||
|
||||
private readonly refreshTokenCookieName: string = 'taskview-refresh';
|
||||
private readonly orgRepository: OrganizationRepository = new OrganizationRepository();
|
||||
|
||||
private async createPersonalWorkspace(userId: number, email: string, login: string) {
|
||||
const slug = `org-${crypto.randomUUID().slice(0, 8)}`
|
||||
const org = await this.orgRepository.create({ name: `${login}'s workspace`, slug }, userId, true)
|
||||
if (org) {
|
||||
await this.orgRepository.addMember(org.id, email, 'owner')
|
||||
}
|
||||
}
|
||||
|
||||
comparePasswords(pwd: string, hash: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -79,15 +90,13 @@ export default class AuthController {
|
||||
}
|
||||
|
||||
makeidLogin(length: number) {
|
||||
let result = '';
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const charactersLength = characters.length;
|
||||
let counter = 0;
|
||||
while (counter < length) {
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||
counter += 1;
|
||||
let result = ''
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
const charactersLength = characters.length
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(randomInt(charactersLength))
|
||||
}
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
|
||||
generateEmailConfirmCode() {
|
||||
@@ -118,7 +127,6 @@ export default class AuthController {
|
||||
|
||||
const code = this.generateLoginCode();
|
||||
|
||||
$logger.info(data.data, `[AuthController:sendLoginCode] we got data for send login code`);
|
||||
|
||||
let userData = await req.appUser.authManager.repository.getUserByLogin(email, isEmail(email));
|
||||
|
||||
@@ -129,8 +137,6 @@ export default class AuthController {
|
||||
}
|
||||
|
||||
if (!userData) {
|
||||
$logger.info(`[AuthController:sendLoginCode] trying to register user ${email}`);
|
||||
|
||||
const password = this.makeidLogin(7),
|
||||
login = this.makeidLogin(7);
|
||||
|
||||
@@ -142,17 +148,18 @@ export default class AuthController {
|
||||
confirmEmailCode: '',
|
||||
});
|
||||
if (!id) {
|
||||
$logger.error(`Can not register user ${email}`);
|
||||
$logger.error(`Can not register user`);
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
$logger.info(`[AuthController:sendLoginCode] user registered ${email}`);
|
||||
await this.createPersonalWorkspace(id, email, login)
|
||||
$logger.info(`[AuthController:sendLoginCode] user registered`);
|
||||
}
|
||||
|
||||
userData = await req.appUser.authManager.repository.getUserByLogin(email, isEmail(email));
|
||||
|
||||
if (!userData) {
|
||||
$logger.error(`Can not fetch user after registration by code ${email}`);
|
||||
$logger.error(`Can not fetch user after registration by code`);
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
@@ -160,11 +167,11 @@ export default class AuthController {
|
||||
const now = Date.now();
|
||||
|
||||
if (!lastUpdate || (lastUpdate && now - +lastUpdate > 60 * 1000)) {
|
||||
$logger.info(`[AuthController:sendLoginCode] updating login code for user ${email}`);
|
||||
$logger.info(`[AuthController:sendLoginCode] updating login code for user`);
|
||||
|
||||
await req.appUser.authManager.repository.updateLoginCode(code, email);
|
||||
|
||||
$logger.info(`[AuthController:sendLoginCode] sending code by email to ${email}`);
|
||||
$logger.info(`[AuthController:sendLoginCode] sending code by email to`);
|
||||
|
||||
await this.sendCodeByEmail(code.split(':')[0], email);
|
||||
}
|
||||
@@ -206,10 +213,12 @@ export default class AuthController {
|
||||
});
|
||||
|
||||
if (!id) {
|
||||
$logger.error(`Can not register user ${user.email} & login ${login}`);
|
||||
return res.status(500).send(`Can not register user ${user.email} & login ${login}`);
|
||||
$logger.error(`Can not register user`);
|
||||
return res.status(500).send(`Can not register user`);
|
||||
}
|
||||
|
||||
await this.createPersonalWorkspace(id, user.email, login)
|
||||
|
||||
userData = await req.appUser.authManager.repository.getUserByLogin(
|
||||
user.email,
|
||||
isEmail(user.email)
|
||||
@@ -217,7 +226,7 @@ export default class AuthController {
|
||||
}
|
||||
|
||||
if (!userData) {
|
||||
$logger.error(`Can not find user ${user.email} after registration`);
|
||||
$logger.error(`Can not find user after registration`);
|
||||
return res.status(500).send(`Can not find user ${user.email} after registration`);
|
||||
}
|
||||
|
||||
@@ -226,7 +235,7 @@ export default class AuthController {
|
||||
const result = await req.appUser.authManager.repository.updateLoginCode(code, userData.email);
|
||||
|
||||
if (!result) {
|
||||
$logger.error(`Can not update login code for user ${userData.email}`);
|
||||
$logger.error(`Can not update login code for user`);
|
||||
return res.status(500).send(`Can not update login code for user`);
|
||||
}
|
||||
|
||||
@@ -252,12 +261,35 @@ export default class AuthController {
|
||||
return res.redirect(`${process.env.APP_URL}/login?tokens=${encodedAuthData}`);
|
||||
}
|
||||
|
||||
private parseLifetimeToMs(lifetime: string): number {
|
||||
const match = lifetime.match(/^(\d+)([smhdw])$/)
|
||||
if (!match) return 1000 * 60 * 60 * 24 * 30
|
||||
const value = parseInt(match[1])
|
||||
const unit = match[2]
|
||||
const multipliers: Record<string, number> = {
|
||||
s: 1_000,
|
||||
m: 60_000,
|
||||
h: 3_600_000,
|
||||
d: 86_400_000,
|
||||
w: 604_800_000,
|
||||
}
|
||||
return value * (multipliers[unit] || 86_400_000)
|
||||
}
|
||||
|
||||
setRefreshToken = async (res: Response, refreshToken: string) => {
|
||||
res.cookie(this.refreshTokenCookieName, refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "none",
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30,
|
||||
maxAge: this.parseLifetimeToMs(this.jwtRefreshExp),
|
||||
});
|
||||
}
|
||||
|
||||
clearRefreshToken = (res: Response) => {
|
||||
res.clearCookie(this.refreshTokenCookieName, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "none",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -296,28 +328,23 @@ export default class AuthController {
|
||||
return res.status(400).send({ message: 'Code expired, get new code' });
|
||||
}
|
||||
|
||||
const tokenRowId = await req.appUser.authManager.jwtStorage.initTokenRecord(userData.id);
|
||||
if (!tokenRowId) {
|
||||
// Invalidate code immediately to prevent replay attacks
|
||||
await req.appUser.authManager.repository.updateLoginCode(null, userData.email);
|
||||
|
||||
const sessionId = await req.appUser.authManager.sessionStorage.createSession(
|
||||
userData.id,
|
||||
req.ip,
|
||||
req.headers['user-agent']
|
||||
);
|
||||
if (!sessionId) {
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
const tokens = this.getTokens({
|
||||
id: tokenRowId,
|
||||
id: sessionId,
|
||||
userData,
|
||||
} as const);
|
||||
|
||||
const updateResult = await req.appUser.authManager.jwtStorage.updateTokens(
|
||||
tokens.access,
|
||||
tokens.refresh,
|
||||
tokenRowId
|
||||
);
|
||||
|
||||
if (!updateResult) {
|
||||
$logger.error(`Can not update tokens in JWT Storage for user ${userData.id} and rowId ${tokenRowId}`);
|
||||
}
|
||||
|
||||
await req.appUser.authManager.repository.updateLoginCode(null, userData.email);
|
||||
|
||||
await this.setRefreshToken(res, tokens.refresh);
|
||||
|
||||
return res.json(tokens);
|
||||
@@ -335,7 +362,6 @@ export default class AuthController {
|
||||
const userData = await req.appUser.authManager.repository.getUserByLogin(login, isEmail(login));
|
||||
|
||||
if (!userData) {
|
||||
$logger.info(`Can not find user with login ${login}`);
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
@@ -345,26 +371,20 @@ export default class AuthController {
|
||||
|
||||
const valid = await this.comparePasswords(password, userData.password);
|
||||
if (valid) {
|
||||
const tokenRowId = await req.appUser.authManager.jwtStorage.initTokenRecord(userData.id);
|
||||
if (!tokenRowId) {
|
||||
const sessionId = await req.appUser.authManager.sessionStorage.createSession(
|
||||
userData.id,
|
||||
req.ip,
|
||||
req.headers['user-agent']
|
||||
);
|
||||
if (!sessionId) {
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
const tokens = this.getTokens({
|
||||
id: tokenRowId,
|
||||
id: sessionId,
|
||||
userData,
|
||||
} as const);
|
||||
|
||||
const updateResult = await req.appUser.authManager.jwtStorage.updateTokens(
|
||||
tokens.access,
|
||||
tokens.refresh,
|
||||
tokenRowId
|
||||
);
|
||||
|
||||
if (!updateResult) {
|
||||
$logger.error(`Can not update tokens in JWT Storage for user ${userData.id} and rowId ${tokenRowId}`);
|
||||
}
|
||||
|
||||
await this.setRefreshToken(res, tokens.refresh);
|
||||
|
||||
return res.json(tokens);
|
||||
@@ -381,7 +401,7 @@ export default class AuthController {
|
||||
|
||||
email = (email as string).toLowerCase();
|
||||
if (!isEmail(email)) {
|
||||
return res.status(40).end();
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
password = hashSync(password, 10);
|
||||
@@ -404,6 +424,8 @@ export default class AuthController {
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
await this.createPersonalWorkspace(id, email, login)
|
||||
|
||||
let emailTemplate: string = '';
|
||||
let confirmEmailBody: string = '';
|
||||
const acceptLanguage = req.headers['accept-language'];
|
||||
@@ -416,7 +438,7 @@ export default class AuthController {
|
||||
emailTemplate = EnEmailTemplate;
|
||||
}
|
||||
|
||||
const confirmUrl = `https://${process.env.APP_URL}/module/auth/confirm/email/${confirmEmailCode}/login/${login}`;
|
||||
const confirmUrl = `${process.env.APP_URL}/module/auth/confirm/email/${confirmEmailCode}/login/${login}`;
|
||||
|
||||
if (emailTemplate) {
|
||||
confirmEmailBody = emailTemplate.replace('{link}', confirmUrl);
|
||||
@@ -501,7 +523,7 @@ export default class AuthController {
|
||||
const result = await req.appUser.authManager.repository.setReminderCodeAndTime(userData.email, code, seconds);
|
||||
|
||||
if (!result) {
|
||||
$logger.error(`Can not set remind_code and time for user ${userData.email}`);
|
||||
$logger.error(`Can not set remind_code and time for user`);
|
||||
return res.status(500).send();
|
||||
}
|
||||
|
||||
@@ -551,8 +573,6 @@ export default class AuthController {
|
||||
|
||||
const passwordHash = hashSync(parsedData.data.password, 10);
|
||||
|
||||
$logger.debug(`Update ${passwordHash} for ${userData.id}`);
|
||||
|
||||
const result = await req.appUser.authManager.repository.updateUserPassword(passwordHash, userData.id);
|
||||
|
||||
$logger.debug(`Update result ${result}`);
|
||||
@@ -564,24 +584,19 @@ export default class AuthController {
|
||||
};
|
||||
|
||||
logout = async (req: Request, res: Response) => {
|
||||
this.setRefreshToken(res, '');
|
||||
this.clearRefreshToken(res);
|
||||
|
||||
const result = req.headers['authorization']?.match(/Bearer\s(\S+)/);
|
||||
const sessionId = req.appUser.getTokenId();
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
|
||||
if (!result) {
|
||||
if (!sessionId || !userId) {
|
||||
return res.status(401).send({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const tokenId = req.appUser.getTokenId();
|
||||
|
||||
if (!tokenId) {
|
||||
return res.status(401).send({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const deleteResult = await req.appUser.authManager.jwtStorage.deleteTokens(tokenId, result['1']);
|
||||
const deleteResult = await req.appUser.authManager.sessionStorage.deleteSession(sessionId, userId);
|
||||
|
||||
if (!deleteResult) {
|
||||
return res.status(500).send({ message: 'Failed to revoke token' });
|
||||
return res.status(500).send({ message: 'Failed to delete session' });
|
||||
}
|
||||
|
||||
return res.status(204).end();
|
||||
@@ -606,21 +621,20 @@ export default class AuthController {
|
||||
const payload = await AuthController.validateTokens(refreshToken);
|
||||
|
||||
if (!payload) {
|
||||
$logger.info('Refresh token validation failed');
|
||||
this.clearRefreshToken(res);
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const isActive = await req.appUser.authManager.sessionStorage.isSessionActive(payload.id);
|
||||
if (!isActive) {
|
||||
this.clearRefreshToken(res);
|
||||
return res.status(401).end();
|
||||
}
|
||||
|
||||
const newTokens = this.getTokens(payload);
|
||||
|
||||
const update = await req.appUser.authManager.jwtStorage.updateTokens(
|
||||
newTokens.access,
|
||||
newTokens.refresh,
|
||||
payload.id
|
||||
);
|
||||
|
||||
if (!update) {
|
||||
$logger.error(`Can not refresh tokens for ${payload}`);
|
||||
return res.status(500).end();
|
||||
}
|
||||
await req.appUser.authManager.sessionStorage.updateLastUsed(payload.id);
|
||||
|
||||
await this.setRefreshToken(res, newTokens.refresh);
|
||||
|
||||
@@ -644,12 +658,12 @@ export default class AuthController {
|
||||
});
|
||||
|
||||
if (!sendResult) {
|
||||
$logger.error(`Can not send account deletion code for user ${userId}`);
|
||||
$logger.error(`Can not send account deletion code for user`);
|
||||
}
|
||||
const insertCode = await req.appUser.authManager.repository.addDeleteAccountCode(code, userId);
|
||||
|
||||
if (!insertCode) {
|
||||
$logger.error(`Can not insert account deletion code for user ${userId}`);
|
||||
$logger.error(`Can not insert account deletion code for user`);
|
||||
return res.status(500).end();
|
||||
}
|
||||
return res.status(200).end();
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { AppUser } from '../../core/AppUser';
|
||||
import AuthModel from './AuthModel';
|
||||
import JwtStorage from './JwtStorage';
|
||||
import SessionStorage from './SessionStorage';
|
||||
|
||||
export class AuthManager {
|
||||
protected readonly user: AppUser;
|
||||
public readonly repository: AuthModel;
|
||||
public readonly jwtStorage: JwtStorage;
|
||||
public readonly sessionStorage: SessionStorage;
|
||||
|
||||
constructor(user: AppUser) {
|
||||
this.user = user;
|
||||
this.repository = new AuthModel();
|
||||
this.jwtStorage = new JwtStorage();
|
||||
this.sessionStorage = new SessionStorage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Database } from '../../modules/db';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import type { TokensFromDb } from '../../types/auth.types';
|
||||
|
||||
export default class JwtStorage {
|
||||
private db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async initTokenRecord(userId: number): Promise<number | false> {
|
||||
try {
|
||||
const data = await this.db.query<{ id: number }>(
|
||||
'INSERT INTO tv_auth.user_tokens (user_id) VALUES ($1) RETURNING id;',
|
||||
[userId]
|
||||
);
|
||||
if (data?.rows && data.rows.length > 0) {
|
||||
return data.rows[0].id;
|
||||
}
|
||||
return false;
|
||||
} catch (error: any) {
|
||||
$logger.error({
|
||||
userId,
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack,
|
||||
}, 'Can not complete initTokenRecord');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateTokens(accessToken: string, refreshToken: string, rowId: number): Promise<boolean> {
|
||||
const query = `
|
||||
UPDATE tv_auth.user_tokens
|
||||
SET access_token = $1, refresh_token = $2
|
||||
WHERE id = $3;
|
||||
`;
|
||||
|
||||
try {
|
||||
const res = await this.db.query(query, [accessToken, refreshToken, rowId]);
|
||||
return !!(res.rowCount && res.rowCount > 0);
|
||||
} catch (error: any) {
|
||||
$logger.error({ errorMessage: error.message, errorStack: error.stack }, 'Error updating tokens:');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchTokens(rowId: number): Promise<TokensFromDb | false> {
|
||||
const query = 'SELECT * FROM tv_auth.user_tokens WHERE id = $1;';
|
||||
try {
|
||||
const res = await this.db.query<TokensFromDb>(query, [rowId]);
|
||||
if (res?.rows && res.rows.length > 0) {
|
||||
return res.rows[0];
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error: any) {
|
||||
$logger.error({
|
||||
rowId,
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack,
|
||||
}, 'Error fetching tokens');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTokens(userId: number, accessToken: string): Promise<boolean> {
|
||||
try {
|
||||
const query = 'DELETE FROM tv_auth.user_tokens WHERE user_id = $1 AND access_token = $2;';
|
||||
await this.db.query(query, [userId, accessToken]);
|
||||
return true;
|
||||
} catch (_error: any) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { and, eq, ne } from 'drizzle-orm'
|
||||
import { UserTokensSchema } from 'taskview-db-schemas'
|
||||
import { Database } from '../../modules/db'
|
||||
import { callWithCatch, parseDeviceName } from '../../utils/helpers'
|
||||
|
||||
export default class SessionStorage {
|
||||
private readonly db: Database
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance()
|
||||
}
|
||||
|
||||
async createSession(userId: number, ip: string | undefined, userAgent: string | undefined): Promise<number | false> {
|
||||
const deviceName = parseDeviceName(userAgent)
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(UserTokensSchema).values({
|
||||
userId,
|
||||
userIp: ip || null,
|
||||
deviceName,
|
||||
userAgent: userAgent || null,
|
||||
lastUsedAt: new Date(),
|
||||
}).returning({ id: UserTokensSchema.id })
|
||||
)
|
||||
return result?.[0]?.id ?? false
|
||||
}
|
||||
|
||||
async isSessionActive(sessionId: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ id: UserTokensSchema.id })
|
||||
.from(UserTokensSchema)
|
||||
.where(eq(UserTokensSchema.id, sessionId))
|
||||
)
|
||||
return !!(result && result.length > 0)
|
||||
}
|
||||
|
||||
async updateLastUsed(sessionId: number): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(UserTokensSchema)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(UserTokensSchema.id, sessionId))
|
||||
)
|
||||
}
|
||||
|
||||
async deleteSession(sessionId: number, userId: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(UserTokensSchema)
|
||||
.where(and(eq(UserTokensSchema.id, sessionId), eq(UserTokensSchema.userId, userId)))
|
||||
)
|
||||
return !!result?.rowCount
|
||||
}
|
||||
|
||||
async deleteAllSessions(userId: number, excludeSessionId?: number): Promise<boolean> {
|
||||
if (excludeSessionId) {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(UserTokensSchema)
|
||||
.where(and(
|
||||
eq(UserTokensSchema.userId, userId),
|
||||
ne(UserTokensSchema.id, excludeSessionId)
|
||||
))
|
||||
)
|
||||
return !!result
|
||||
}
|
||||
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(UserTokensSchema)
|
||||
.where(eq(UserTokensSchema.userId, userId))
|
||||
)
|
||||
return !!result
|
||||
}
|
||||
|
||||
async fetchUserSessions(userId: number) {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select()
|
||||
.from(UserTokensSchema)
|
||||
.where(eq(UserTokensSchema.userId, userId))
|
||||
.orderBy(UserTokensSchema.lastUsedAt)
|
||||
)
|
||||
return result ?? []
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { Database } from '../../../modules/db';
|
||||
import type { UserJwtPayload } from '../../../types/auth.types';
|
||||
import { delay } from '../../../utils/helpers';
|
||||
import AuthModel from '../AuthModel';
|
||||
import JwtStorage from '../JwtStorage';
|
||||
import JwtStorage from '../SessionStorage';
|
||||
|
||||
const port = 1809;
|
||||
const url = `http://localhost:${port}`;
|
||||
@@ -69,13 +69,9 @@ describe('Login API', () => {
|
||||
expect((payloadRefresh as any).userData).toHaveProperty('login');
|
||||
expect((payloadRefresh as any).userData).toHaveProperty('email');
|
||||
|
||||
const jwtStorage = new JwtStorage();
|
||||
const result = await jwtStorage.fetchTokens(payloadRefresh.id);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Can not fetch tokens');
|
||||
}
|
||||
expect(result.access_token).toBeTruthy();
|
||||
const sessionStorage = new JwtStorage();
|
||||
const isActive = await sessionStorage.isSessionActive(payloadRefresh.id);
|
||||
expect(isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('Registration', async () => {
|
||||
|
||||
@@ -2,17 +2,17 @@ import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { Database } from '../../../modules/db';
|
||||
import type { RegisterUserInDb } from '../../../types/auth.types';
|
||||
import AuthModel from '../AuthModel';
|
||||
import JwtStorage from '../JwtStorage';
|
||||
import SessionStorage from '../SessionStorage';
|
||||
|
||||
describe('AuthModel Integration Tests', () => {
|
||||
let jwtStorage: JwtStorage;
|
||||
describe('SessionStorage Integration Tests', () => {
|
||||
let sessionStorage: SessionStorage;
|
||||
let authModel: AuthModel;
|
||||
let emailNum: number;
|
||||
let userId: number;
|
||||
let rowId: number;
|
||||
let sessionId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
jwtStorage = new JwtStorage();
|
||||
sessionStorage = new SessionStorage();
|
||||
authModel = new AuthModel();
|
||||
emailNum = Date.now();
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('AuthModel Integration Tests', () => {
|
||||
block: 0,
|
||||
};
|
||||
userId = (await authModel.registerUserInDb(userData)) as number;
|
||||
rowId = (await jwtStorage.initTokenRecord(userId)) as number;
|
||||
sessionId = (await sessionStorage.createSession(userId, '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120')) as number;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -32,40 +32,34 @@ describe('AuthModel Integration Tests', () => {
|
||||
await db.query("delete from tv_auth.users where login not in ('user', 'user1', 'user3')");
|
||||
});
|
||||
|
||||
it('initTokenRecord', async () => {
|
||||
expect(rowId).toBeTruthy();
|
||||
it('createSession', async () => {
|
||||
expect(sessionId).toBeTruthy();
|
||||
const deleteAllSession = await authModel.clearAllSessionTokensForUser(userId);
|
||||
expect(deleteAllSession).toBe(true);
|
||||
});
|
||||
|
||||
it('updateTokens', async () => {
|
||||
const updateResult = await jwtStorage.updateTokens('access-1', 'refresh-1', rowId);
|
||||
expect(updateResult).toBe(true);
|
||||
it('isSessionActive', async () => {
|
||||
const isActive = await sessionStorage.isSessionActive(sessionId);
|
||||
expect(isActive).toBe(true);
|
||||
|
||||
const isInactive = await sessionStorage.isSessionActive(999999);
|
||||
expect(isInactive).toBe(false);
|
||||
});
|
||||
|
||||
it('fetchTokens', async () => {
|
||||
let fetchResult = await jwtStorage.fetchTokens(rowId);
|
||||
expect(fetchResult).toBeTruthy();
|
||||
expect(fetchResult).toHaveProperty('id');
|
||||
expect(fetchResult).toHaveProperty('user_id');
|
||||
expect(fetchResult).toHaveProperty('access_token');
|
||||
expect(fetchResult).toHaveProperty('refresh_token');
|
||||
expect(fetchResult).toHaveProperty('user_ip');
|
||||
expect(fetchResult).toHaveProperty('time_creation');
|
||||
|
||||
const updateResult = await jwtStorage.updateTokens('access-1', 'refresh-1', rowId);
|
||||
expect(updateResult).toBe(true);
|
||||
|
||||
fetchResult = await jwtStorage.fetchTokens(rowId);
|
||||
expect(fetchResult).toBeTruthy();
|
||||
if (fetchResult) {
|
||||
expect(fetchResult.access_token).toBe('access-1');
|
||||
expect(fetchResult.refresh_token).toBe('refresh-1');
|
||||
}
|
||||
it('fetchUserSessions', async () => {
|
||||
const sessions = await sessionStorage.fetchUserSessions(userId);
|
||||
expect(sessions.length).toBeGreaterThan(0);
|
||||
expect(sessions[0]).toHaveProperty('id');
|
||||
expect(sessions[0]).toHaveProperty('userId');
|
||||
expect(sessions[0]).toHaveProperty('deviceName');
|
||||
expect(sessions[0]).toHaveProperty('userIp');
|
||||
});
|
||||
|
||||
it('deleteTokens', async () => {
|
||||
const result = await jwtStorage.deleteTokens(userId, 'access-1');
|
||||
it('deleteSession', async () => {
|
||||
const result = await sessionStorage.deleteSession(sessionId, userId);
|
||||
expect(result).toBe(true);
|
||||
|
||||
const isActive = await sessionStorage.isSessionActive(sessionId);
|
||||
expect(isActive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,19 @@ import type { NextFunction, Request, Response } from 'express';
|
||||
import AuthController from '../AuthController';
|
||||
|
||||
export const IsLoggedIn = async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.appUser.isApiTokenAuth() && !req.appUser.isBlocked()) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = req.headers['authorization']?.split(' ')[1];
|
||||
if (token) {
|
||||
const userPayload = await AuthController.validateTokens(token);
|
||||
if (userPayload && req.appUser.getTokenId() === userPayload.id && !req.appUser.isBlocked()) {
|
||||
if (
|
||||
userPayload
|
||||
&& req.appUser.getTokenId() === userPayload.id
|
||||
&& req.appUser.getHasActiveToken()
|
||||
&& !req.appUser.isBlocked()
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,4 +192,14 @@ export class CollaborationRolesRepository {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async assignAllPermissionsToRole(roleId: number, permissionIds: number[]): Promise<boolean> {
|
||||
if (permissionIds.length === 0) return false
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.insert(CollaborationPermissionsToRoleSchema)
|
||||
.values(permissionIds.map(permissionId => ({ roleId, permissionId })))
|
||||
)
|
||||
return !!result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type } from 'arktype';
|
||||
import type { Request, Response } from 'express';
|
||||
import { eventBus } from '../../core/EventBus';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import {
|
||||
CollaborationArkTypeAddUser,
|
||||
@@ -83,6 +84,14 @@ export class CollaborationController {
|
||||
|
||||
const user = await req.appUser.collaborationManager.addUserNew(output);
|
||||
|
||||
if (user) {
|
||||
eventBus.emit('collaboration.userAdded', {
|
||||
goalId: output.goalId,
|
||||
email: output.email.toLowerCase(),
|
||||
initiatorId: req.appUser.getUserData()!.id,
|
||||
});
|
||||
}
|
||||
|
||||
return res.tvJson(user ?? null);
|
||||
};
|
||||
|
||||
@@ -93,7 +102,17 @@ export class CollaborationController {
|
||||
return res.status(400).send(output.summary);
|
||||
}
|
||||
|
||||
return res.tvJson(await req.appUser.collaborationManager.deleteUserNew(output));
|
||||
const result = await req.appUser.collaborationManager.deleteUserNew(output);
|
||||
|
||||
if (result) {
|
||||
eventBus.emit('collaboration.userRemoved', {
|
||||
goalId: output.goalId,
|
||||
collaborationUserId: output.id,
|
||||
initiatorId: req.appUser.getUserData()!.id,
|
||||
});
|
||||
}
|
||||
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
toggleUserRolesNew = async (req: Request, res: Response) => {
|
||||
@@ -103,11 +122,20 @@ export class CollaborationController {
|
||||
return res.status(400).send(output.summary);
|
||||
}
|
||||
|
||||
return res.tvJson(await req.appUser.collaborationManager.toggleUserRolesNew(output));
|
||||
const result = await req.appUser.collaborationManager.toggleUserRolesNew(output);
|
||||
|
||||
eventBus.emit('collaboration.rolesChanged', {
|
||||
goalId: output.goalId,
|
||||
collaborationUserId: output.userId,
|
||||
initiatorId: req.appUser.getUserData()!.id,
|
||||
});
|
||||
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
fetchAllUsersNew = async (req: Request, res: Response) => {
|
||||
const users = await req.appUser.collaborationManager.fetchAllUsersNew();
|
||||
const organizationId = req.query.organizationId ? Number(req.query.organizationId) : undefined;
|
||||
const users = await req.appUser.collaborationManager.fetchAllUsersNew(organizationId);
|
||||
return res.tvJson(users);
|
||||
};
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ export class CollaborationManager {
|
||||
}
|
||||
|
||||
async addUser(args: AddUserArg): Promise<CollaborationUserInDb | false> {
|
||||
const userId = await this.repository.addUserForCollaboration(args.goalId, args.email);
|
||||
const userId = await this.repository.addUserForCollaboration(args.goalId, args.email.toLowerCase());
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
@@ -121,7 +121,20 @@ export class CollaborationManager {
|
||||
}
|
||||
|
||||
async addUserNew(args: CollaborationArgAddUser): Promise<CollaborationUserWithRoles | null> {
|
||||
const user = await this.repository.addUserForCollaborationNew(args);
|
||||
const email = args.email.toLowerCase();
|
||||
|
||||
const goal = await this.user.goalsManager.goalsRepository.findGoalById(args.goalId);
|
||||
if (goal && goal.organizationId) {
|
||||
const member = await this.user.organizationManager.repository.getMemberByEmail(goal.organizationId, email);
|
||||
if (!member) {
|
||||
await this.user.organizationManager.repository.addMember(goal.organizationId, email, 'member');
|
||||
}
|
||||
}
|
||||
|
||||
const user = await this.repository.addUserForCollaborationNew({
|
||||
...args,
|
||||
email,
|
||||
});
|
||||
if (!user) return null;
|
||||
|
||||
return {
|
||||
@@ -142,15 +155,15 @@ export class CollaborationManager {
|
||||
return await this.repository.toggleUserRolesNew(args);
|
||||
}
|
||||
|
||||
async fetchAllUsersNew(): Promise<CollaborationUserWithRoles[]> {
|
||||
|
||||
const sharedGoals = await this.user.goalsManager.fetchSharedGoals();
|
||||
async fetchAllUsersNew(organizationId?: number): Promise<CollaborationUserWithRoles[]> {
|
||||
|
||||
const sharedGoals = await this.user.goalsManager.fetchSharedGoals(organizationId);
|
||||
|
||||
const goalIds = sharedGoals
|
||||
.filter((g) => g.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_ASSIGNED_USERS))
|
||||
.map((g) => g.id);
|
||||
|
||||
const ownGoals = await this.user.goalsManager.fetchAllOwnGoalsIds();
|
||||
const ownGoals = await this.user.goalsManager.fetchAllOwnGoalsIds(organizationId);
|
||||
|
||||
goalIds.push(...ownGoals);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import { IsOrgMemberIfProvided } from '../../middlewares/is-org-member';
|
||||
import { CollaborationController } from './CollaborationController';
|
||||
import { CanAddUserCollaboration } from './middlewares/CanAddUserCollaboration';
|
||||
import { CanDeleteUserCollaboration } from './middlewares/CanDeleteUserCollaboration';
|
||||
@@ -50,7 +51,7 @@ export default class CollaborationRoutes implements Routable {
|
||||
/**
|
||||
* Fetch all users for collaboration
|
||||
*/
|
||||
this.router.get('', [IsLoggedIn], this.collaborationController.fetchAllUsersNew);
|
||||
this.router.get('', [IsLoggedIn, IsOrgMemberIfProvided], this.collaborationController.fetchAllUsersNew);
|
||||
|
||||
/**
|
||||
* Fetch users for goal for collaboration
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
UpdateGoalDbArgSchema,
|
||||
} from '../../types/goal.type';
|
||||
import { logError } from '../../utils/api';
|
||||
import { GoalsArkTypeAdd, GoalsArkTypeDelete, GoalsArkTypeUpdate } from './types';
|
||||
import { GoalsArkTypeAdd, GoalsArkTypeDelete, GoalsArkTypeFetch, GoalsArkTypeUpdate } from './types';
|
||||
|
||||
export default class GoalsController {
|
||||
fetchGoals = async (req: Request, res: Response) => {
|
||||
@@ -106,6 +106,10 @@ export default class GoalsController {
|
||||
};
|
||||
|
||||
fetchGoalsNew = async (req: Request, res: Response) => {
|
||||
return res.tvJson(await req.appUser.goalsManager.fetchGoalsNew());
|
||||
const out = GoalsArkTypeFetch(req.query);
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
return res.tvJson(await req.appUser.goalsManager.fetchGoalsNew(out.organizationId));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,8 +69,8 @@ export default class GoalsManager {
|
||||
}
|
||||
|
||||
/** @deprecated use fetchSharedGoalsForUser from GoalsRepository instead */
|
||||
async fetchSharedGoals() {
|
||||
const goals = await this.goalsRepository.fetchSharedGoals(this.user);
|
||||
async fetchSharedGoals(organizationId?: number) {
|
||||
const goals = await this.goalsRepository.fetchSharedGoals(this.user, organizationId);
|
||||
return await Promise.all(
|
||||
goals.map(
|
||||
async (g) =>
|
||||
@@ -91,8 +91,8 @@ export default class GoalsManager {
|
||||
return await this.goalsRepository.updateArchive(goalId, archive);
|
||||
}
|
||||
|
||||
async fetchAllOwnGoalsIds() {
|
||||
return await this.goalsRepository.fetchAllOwnGoalsIds(this.user);
|
||||
async fetchAllOwnGoalsIds(organizationId?: number) {
|
||||
return await this.goalsRepository.fetchAllOwnGoalsIds(this.user, organizationId);
|
||||
}
|
||||
|
||||
async createGoal(goalData: GoalsArgAdd): Promise<GoalsItemForClientWithPermissions | false> {
|
||||
@@ -100,10 +100,28 @@ export default class GoalsManager {
|
||||
if (!isNotNullable(userId)) {
|
||||
return false;
|
||||
}
|
||||
const goal = await this.goalsRepository.createGoal(goalData, userId);
|
||||
|
||||
let ownerId = userId;
|
||||
if (goalData.organizationId) {
|
||||
const org = await this.user.organizationManager.getById(goalData.organizationId);
|
||||
if (!org) return false;
|
||||
ownerId = org.ownerId;
|
||||
} else {
|
||||
const personalOrgId = await this.user.organizationManager.getPersonalOrgId();
|
||||
if (!personalOrgId) return false;
|
||||
goalData = { ...goalData, organizationId: personalOrgId };
|
||||
}
|
||||
|
||||
const creatorId = goalData.organizationId && ownerId !== userId ? userId : undefined;
|
||||
const goal = await this.goalsRepository.createGoal(goalData, ownerId, creatorId);
|
||||
if (!goal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (creatorId) {
|
||||
await this.addCreatorAsCollaboratorWithFullAccess(goal.id);
|
||||
}
|
||||
|
||||
return {
|
||||
...goal,
|
||||
permissions: (
|
||||
@@ -138,24 +156,34 @@ export default class GoalsManager {
|
||||
return await this.goalsRepository.deleteGoalNew(goalData);
|
||||
}
|
||||
|
||||
async fetchGoalsNew(): Promise<GoalsItemForClientWithPermissions[]> {
|
||||
const sharedGoals = await this.goalsRepository.fetchSharedGoalsForUser(this.user!);
|
||||
const ownGoals = await this.goalsRepository.fetchGoalsNew(this.user.getUserData()?.id!);
|
||||
async fetchGoalsNew(organizationId?: number): Promise<GoalsItemForClientWithPermissions[]> {
|
||||
const sharedGoals = await this.goalsRepository.fetchSharedGoalsForUser(this.user!, organizationId);
|
||||
const ownGoals = await this.goalsRepository.fetchGoalsNew(this.user.getUserData()?.id!, organizationId);
|
||||
|
||||
const allowedGoalIds = this.user.getAllowedGoalIds();
|
||||
const filterByAllowed = allowedGoalIds && allowedGoalIds.length > 0;
|
||||
|
||||
const filteredOwnGoals = filterByAllowed
|
||||
? ownGoals.filter((g) => allowedGoalIds.includes(g.id))
|
||||
: ownGoals;
|
||||
const filteredSharedGoals = filterByAllowed
|
||||
? sharedGoals.filter((g) => allowedGoalIds.includes(g.id))
|
||||
: sharedGoals;
|
||||
|
||||
let ownGoalsWithPermissions: GoalsItemForClientWithPermissions[] = [];
|
||||
let sharedGoalsWithPermissions: GoalsItemForClientWithPermissions[] = [];
|
||||
|
||||
if (ownGoals.length > 0) {
|
||||
if (filteredOwnGoals.length > 0) {
|
||||
const permChecker = await this.user.permissionsFetcher.getPermissionsForType(
|
||||
ownGoals[0].id,
|
||||
filteredOwnGoals[0].id,
|
||||
GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL
|
||||
);
|
||||
ownGoalsWithPermissions = ownGoals.map((g) => ({ ...g, permissions: permChecker.getAllPermissions() }));
|
||||
ownGoalsWithPermissions = filteredOwnGoals.map((g) => ({ ...g, permissions: permChecker.getAllPermissions() }));
|
||||
}
|
||||
|
||||
if (sharedGoals.length > 0) {
|
||||
if (filteredSharedGoals.length > 0) {
|
||||
sharedGoalsWithPermissions = await Promise.all(
|
||||
sharedGoals.map(async (g) => {
|
||||
filteredSharedGoals.map(async (g) => {
|
||||
return {
|
||||
...g,
|
||||
permissions: (
|
||||
@@ -171,4 +199,26 @@ export default class GoalsManager {
|
||||
|
||||
return [...ownGoalsWithPermissions, ...sharedGoalsWithPermissions];
|
||||
}
|
||||
|
||||
private async addCreatorAsCollaboratorWithFullAccess(goalId: number) {
|
||||
const email = this.user.getUserData()?.email
|
||||
if (!email) return
|
||||
|
||||
const collabUser = await this.user.collaborationManager.addUserNew({ goalId, email })
|
||||
if (!collabUser) return
|
||||
|
||||
const role = await this.user.collaborationRolesManager.repository.addRoleNew('TvOrgAdmin', goalId)
|
||||
if (!role) return
|
||||
|
||||
const allPermissions = await this.user.collaborationRolesManager.repository.fetchAllAvailablePermissionsNew()
|
||||
if (allPermissions.length > 0) {
|
||||
await this.user.collaborationRolesManager.repository.assignAllPermissionsToRole(role.id, allPermissions.map(p => p.id))
|
||||
}
|
||||
|
||||
await this.user.collaborationManager.repository.toggleUserRolesNew({
|
||||
goalId,
|
||||
userId: collabUser.id,
|
||||
roles: [role.id],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,20 +105,25 @@ export class GoalsRepository {
|
||||
return !!(del.rowCount && del.rowCount > 0);
|
||||
}
|
||||
|
||||
async fetchSharedGoals(user: AppUser): Promise<GoalItemInDb[]> {
|
||||
async fetchSharedGoals(user: AppUser, organizationId?: number): Promise<GoalItemInDb[]> {
|
||||
if (!user.getUserData()?.email) {
|
||||
$logger.error('Trying fetch shared goals without active user');
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.query<GoalItemInDb>(
|
||||
`select tg.* from tasks.goals tg
|
||||
const params: any[] = [user.getUserData()?.email, user.getUserData()?.id];
|
||||
let sql = `select tg.* from tasks.goals tg
|
||||
left join collaboration.users_to_goals utg on utg.goal_id = tg.id
|
||||
left join collaboration.users cu on cu.id = utg.user_id
|
||||
where cu.email = $1 and tg.owner <> $2`,
|
||||
[user.getUserData()?.email, user.getUserData()?.id]
|
||||
)
|
||||
where cu.email = $1 and tg.owner <> $2`;
|
||||
|
||||
if (organizationId) {
|
||||
sql += ' and tg.organization_id = $3';
|
||||
params.push(organizationId);
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.query<GoalItemInDb>(sql, params)
|
||||
.catch(logError);
|
||||
|
||||
if (!result) {
|
||||
@@ -128,9 +133,16 @@ export class GoalsRepository {
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async fetchSharedGoalsForUser(user: AppUser): Promise<GoalsSchemaTypeForSelect[]> {
|
||||
// debugger;
|
||||
async fetchSharedGoalsForUser(user: AppUser, organizationId?: number): Promise<GoalsSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() => {
|
||||
const conditions = [
|
||||
eq(CollaborationUsersSchema.email, user.getUserData()?.email!),
|
||||
ne(GoalsSchema.owner, user.getUserData()?.id!),
|
||||
];
|
||||
if (organizationId) {
|
||||
conditions.push(eq(GoalsSchema.organizationId, organizationId));
|
||||
}
|
||||
|
||||
const query = this.db.dbDrizzle
|
||||
.select({
|
||||
id: GoalsSchema.id,
|
||||
@@ -142,6 +154,8 @@ export class GoalsRepository {
|
||||
creatorId: GoalsSchema.creatorId,
|
||||
editDate: GoalsSchema.editDate,
|
||||
archive: GoalsSchema.archive,
|
||||
backlogVersion: GoalsSchema.backlogVersion,
|
||||
organizationId: GoalsSchema.organizationId,
|
||||
})
|
||||
.from(GoalsSchema)
|
||||
.leftJoin(CollaborationUsersToGoalsSchema, eq(GoalsSchema.id, CollaborationUsersToGoalsSchema.goalId))
|
||||
@@ -149,12 +163,7 @@ export class GoalsRepository {
|
||||
CollaborationUsersSchema,
|
||||
eq(CollaborationUsersToGoalsSchema.userId, CollaborationUsersSchema.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(CollaborationUsersSchema.email, user.getUserData()?.email!),
|
||||
ne(GoalsSchema.owner, user.getUserData()?.id!)
|
||||
)
|
||||
);
|
||||
.where(and(...conditions));
|
||||
// const sql = query.toSQL();
|
||||
// console.log('query', sql);
|
||||
return query;
|
||||
@@ -183,29 +192,34 @@ export class GoalsRepository {
|
||||
return !!(result.rowCount && result.rowCount > 0);
|
||||
}
|
||||
|
||||
async fetchAllOwnGoalsIds(user: AppUser): Promise<number[]> {
|
||||
const ownGoals = await this.db
|
||||
.query<{ id: number }>('select id from tasks.goals where owner = $1 and archive = $2', [
|
||||
user.getUserData()?.id,
|
||||
0,
|
||||
])
|
||||
.catch(logError);
|
||||
|
||||
if (!ownGoals) {
|
||||
$logger.error(`Can not fetch own goals for all state`);
|
||||
return [];
|
||||
async fetchAllOwnGoalsIds(user: AppUser, organizationId?: number): Promise<number[]> {
|
||||
const conditions = [
|
||||
eq(GoalsSchema.owner, user.getUserData()?.id!),
|
||||
eq(GoalsSchema.archive, 0),
|
||||
];
|
||||
if (organizationId) {
|
||||
conditions.push(eq(GoalsSchema.organizationId, organizationId));
|
||||
}
|
||||
|
||||
return ownGoals.rows.map((g) => g.id);
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select({ id: GoalsSchema.id })
|
||||
.from(GoalsSchema)
|
||||
.where(and(...conditions))
|
||||
);
|
||||
|
||||
if (!result) return [];
|
||||
return result.map((g) => g.id);
|
||||
}
|
||||
|
||||
async createGoal(goalData: GoalsArgAdd, userId: number): Promise<GoalsSchemaTypeForSelect | false> {
|
||||
async createGoal(goalData: GoalsArgAdd, ownerId: number, creatorId?: number): Promise<GoalsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.insert(GoalsSchema)
|
||||
.values({
|
||||
...goalData,
|
||||
owner: userId,
|
||||
owner: ownerId,
|
||||
...(creatorId && { creatorId }),
|
||||
})
|
||||
.returning()
|
||||
);
|
||||
@@ -240,9 +254,22 @@ export class GoalsRepository {
|
||||
return !!(result?.rowCount && result.rowCount > 0);
|
||||
}
|
||||
|
||||
async fetchGoalsNew(userId: number): Promise<GoalsSchemaTypeForSelect[]> {
|
||||
async findGoalById(goalId: number): Promise<GoalsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(GoalsSchema).where(eq(GoalsSchema.owner, userId))
|
||||
this.db.dbDrizzle.select().from(GoalsSchema).where(eq(GoalsSchema.id, goalId))
|
||||
);
|
||||
if (!result || result.length === 0) return false;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async fetchGoalsNew(userId: number, organizationId?: number): Promise<GoalsSchemaTypeForSelect[]> {
|
||||
const conditions = [eq(GoalsSchema.owner, userId)];
|
||||
if (organizationId) {
|
||||
conditions.push(eq(GoalsSchema.organizationId, organizationId));
|
||||
}
|
||||
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(GoalsSchema).where(and(...conditions))
|
||||
);
|
||||
if (!result) {
|
||||
return [];
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import GoalsController from './GoalsController';
|
||||
// import { CanArchiveGoal } from './middlewares/CanArchiveGoal';
|
||||
import { IsOrgMemberIfProvided } from '../../middlewares/is-org-member';
|
||||
import { canAddGoal } from './middlewares/can-add-goal';
|
||||
import { canDeleteGoal } from './middlewares/can-delete-goal';
|
||||
import { canEditGoal } from './middlewares/can-edit-goal';
|
||||
@@ -23,9 +24,9 @@ export default class GoalsRoutes implements Routable {
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.post('', [IsLoggedIn, canAddGoal], this.goalsController.createGoal);
|
||||
this.router.post('', [IsLoggedIn, IsOrgMemberIfProvided, canAddGoal], this.goalsController.createGoal);
|
||||
this.router.patch('', [IsLoggedIn, canEditGoal], this.goalsController.updateGoalNew);
|
||||
this.router.delete('', [IsLoggedIn, canDeleteGoal], this.goalsController.deleteGoalNew);
|
||||
this.router.get('', [IsLoggedIn, canFetchGoals], this.goalsController.fetchGoalsNew);
|
||||
this.router.get('', [IsLoggedIn, IsOrgMemberIfProvided, canFetchGoals], this.goalsController.fetchGoalsNew);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { ORG_ADMIN_ROLES, type OrgRole } from '../../organizations/types'
|
||||
|
||||
export const canAddGoal = async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.appUser.isBlocked()) {
|
||||
return next();
|
||||
if (req.appUser.isBlocked()) {
|
||||
return res.status(403).end()
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
const organizationId = req.body.organizationId
|
||||
if (organizationId) {
|
||||
const member = await req.appUser.organizationManager.getCurrentUserMember(Number(organizationId))
|
||||
if (!member || !ORG_ADMIN_ROLES.includes(member.role as OrgRole)) {
|
||||
return res.status(403).end()
|
||||
}
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export const GoalsArkTypeAdd = type({
|
||||
name: 'string',
|
||||
'description?': 'string | null',
|
||||
'color?': 'string | null',
|
||||
'organizationId?': 'number',
|
||||
});
|
||||
|
||||
export type GoalsArgAdd = typeof GoalsArkTypeAdd.infer;
|
||||
@@ -25,4 +26,10 @@ export const GoalsArkTypeDelete = type({
|
||||
|
||||
export type GoalsArgDelete = typeof GoalsArkTypeDelete.infer;
|
||||
|
||||
export const GoalsArkTypeFetch = type({
|
||||
'organizationId?': type('string | number').pipe((v) => Number(v)),
|
||||
});
|
||||
|
||||
export type GoalsArgFetch = typeof GoalsArkTypeFetch.infer;
|
||||
|
||||
export type GoalsItemForClientWithPermissions = GoalsSchemaTypeForSelect & { permissions: GoalPermissionsForClient };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq, and, or, isNull, inArray } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { TasksSchema, CollaborationUsersSchema, UsersSchema } from 'taskview-db-schemas';
|
||||
import { TasksSchema, CollaborationUsersSchema, UsersSchema, GoalsSchema } from 'taskview-db-schemas';
|
||||
import { eventBus, type AppEvents } from '../../core/EventBus';
|
||||
import { getJobQueue } from '../../core/JobQueue';
|
||||
import { Database } from '../../modules/db';
|
||||
@@ -108,6 +108,7 @@ export class NotificationDispatcher implements Dispatcher {
|
||||
): Promise<void> {
|
||||
const initiatorName = await this.resolveUserName(data.initiatorId);
|
||||
const message = NotificationMessages.assign(task.description, initiatorName);
|
||||
const organizationId = await this.resolveOrganizationId(task.goalId);
|
||||
|
||||
$logger.info(`[NotificationDispatcher] Assign notification for task=${data.taskId}, recipients=[${recipientIds.join(',')}]`);
|
||||
|
||||
@@ -115,7 +116,7 @@ export class NotificationDispatcher implements Dispatcher {
|
||||
recipientIds,
|
||||
NotificationType.ASSIGN,
|
||||
message,
|
||||
{ goalId: task.goalId, goalListId: task.goalListId },
|
||||
{ goalId: task.goalId, goalListId: task.goalListId, organizationId },
|
||||
data.taskId,
|
||||
);
|
||||
}
|
||||
@@ -135,6 +136,7 @@ export class NotificationDispatcher implements Dispatcher {
|
||||
|
||||
const tz = task.owner ? await this.deviceTokensRepo.getTimezoneByUserId(task.owner) : 'UTC';
|
||||
const message = NotificationMessages.deadline(task.description, task.endDate, task.endTime, tz);
|
||||
const organizationId = await this.resolveOrganizationId(task.goalId);
|
||||
|
||||
$logger.info(`[NotificationDispatcher] Expired deadline notification for task=${data.taskId}, recipients=[${recipientIds.join(',')}]`);
|
||||
|
||||
@@ -142,7 +144,7 @@ export class NotificationDispatcher implements Dispatcher {
|
||||
recipientIds,
|
||||
NotificationType.DEADLINE,
|
||||
message,
|
||||
{ goalId: task.goalId, goalListId: task.goalListId },
|
||||
{ goalId: task.goalId, goalListId: task.goalListId, organizationId },
|
||||
data.taskId,
|
||||
);
|
||||
}
|
||||
@@ -152,6 +154,16 @@ export class NotificationDispatcher implements Dispatcher {
|
||||
await this.deadlineScheduler.cancel(data.taskId);
|
||||
}
|
||||
|
||||
private async resolveOrganizationId(goalId: number): Promise<number | null> {
|
||||
const db = Database.getInstance();
|
||||
const result = await db.dbDrizzle
|
||||
.select({ organizationId: GoalsSchema.organizationId })
|
||||
.from(GoalsSchema)
|
||||
.where(eq(GoalsSchema.id, goalId))
|
||||
.limit(1);
|
||||
return result[0]?.organizationId ?? null;
|
||||
}
|
||||
|
||||
private async resolveUserName(userId: number): Promise<string> {
|
||||
const db = Database.getInstance();
|
||||
const result = await db.dbDrizzle
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { NotificationChannel } from './types';
|
||||
export interface NotificationMeta {
|
||||
goalId: number;
|
||||
goalListId: number | null;
|
||||
organizationId: number | null;
|
||||
}
|
||||
|
||||
export interface NotificationProvider {
|
||||
|
||||
@@ -18,7 +18,8 @@ const DeviceTokenArkType = type({
|
||||
export class NotificationsController {
|
||||
fetch = async (req: Request, res: Response) => {
|
||||
const cursor = req.query.cursor ? Number(req.query.cursor) : undefined;
|
||||
return res.tvJson(await req.appUser.notificationsManager.fetchByUser(cursor));
|
||||
const organizationId = req.query.organizationId ? Number(req.query.organizationId) : undefined;
|
||||
return res.tvJson(await req.appUser.notificationsManager.fetchByUser(cursor, organizationId));
|
||||
};
|
||||
|
||||
markRead = async (req: Request, res: Response) => {
|
||||
@@ -29,8 +30,9 @@ export class NotificationsController {
|
||||
return res.tvJson(await req.appUser.notificationsManager.markRead(out.notificationId));
|
||||
};
|
||||
|
||||
markAllRead = async (_req: Request, res: Response) => {
|
||||
return res.tvJson(await _req.appUser.notificationsManager.markAllRead());
|
||||
markAllRead = async (req: Request, res: Response) => {
|
||||
const organizationId = req.query.organizationId ? Number(req.query.organizationId) : undefined;
|
||||
return res.tvJson(await req.appUser.notificationsManager.markAllRead(organizationId));
|
||||
};
|
||||
|
||||
registerDevice = async (req: Request, res: Response) => {
|
||||
|
||||
@@ -10,10 +10,10 @@ export class NotificationsManager {
|
||||
this.repository = new NotificationsRepository();
|
||||
}
|
||||
|
||||
async fetchByUser(cursor?: number) {
|
||||
async fetchByUser(cursor?: number, organizationId?: number) {
|
||||
const userId = this.user.getUserData()?.id;
|
||||
if (!userId) return { notifications: [] };
|
||||
const notifications = await this.repository.fetchByUser(userId, cursor);
|
||||
const notifications = await this.repository.fetchByUser(userId, cursor, organizationId);
|
||||
return { notifications };
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ export class NotificationsManager {
|
||||
return this.repository.markRead(notificationId, userId);
|
||||
}
|
||||
|
||||
async markAllRead() {
|
||||
async markAllRead(organizationId?: number) {
|
||||
const userId = this.user.getUserData()?.id;
|
||||
if (!userId) return false;
|
||||
return this.repository.markAllRead(userId);
|
||||
return this.repository.markAllRead(userId, organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import { IsOrgMemberIfProvided } from '../../middlewares/is-org-member';
|
||||
import { NotificationsController } from './NotificationsController';
|
||||
|
||||
export default class NotificationsRoutes implements Routable {
|
||||
@@ -18,9 +19,9 @@ export default class NotificationsRoutes implements Routable {
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('/', [IsLoggedIn], this.controller.fetch);
|
||||
this.router.get('/', [IsLoggedIn, IsOrgMemberIfProvided], this.controller.fetch);
|
||||
this.router.patch('/read', [IsLoggedIn], this.controller.markRead);
|
||||
this.router.patch('/read-all', [IsLoggedIn], this.controller.markAllRead);
|
||||
this.router.patch('/read-all', [IsLoggedIn, IsOrgMemberIfProvided], this.controller.markAllRead);
|
||||
this.router.get('/preferences', [IsLoggedIn], this.controller.getPreferences);
|
||||
this.router.put('/preferences', [IsLoggedIn], this.controller.savePreferences);
|
||||
this.router.get('/connection-token', [IsLoggedIn], this.controller.connectionToken);
|
||||
|
||||
@@ -11,6 +11,7 @@ export class CentrifugoProvider implements NotificationProvider {
|
||||
notification,
|
||||
goalId: meta.goalId,
|
||||
goalListId: meta.goalListId,
|
||||
organizationId: meta.organizationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ export class FCMProvider implements NotificationProvider {
|
||||
taskId: notification.taskId ? String(notification.taskId) : '',
|
||||
goalId: String(meta.goalId),
|
||||
goalListId: meta.goalListId ? String(meta.goalListId) : '',
|
||||
organizationId: meta.organizationId ? String(meta.organizationId) : '',
|
||||
notificationId: String(notification.id),
|
||||
},
|
||||
android: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import { DeviceTokensSchema, type DeviceTokensSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { callWithCatch } from '../../../utils/helpers';
|
||||
@@ -11,6 +11,14 @@ export class DeviceTokensRepository {
|
||||
}
|
||||
|
||||
async register(userId: number, token: string, platform: string, timezone: string): Promise<boolean> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(DeviceTokensSchema)
|
||||
.where(and(
|
||||
eq(DeviceTokensSchema.token, token),
|
||||
ne(DeviceTokensSchema.userId, userId),
|
||||
))
|
||||
);
|
||||
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(DeviceTokensSchema).values({
|
||||
userId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, eq, desc, lt, sql } from 'drizzle-orm';
|
||||
import { NotificationsSchema, TasksSchema, type NotificationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { and, eq, desc, lt, sql, inArray } from 'drizzle-orm';
|
||||
import { NotificationsSchema, TasksSchema, GoalsSchema, type NotificationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { callWithCatch } from '../../../utils/helpers';
|
||||
|
||||
@@ -24,12 +24,24 @@ export class NotificationsRepository {
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async fetchByUser(userId: number, cursor?: number) {
|
||||
async fetchByUser(userId: number, cursor?: number, organizationId?: number) {
|
||||
const limit = 30;
|
||||
const conditions = [eq(NotificationsSchema.userId, userId)];
|
||||
if (cursor) {
|
||||
conditions.push(lt(NotificationsSchema.id, cursor));
|
||||
}
|
||||
if (organizationId) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
NotificationsSchema.taskId,
|
||||
this.db.dbDrizzle
|
||||
.select({ id: TasksSchema.id })
|
||||
.from(TasksSchema)
|
||||
.innerJoin(GoalsSchema, eq(TasksSchema.goalId, GoalsSchema.id))
|
||||
.where(eq(GoalsSchema.organizationId, organizationId))
|
||||
)
|
||||
);
|
||||
}
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({
|
||||
id: NotificationsSchema.id,
|
||||
@@ -83,14 +95,27 @@ export class NotificationsRepository {
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async markAllRead(userId: number): Promise<boolean> {
|
||||
async markAllRead(userId: number, organizationId?: number): Promise<boolean> {
|
||||
const conditions = [
|
||||
eq(NotificationsSchema.userId, userId),
|
||||
eq(NotificationsSchema.read, false),
|
||||
];
|
||||
if (organizationId) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
NotificationsSchema.taskId,
|
||||
this.db.dbDrizzle
|
||||
.select({ id: TasksSchema.id })
|
||||
.from(TasksSchema)
|
||||
.innerJoin(GoalsSchema, eq(TasksSchema.goalId, GoalsSchema.id))
|
||||
.where(eq(GoalsSchema.organizationId, organizationId))
|
||||
)
|
||||
);
|
||||
}
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(NotificationsSchema)
|
||||
.set({ read: true })
|
||||
.where(and(
|
||||
eq(NotificationsSchema.userId, userId),
|
||||
eq(NotificationsSchema.read, false),
|
||||
))
|
||||
.where(and(...conditions))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ import { getNotificationService } from '../NotificationService';
|
||||
import { NotificationMessages } from '../NotificationMessages';
|
||||
import { DeviceTokensRepository } from '../repositories/DeviceTokensRepository';
|
||||
import { NotificationType, type DeadlineJobData, type TaskWithDeadline } from '../types';
|
||||
import { parseUtcTime } from '../utils';
|
||||
import { parseUtcTime, localHourToUtc } from '../utils';
|
||||
|
||||
const DEADLINE_JOB = 'deadline-notification';
|
||||
const DEFAULT_MORNING_HOUR = 9;
|
||||
|
||||
export class DeadlineScheduler {
|
||||
private readonly deviceTokensRepo = new DeviceTokensRepository();
|
||||
@@ -25,15 +26,21 @@ export class DeadlineScheduler {
|
||||
if (!deadline) return;
|
||||
startAfter = deadline > new Date() ? deadline : undefined;
|
||||
} else {
|
||||
const deadlineDay = new Date(`${task.endDate}T00:00:00Z`);
|
||||
const tz = task.owner ? await this.deviceTokensRepo.getTimezoneByUserId(task.owner) : null;
|
||||
const deadlineDay = tz
|
||||
? localHourToUtc(task.endDate, DEFAULT_MORNING_HOUR, tz)
|
||||
: new Date(`${task.endDate}T00:00:00Z`);
|
||||
startAfter = deadlineDay > new Date() ? deadlineDay : undefined;
|
||||
}
|
||||
|
||||
const organizationId = await this.resolveOrganizationId(Database.getInstance(), task.goalId);
|
||||
|
||||
const data: DeadlineJobData = {
|
||||
taskId: task.id,
|
||||
description: task.description ?? '',
|
||||
goalId: task.goalId,
|
||||
goalListId: task.goalListId,
|
||||
organizationId,
|
||||
endDate: task.endDate,
|
||||
endTime: task.endTime,
|
||||
initiatorId: initiatorId ?? null,
|
||||
@@ -59,7 +66,7 @@ export class DeadlineScheduler {
|
||||
await boss.createQueue(DEADLINE_JOB);
|
||||
|
||||
await boss.work<DeadlineJobData>(DEADLINE_JOB, async ([job]) => {
|
||||
const { taskId, description, goalId, goalListId, endDate, endTime, initiatorId, immediate } = job.data;
|
||||
const { taskId, description, goalId, goalListId, organizationId, endDate, endTime, initiatorId, immediate } = job.data;
|
||||
|
||||
if (!taskId) return;
|
||||
$logger.info(`[DeadlineScheduler] Worker: job=${job.id} task=${taskId}`);
|
||||
@@ -101,7 +108,7 @@ export class DeadlineScheduler {
|
||||
recipientIds,
|
||||
NotificationType.DEADLINE,
|
||||
message,
|
||||
{ goalId, goalListId },
|
||||
{ goalId, goalListId, organizationId: organizationId ?? null },
|
||||
taskId,
|
||||
);
|
||||
});
|
||||
@@ -111,6 +118,15 @@ export class DeadlineScheduler {
|
||||
return `deadline-${taskId}`;
|
||||
}
|
||||
|
||||
private async resolveOrganizationId(db: Database, goalId: number): Promise<number | null> {
|
||||
const result = await db.dbDrizzle
|
||||
.select({ organizationId: GoalsSchema.organizationId })
|
||||
.from(GoalsSchema)
|
||||
.where(eq(GoalsSchema.id, goalId))
|
||||
.limit(1);
|
||||
return result[0]?.organizationId ?? null;
|
||||
}
|
||||
|
||||
private async resolveRecipients(db: Database, taskId: number, goalId: number, taskOwner: number | null): Promise<number[] | null> {
|
||||
const authUsers = alias(UsersSchema, 'auth_users');
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface DeadlineJobData {
|
||||
description: string;
|
||||
goalId: number;
|
||||
goalListId: number | null;
|
||||
organizationId: number | null;
|
||||
endDate: string;
|
||||
endTime: string | null;
|
||||
initiatorId: number | null;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { type } from 'arktype'
|
||||
import type { Request, Response } from 'express'
|
||||
import { logError } from '../../utils/api'
|
||||
import {
|
||||
OrganizationArkTypeCreate,
|
||||
OrganizationArkTypeUpdate,
|
||||
OrganizationMemberArkTypeAdd,
|
||||
OrganizationMemberArkTypeUpdateRole,
|
||||
OrganizationMemberArkTypeRemove,
|
||||
} from './types'
|
||||
|
||||
export class OrganizationController {
|
||||
create = async (req: Request, res: Response) => {
|
||||
const out = OrganizationArkTypeCreate(req.body)
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary)
|
||||
}
|
||||
|
||||
const org = await req.appUser.organizationManager.create(out).catch(logError)
|
||||
return res.tvJson(org ?? null)
|
||||
}
|
||||
|
||||
update = async (req: Request, res: Response) => {
|
||||
const orgId = Number(req.params.orgId)
|
||||
if (!orgId) return res.status(400).end()
|
||||
|
||||
const out = OrganizationArkTypeUpdate(req.body)
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary)
|
||||
}
|
||||
|
||||
const org = await req.appUser.organizationManager.update({ ...out, organizationId: orgId }).catch(logError)
|
||||
return res.tvJson(org ?? null)
|
||||
}
|
||||
|
||||
delete = async (req: Request, res: Response) => {
|
||||
const orgId = Number(req.params.orgId)
|
||||
if (!orgId) return res.status(400).end()
|
||||
|
||||
const result = await req.appUser.organizationManager.delete(orgId).catch(logError)
|
||||
return res.tvJson(!!result)
|
||||
}
|
||||
|
||||
fetch = async (req: Request, res: Response) => {
|
||||
const orgs = await req.appUser.organizationManager.fetchForCurrentUser().catch(logError)
|
||||
return res.tvJson(orgs ?? [])
|
||||
}
|
||||
|
||||
getById = async (req: Request, res: Response) => {
|
||||
const orgId = Number(req.params.orgId)
|
||||
if (!orgId) return res.status(400).end()
|
||||
|
||||
const org = await req.appUser.organizationManager.getById(orgId).catch(logError)
|
||||
return res.tvJson(org ?? null)
|
||||
}
|
||||
|
||||
fetchMembers = async (req: Request, res: Response) => {
|
||||
const orgId = Number(req.params.orgId)
|
||||
if (!orgId) return res.status(400).end()
|
||||
|
||||
const members = await req.appUser.organizationManager.fetchMembers(orgId).catch(logError)
|
||||
return res.tvJson(members ?? [])
|
||||
}
|
||||
|
||||
addMember = async (req: Request, res: Response) => {
|
||||
const out = OrganizationMemberArkTypeAdd(req.body)
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary)
|
||||
}
|
||||
|
||||
const member = await req.appUser.organizationManager
|
||||
.addMember(out.organizationId, out.email, out.role)
|
||||
.catch(logError)
|
||||
return res.tvJson(member ?? null)
|
||||
}
|
||||
|
||||
updateMemberRole = async (req: Request, res: Response) => {
|
||||
const out = OrganizationMemberArkTypeUpdateRole(req.body)
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary)
|
||||
}
|
||||
|
||||
const member = await req.appUser.organizationManager
|
||||
.updateMemberRole(out.organizationId, out.email, out.role)
|
||||
.catch(logError)
|
||||
return res.tvJson(member ?? null)
|
||||
}
|
||||
|
||||
removeMember = async (req: Request, res: Response) => {
|
||||
const out = OrganizationMemberArkTypeRemove(req.body)
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary)
|
||||
}
|
||||
|
||||
const result = await req.appUser.organizationManager
|
||||
.removeMember(out.organizationId, out.email)
|
||||
.catch(logError)
|
||||
return res.tvJson(!!result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { AppUser } from '../../core/AppUser'
|
||||
import { isNotNullable } from '../../utils/helpers'
|
||||
import { OrganizationRepository } from './OrganizationRepository'
|
||||
import type { OrganizationArgCreate, OrganizationArgUpdate } from './types'
|
||||
|
||||
export class OrganizationManager {
|
||||
public readonly repository: OrganizationRepository
|
||||
private readonly user: AppUser
|
||||
|
||||
constructor(user: AppUser) {
|
||||
this.user = user
|
||||
this.repository = new OrganizationRepository()
|
||||
}
|
||||
|
||||
private getUserId(): number | null {
|
||||
const id = this.user.getUserData()?.id
|
||||
return isNotNullable(id) ? id : null
|
||||
}
|
||||
|
||||
private getUserEmail(): string | null {
|
||||
return this.user.getUserData()?.email ?? null
|
||||
}
|
||||
|
||||
async create(data: OrganizationArgCreate) {
|
||||
const userId = this.getUserId()
|
||||
const email = this.getUserEmail()
|
||||
if (!userId || !email) return false
|
||||
|
||||
const slug = (data.slug ?? this.generateSlug()).toLowerCase()
|
||||
|
||||
const existing = await this.repository.findBySlug(slug)
|
||||
if (existing) return false
|
||||
|
||||
const org = await this.repository.create({ ...data, slug }, userId)
|
||||
if (!org) return false
|
||||
|
||||
await this.repository.addMember(org.id, email, 'owner')
|
||||
return { ...org, currentUserRole: 'owner' }
|
||||
}
|
||||
|
||||
async update(data: OrganizationArgUpdate) {
|
||||
if (data.slug) {
|
||||
data = { ...data, slug: data.slug.toLowerCase() }
|
||||
const existing = await this.repository.findBySlug(data.slug)
|
||||
if (existing && existing.id !== data.organizationId) return false
|
||||
}
|
||||
|
||||
return await this.repository.update(data)
|
||||
}
|
||||
|
||||
async delete(orgId: number) {
|
||||
const org = await this.repository.findById(orgId)
|
||||
if (!org) return false
|
||||
if (org.isPersonal) return false
|
||||
|
||||
const userId = this.getUserId()
|
||||
if (org.ownerId !== userId) return false
|
||||
|
||||
await this.repository.invalidateSessionsForOrgOnlyMembers(orgId)
|
||||
return await this.repository.delete(orgId)
|
||||
}
|
||||
|
||||
async fetchForCurrentUser() {
|
||||
const email = this.getUserEmail()
|
||||
if (!email) return []
|
||||
|
||||
return await this.repository.fetchForUserByEmail(email)
|
||||
}
|
||||
|
||||
async getById(orgId: number) {
|
||||
return await this.repository.findById(orgId)
|
||||
}
|
||||
|
||||
async getPersonalOrgId(): Promise<number | false> {
|
||||
const userId = this.getUserId()
|
||||
if (!userId) return false
|
||||
|
||||
const org = await this.repository.findPersonalForUser(userId)
|
||||
if (!org) return false
|
||||
|
||||
return org.id
|
||||
}
|
||||
|
||||
async addMember(orgId: number, email: string, role: string = 'member') {
|
||||
const userId = this.getUserId()
|
||||
if (!userId) return false
|
||||
if (role === 'owner') return false
|
||||
|
||||
return await this.repository.addMember(orgId, email, role, userId)
|
||||
}
|
||||
|
||||
async updateMemberRole(orgId: number, email: string, role: string) {
|
||||
const targetMember = await this.repository.getMemberByEmail(orgId, email)
|
||||
if (!targetMember || targetMember.role === 'owner') return false
|
||||
|
||||
return await this.repository.updateMemberRole(orgId, email, role)
|
||||
}
|
||||
|
||||
async removeMember(orgId: number, email: string) {
|
||||
const org = await this.repository.findById(orgId)
|
||||
if (!org) return false
|
||||
|
||||
const targetMember = await this.repository.getMemberByEmail(orgId, email)
|
||||
if (!targetMember || targetMember.role === 'owner') return false
|
||||
|
||||
const removed = await this.repository.removeMember(orgId, email)
|
||||
if (removed) {
|
||||
await this.repository.removeUserFromOrgGoals(orgId, email)
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
async fetchMembers(orgId: number) {
|
||||
return await this.repository.fetchMembers(orgId)
|
||||
}
|
||||
|
||||
async getCurrentUserMember(orgId: number) {
|
||||
const email = this.getUserEmail()
|
||||
if (!email) return false
|
||||
return await this.repository.getMemberByEmail(orgId, email)
|
||||
}
|
||||
|
||||
private generateSlug(): string {
|
||||
return `org-${crypto.randomUUID().slice(0, 8)}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { and, eq, inArray, ne } from 'drizzle-orm'
|
||||
import {
|
||||
OrganizationsSchema,
|
||||
OrganizationMembersSchema,
|
||||
CollaborationUsersSchema,
|
||||
CollaborationUsersToGoalsSchema,
|
||||
GoalsSchema,
|
||||
UsersSchema,
|
||||
UserTokensSchema,
|
||||
type OrganizationsSchemaTypeForSelect,
|
||||
type OrganizationMembersSchemaTypeForSelect,
|
||||
} from 'taskview-db-schemas'
|
||||
import { Database } from '../../modules/db'
|
||||
import { callWithCatch } from '../../utils/helpers'
|
||||
import type { OrganizationArgCreate, OrganizationArgUpdate } from './types'
|
||||
|
||||
export class OrganizationRepository {
|
||||
private readonly db: Database
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance()
|
||||
}
|
||||
|
||||
async create(data: OrganizationArgCreate, userId: number, isPersonal: boolean = false): Promise<OrganizationsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.insert(OrganizationsSchema)
|
||||
.values({
|
||||
name: data.name,
|
||||
slug: data.slug ?? `org-${Date.now()}`,
|
||||
ownerId: userId,
|
||||
logoUrl: data.logoUrl,
|
||||
isPersonal: isPersonal ? 1 : 0,
|
||||
})
|
||||
.returning()
|
||||
)
|
||||
|
||||
if (!result) return false
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async update(data: OrganizationArgUpdate): Promise<OrganizationsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.update(OrganizationsSchema)
|
||||
.set({
|
||||
...(data.name !== undefined && { name: data.name }),
|
||||
...(data.slug !== undefined && { slug: data.slug }),
|
||||
...(data.logoUrl !== undefined && { logoUrl: data.logoUrl }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(OrganizationsSchema.id, data.organizationId))
|
||||
.returning()
|
||||
)
|
||||
|
||||
if (!result) return false
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async invalidateSessionsForOrgOnlyMembers(orgId: number): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(UserTokensSchema).where(
|
||||
inArray(
|
||||
UserTokensSchema.userId,
|
||||
this.db.dbDrizzle
|
||||
.select({ id: UsersSchema.id })
|
||||
.from(UsersSchema)
|
||||
.innerJoin(OrganizationMembersSchema, eq(OrganizationMembersSchema.email, UsersSchema.email))
|
||||
.where(eq(OrganizationMembersSchema.organizationId, orgId))
|
||||
.except(
|
||||
this.db.dbDrizzle
|
||||
.select({ id: UsersSchema.id })
|
||||
.from(UsersSchema)
|
||||
.innerJoin(OrganizationMembersSchema, eq(OrganizationMembersSchema.email, UsersSchema.email))
|
||||
.where(ne(OrganizationMembersSchema.organizationId, orgId))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async delete(orgId: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.delete(OrganizationsSchema)
|
||||
.where(eq(OrganizationsSchema.id, orgId))
|
||||
)
|
||||
|
||||
return !!(result?.rowCount && result.rowCount > 0)
|
||||
}
|
||||
|
||||
async findById(orgId: number): Promise<OrganizationsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(OrganizationsSchema)
|
||||
.where(eq(OrganizationsSchema.id, orgId))
|
||||
)
|
||||
|
||||
if (!result || result.length === 0) return false
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async findPersonalForUser(userId: number): Promise<OrganizationsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(OrganizationsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(OrganizationsSchema.ownerId, userId),
|
||||
eq(OrganizationsSchema.isPersonal, 1),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (!result || result.length === 0) return false
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async findBySlug(slug: string): Promise<OrganizationsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(OrganizationsSchema)
|
||||
.where(eq(OrganizationsSchema.slug, slug))
|
||||
)
|
||||
|
||||
if (!result || result.length === 0) return false
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async fetchForUserByEmail(email: string) {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select({
|
||||
org: OrganizationsSchema,
|
||||
role: OrganizationMembersSchema.role,
|
||||
})
|
||||
.from(OrganizationMembersSchema)
|
||||
.innerJoin(OrganizationsSchema, eq(OrganizationMembersSchema.organizationId, OrganizationsSchema.id))
|
||||
.where(eq(OrganizationMembersSchema.email, email))
|
||||
)
|
||||
|
||||
if (!result) return []
|
||||
return result.map(r => ({
|
||||
...r.org,
|
||||
currentUserRole: r.role,
|
||||
}))
|
||||
}
|
||||
|
||||
async addMember(orgId: number, email: string, role: string, invitedBy?: number): Promise<OrganizationMembersSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.insert(OrganizationMembersSchema)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
email: email.toLowerCase(),
|
||||
role,
|
||||
invitedBy: invitedBy ?? null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
)
|
||||
|
||||
if (!result || result.length === 0) return false
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async updateMemberRole(orgId: number, email: string, role: string): Promise<OrganizationMembersSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.update(OrganizationMembersSchema)
|
||||
.set({ role })
|
||||
.where(
|
||||
and(
|
||||
eq(OrganizationMembersSchema.organizationId, orgId),
|
||||
eq(OrganizationMembersSchema.email, email),
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
)
|
||||
|
||||
if (!result || result.length === 0) return false
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async removeMember(orgId: number, email: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.delete(OrganizationMembersSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(OrganizationMembersSchema.organizationId, orgId),
|
||||
eq(OrganizationMembersSchema.email, email),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return !!(result?.rowCount && result.rowCount > 0)
|
||||
}
|
||||
|
||||
async fetchMembers(orgId: number): Promise<OrganizationMembersSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(OrganizationMembersSchema)
|
||||
.where(eq(OrganizationMembersSchema.organizationId, orgId))
|
||||
)
|
||||
|
||||
if (!result) return []
|
||||
return result
|
||||
}
|
||||
|
||||
async getMemberByEmail(orgId: number, email: string): Promise<OrganizationMembersSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(OrganizationMembersSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(OrganizationMembersSchema.organizationId, orgId),
|
||||
eq(OrganizationMembersSchema.email, email),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (!result || result.length === 0) return false
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async removeUserFromOrgGoals(orgId: number, email: string): Promise<void> {
|
||||
await callWithCatch(async () => {
|
||||
const collabUsers = await this.db.dbDrizzle
|
||||
.select({ id: CollaborationUsersSchema.id })
|
||||
.from(CollaborationUsersSchema)
|
||||
.where(eq(CollaborationUsersSchema.email, email))
|
||||
|
||||
const orgGoals = await this.db.dbDrizzle
|
||||
.select({ id: GoalsSchema.id })
|
||||
.from(GoalsSchema)
|
||||
.where(eq(GoalsSchema.organizationId, orgId))
|
||||
|
||||
const userIds = collabUsers.map(u => u.id)
|
||||
const goalIds = orgGoals.map(g => g.id)
|
||||
|
||||
if (userIds.length && goalIds.length) {
|
||||
await this.db.dbDrizzle
|
||||
.delete(CollaborationUsersToGoalsSchema)
|
||||
.where(
|
||||
and(
|
||||
inArray(CollaborationUsersToGoalsSchema.userId, userIds),
|
||||
inArray(CollaborationUsersToGoalsSchema.goalId, goalIds),
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Router } from 'express'
|
||||
import type { Routable } from '../../types/routable.type'
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
|
||||
import { OrganizationController } from './OrganizationController'
|
||||
import { IsOrgAdmin } from './middlewares/IsOrgAdmin'
|
||||
import { IsOrgMember } from './middlewares/IsOrgMember'
|
||||
import { IsOrgOwner } from './middlewares/IsOrgOwner'
|
||||
|
||||
export default class OrganizationRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
private readonly controller: OrganizationController
|
||||
|
||||
constructor() {
|
||||
this.router = Router()
|
||||
this.controller = new OrganizationController()
|
||||
this.initRoutes()
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.post('', [IsLoggedIn], this.controller.create)
|
||||
this.router.get('', [IsLoggedIn], this.controller.fetch)
|
||||
|
||||
this.router.post('/members', [IsLoggedIn, IsOrgAdmin], this.controller.addMember)
|
||||
this.router.patch('/members/role', [IsLoggedIn, IsOrgAdmin], this.controller.updateMemberRole)
|
||||
this.router.delete('/members', [IsLoggedIn, IsOrgAdmin], this.controller.removeMember)
|
||||
|
||||
this.router.get('/:orgId', [IsLoggedIn, IsOrgMember], this.controller.getById)
|
||||
this.router.patch('/:orgId', [IsLoggedIn, IsOrgAdmin], this.controller.update)
|
||||
this.router.delete('/:orgId', [IsLoggedIn, IsOrgOwner], this.controller.delete)
|
||||
this.router.get('/:orgId/members', [IsLoggedIn, IsOrgAdmin], this.controller.fetchMembers)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { ORG_ADMIN_ROLES, type OrgRole } from '../types'
|
||||
|
||||
export const IsOrgAdmin = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const orgId = Number(req.params.orgId || req.body.organizationId || req.query.organizationId)
|
||||
if (!orgId) return res.status(400).end()
|
||||
|
||||
const member = await req.appUser.organizationManager.getCurrentUserMember(orgId)
|
||||
|
||||
if (!member || !ORG_ADMIN_ROLES.includes(member.role as OrgRole)) return res.status(403).end()
|
||||
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { ORG_ALL_ROLES, type OrgRole } from '../types'
|
||||
|
||||
export const IsOrgMember = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const orgId = Number(req.params.orgId || req.body.organizationId)
|
||||
if (!orgId) return res.status(400).end()
|
||||
|
||||
const member = await req.appUser.organizationManager.getCurrentUserMember(orgId)
|
||||
|
||||
if (!member || !ORG_ALL_ROLES.includes(member.role as OrgRole)) return res.status(403).end()
|
||||
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { OrgRoles } from '../types'
|
||||
|
||||
export const IsOrgOwner = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const orgId = Number(req.params.orgId || req.body.organizationId)
|
||||
if (!orgId) return res.status(400).end()
|
||||
|
||||
const member = await req.appUser.organizationManager.getCurrentUserMember(orgId)
|
||||
|
||||
if (!member || member.role !== OrgRoles.OWNER) return res.status(403).end()
|
||||
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { type } from 'arktype'
|
||||
|
||||
export const OrgRoles = {
|
||||
OWNER: 'owner',
|
||||
ADMIN: 'admin',
|
||||
MEMBER: 'member',
|
||||
} as const
|
||||
|
||||
export type OrgRole = typeof OrgRoles[keyof typeof OrgRoles]
|
||||
|
||||
export const ORG_ADMIN_ROLES: readonly OrgRole[] = [OrgRoles.OWNER, OrgRoles.ADMIN]
|
||||
export const ORG_ALL_ROLES: readonly OrgRole[] = [OrgRoles.OWNER, OrgRoles.ADMIN, OrgRoles.MEMBER]
|
||||
|
||||
export const OrganizationArkTypeCreate = type({
|
||||
name: 'string > 0',
|
||||
'slug?': 'string',
|
||||
'logoUrl?': 'string | null',
|
||||
})
|
||||
|
||||
export type OrganizationArgCreate = typeof OrganizationArkTypeCreate.infer
|
||||
|
||||
export const OrganizationArkTypeUpdate = type({
|
||||
'name?': 'string',
|
||||
'slug?': 'string',
|
||||
'logoUrl?': 'string | null',
|
||||
})
|
||||
|
||||
export type OrganizationArgUpdate = typeof OrganizationArkTypeUpdate.infer & { organizationId: number }
|
||||
|
||||
export const OrganizationMemberArkTypeAdd = type({
|
||||
organizationId: 'number',
|
||||
email: 'string',
|
||||
'role?': "'admin' | 'member'",
|
||||
})
|
||||
|
||||
export type OrganizationMemberArgAdd = typeof OrganizationMemberArkTypeAdd.infer
|
||||
|
||||
export const OrganizationMemberArkTypeUpdateRole = type({
|
||||
organizationId: 'number',
|
||||
email: 'string',
|
||||
role: "'admin' | 'member'",
|
||||
})
|
||||
|
||||
export type OrganizationMemberArgUpdateRole = typeof OrganizationMemberArkTypeUpdateRole.infer
|
||||
|
||||
export const OrganizationMemberArkTypeRemove = type({
|
||||
organizationId: 'number',
|
||||
email: 'string',
|
||||
})
|
||||
|
||||
export type OrganizationMemberArgRemove = typeof OrganizationMemberArkTypeRemove.infer
|
||||
@@ -0,0 +1,65 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { CollaborationUsersSchema, UsersSchema } from 'taskview-db-schemas';
|
||||
import { getCentrifugoClient } from '../../core/CentrifugoClient';
|
||||
import type { Dispatcher } from '../../core/Dispatcher';
|
||||
import { eventBus, type AppEvents } from '../../core/EventBus';
|
||||
import { Database } from '../../modules/db';
|
||||
|
||||
export class RealtimeDispatcher implements Dispatcher {
|
||||
register(): void {
|
||||
eventBus.on('collaboration.userAdded', (data) => this.onCollaborationUserAdded(data));
|
||||
eventBus.on('collaboration.userRemoved', (data) => this.onCollaborationUserRemoved(data));
|
||||
eventBus.on('collaboration.rolesChanged', (data) => this.onCollaborationRolesChanged(data));
|
||||
}
|
||||
|
||||
async registerWorkers(): Promise<void> {}
|
||||
|
||||
private async onCollaborationUserAdded(data: AppEvents['collaboration.userAdded']): Promise<void> {
|
||||
const userId = await this.resolveAuthUserIdByEmail(data.email);
|
||||
if (!userId) return;
|
||||
|
||||
await this.publishToUser(userId, 'goals.changed', { goalId: data.goalId });
|
||||
}
|
||||
|
||||
private async onCollaborationUserRemoved(data: AppEvents['collaboration.userRemoved']): Promise<void> {
|
||||
const userId = await this.resolveAuthUserIdByCollaborationUserId(data.collaborationUserId);
|
||||
if (!userId) return;
|
||||
|
||||
await this.publishToUser(userId, 'goals.changed', { goalId: data.goalId });
|
||||
}
|
||||
|
||||
private async onCollaborationRolesChanged(data: AppEvents['collaboration.rolesChanged']): Promise<void> {
|
||||
const userId = await this.resolveAuthUserIdByCollaborationUserId(data.collaborationUserId);
|
||||
if (!userId) return;
|
||||
|
||||
await this.publishToUser(userId, 'goals.changed', { goalId: data.goalId });
|
||||
}
|
||||
|
||||
private async publishToUser(userId: number, event: string, data: Record<string, unknown>): Promise<void> {
|
||||
const centrifugo = getCentrifugoClient();
|
||||
await centrifugo.publishToUser(userId, event, data);
|
||||
}
|
||||
|
||||
private async resolveAuthUserIdByEmail(email: string): Promise<number | null> {
|
||||
const db = Database.getInstance();
|
||||
const result = await db.dbDrizzle
|
||||
.select({ id: UsersSchema.id })
|
||||
.from(UsersSchema)
|
||||
.where(eq(UsersSchema.email, email))
|
||||
.limit(1);
|
||||
|
||||
return result[0]?.id ?? null;
|
||||
}
|
||||
|
||||
private async resolveAuthUserIdByCollaborationUserId(collaborationUserId: number): Promise<number | null> {
|
||||
const db = Database.getInstance();
|
||||
const result = await db.dbDrizzle
|
||||
.select({ id: UsersSchema.id })
|
||||
.from(CollaborationUsersSchema)
|
||||
.innerJoin(UsersSchema, eq(CollaborationUsersSchema.email, UsersSchema.email))
|
||||
.where(eq(CollaborationUsersSchema.id, collaborationUserId))
|
||||
.limit(1);
|
||||
|
||||
return result[0]?.id ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { $logger } from '../../modules/logget'
|
||||
import { ScimManager } from './ScimManager'
|
||||
import { toScimUser, toScimList, toScimError } from './scim.helpers'
|
||||
|
||||
export class ScimController {
|
||||
private readonly manager = new ScimManager()
|
||||
|
||||
listUsers = async (_req: Request, res: Response) => {
|
||||
const { scimOrg } = res.locals
|
||||
const members = await this.manager.listUsers(scimOrg.id)
|
||||
|
||||
return res.json(toScimList(members.map(m => toScimUser(m, true))))
|
||||
}
|
||||
|
||||
getUser = async (req: Request, res: Response) => {
|
||||
const { scimOrg } = res.locals
|
||||
const email = decodeURIComponent(req.params.id)
|
||||
|
||||
const member = await this.manager.getUserByEmail(scimOrg.id, email)
|
||||
if (!member) {
|
||||
return res.status(404).json(toScimError(404, 'User not found'))
|
||||
}
|
||||
|
||||
return res.json(toScimUser(member, true))
|
||||
}
|
||||
|
||||
createUser = async (req: Request, res: Response) => {
|
||||
const { scimOrg } = res.locals
|
||||
const email = req.body.userName || req.body.emails?.[0]?.value
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json(toScimError(400, 'userName or emails[0].value is required'))
|
||||
}
|
||||
|
||||
await this.manager.reactivateUser(scimOrg, email)
|
||||
|
||||
const member = await this.manager.getUserByEmail(scimOrg.id, email)
|
||||
if (!member) {
|
||||
return res.status(500).json(toScimError(500, 'Failed to create user'))
|
||||
}
|
||||
|
||||
return res.status(201).json(toScimUser(member, true))
|
||||
}
|
||||
|
||||
patchUser = async (req: Request, res: Response) => {
|
||||
const { scimOrg, scimConfig } = res.locals
|
||||
const email = decodeURIComponent(req.params.id)
|
||||
|
||||
const operations = req.body.Operations || []
|
||||
|
||||
for (const op of operations) {
|
||||
if (op.op === 'replace' && (op.path === 'active' || op.value?.active !== undefined)) {
|
||||
const active = op.path === 'active' ? op.value : op.value.active
|
||||
|
||||
if (active === false || active === 'false') {
|
||||
const result = await this.manager.deactivateUser(scimOrg, scimConfig, email)
|
||||
if (!result) {
|
||||
return res.status(404).json(toScimError(404, 'User not found'))
|
||||
}
|
||||
$logger.info(`SCIM: deactivated user ${email} from org ${scimOrg.id}`)
|
||||
return res.json(toScimUser({ email, role: 'member' }, false))
|
||||
}
|
||||
|
||||
if (active === true || active === 'true') {
|
||||
await this.manager.reactivateUser(scimOrg, email)
|
||||
$logger.info(`SCIM: reactivated user ${email} in org ${scimOrg.id}`)
|
||||
const member = await this.manager.getUserByEmail(scimOrg.id, email)
|
||||
return res.json(toScimUser(member || { email, role: 'member' }, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json(toScimUser({ email, role: 'member' }, true))
|
||||
}
|
||||
|
||||
deleteUser = async (req: Request, res: Response) => {
|
||||
const { scimOrg, scimConfig } = res.locals
|
||||
const email = decodeURIComponent(req.params.id)
|
||||
|
||||
const result = await this.manager.deactivateUser(scimOrg, scimConfig, email)
|
||||
if (!result) {
|
||||
return res.status(404).json(toScimError(404, 'User not found'))
|
||||
}
|
||||
|
||||
$logger.info(`SCIM: deleted user ${email} from org ${scimOrg.id}`)
|
||||
return res.status(204).end()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { SsoConfigsSchemaTypeForSelect, OrganizationsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import AuthModel from '../auth/AuthModel'
|
||||
import SessionStorage from '../auth/SessionStorage'
|
||||
import { OrganizationRepository } from '../organizations/OrganizationRepository'
|
||||
import { SsoRepository } from '../sso/SsoRepository'
|
||||
|
||||
export class ScimManager {
|
||||
private readonly orgRepo = new OrganizationRepository()
|
||||
private readonly ssoRepo = new SsoRepository()
|
||||
private readonly authModel = new AuthModel()
|
||||
private readonly sessionStorage = new SessionStorage()
|
||||
|
||||
async deactivateUser(
|
||||
org: OrganizationsSchemaTypeForSelect,
|
||||
config: SsoConfigsSchemaTypeForSelect,
|
||||
email: string,
|
||||
) {
|
||||
const member = await this.orgRepo.getMemberByEmail(org.id, email)
|
||||
if (!member) return false
|
||||
|
||||
const user = await this.authModel.fetchUserByEmail(email)
|
||||
|
||||
await this.orgRepo.removeMember(org.id, email)
|
||||
await this.orgRepo.removeUserFromOrgGoals(org.id, email)
|
||||
|
||||
if (user) {
|
||||
await this.ssoRepo.deleteIdentityByUser(user.id, config.id)
|
||||
await this.sessionStorage.deleteAllSessions(user.id)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async reactivateUser(
|
||||
org: OrganizationsSchemaTypeForSelect,
|
||||
email: string,
|
||||
role: string = 'member',
|
||||
) {
|
||||
await this.orgRepo.addMember(org.id, email, role)
|
||||
return true
|
||||
}
|
||||
|
||||
async listUsers(orgId: number) {
|
||||
return await this.orgRepo.fetchMembers(orgId)
|
||||
}
|
||||
|
||||
async getUserByEmail(orgId: number, email: string) {
|
||||
return await this.orgRepo.getMemberByEmail(orgId, email)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Router } from 'express'
|
||||
import type { Routable } from '../../types/routable.type'
|
||||
import { ScimAuth } from './middlewares/ScimAuth'
|
||||
import { ScimController } from './ScimController'
|
||||
|
||||
export default class ScimRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
private readonly controller: ScimController
|
||||
|
||||
constructor() {
|
||||
this.router = Router()
|
||||
this.controller = new ScimController()
|
||||
this.initRoutes()
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.use(ScimAuth)
|
||||
|
||||
this.router.get('/Users', this.controller.listUsers)
|
||||
this.router.get('/Users/:id', this.controller.getUser)
|
||||
this.router.post('/Users', this.controller.createUser)
|
||||
this.router.patch('/Users/:id', this.controller.patchUser)
|
||||
this.router.delete('/Users/:id', this.controller.deleteUser)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createHash } from 'crypto'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { SsoRepository } from '../../sso/SsoRepository'
|
||||
import { OrganizationRepository } from '../../organizations/OrganizationRepository'
|
||||
|
||||
const ssoRepo = new SsoRepository()
|
||||
const orgRepo = new OrganizationRepository()
|
||||
|
||||
export const ScimAuth = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'],
|
||||
detail: 'Missing or invalid authorization header',
|
||||
status: '401',
|
||||
})
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7)
|
||||
const hashedToken = createHash('sha256').update(token).digest('hex')
|
||||
|
||||
const config = await ssoRepo.findByScimToken(hashedToken)
|
||||
if (!config) {
|
||||
return res.status(401).json({
|
||||
schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'],
|
||||
detail: 'Invalid SCIM token',
|
||||
status: '401',
|
||||
})
|
||||
}
|
||||
|
||||
const org = await orgRepo.findById(config.organizationId)
|
||||
if (!org) {
|
||||
return res.status(401).json({
|
||||
schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'],
|
||||
detail: 'Organization not found',
|
||||
status: '401',
|
||||
})
|
||||
}
|
||||
|
||||
res.locals.scimOrg = org
|
||||
res.locals.scimConfig = config
|
||||
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
const SCIM_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:User'
|
||||
const SCIM_LIST_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:ListResponse'
|
||||
const SCIM_ERROR_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:Error'
|
||||
|
||||
export function toScimUser(member: { email: string; role: string }, active: boolean) {
|
||||
return {
|
||||
schemas: [SCIM_SCHEMA],
|
||||
id: member.email,
|
||||
userName: member.email,
|
||||
emails: [{ value: member.email, primary: true }],
|
||||
active,
|
||||
roles: [{ value: member.role }],
|
||||
}
|
||||
}
|
||||
|
||||
export function toScimList(resources: ReturnType<typeof toScimUser>[]) {
|
||||
return {
|
||||
schemas: [SCIM_LIST_SCHEMA],
|
||||
totalResults: resources.length,
|
||||
Resources: resources,
|
||||
}
|
||||
}
|
||||
|
||||
export function toScimError(status: number, detail: string) {
|
||||
return {
|
||||
schemas: [SCIM_ERROR_SCHEMA],
|
||||
detail,
|
||||
status: String(status),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { ArkErrors } from 'arktype'
|
||||
import { SessionDeleteSchema } from './types'
|
||||
|
||||
export class SessionsController {
|
||||
fetch = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id
|
||||
if (!userId) return res.status(401).end()
|
||||
|
||||
const currentSessionId = req.appUser.getTokenId()
|
||||
const sessions = await req.appUser.authManager.sessionStorage.fetchUserSessions(userId)
|
||||
|
||||
const result = sessions.map((s) => ({
|
||||
id: s.id,
|
||||
deviceName: s.deviceName,
|
||||
userIp: s.userIp,
|
||||
createdAt: s.timeCreation,
|
||||
lastUsedAt: s.lastUsedAt,
|
||||
isCurrent: s.id === currentSessionId,
|
||||
}))
|
||||
|
||||
return res.tvJson(result)
|
||||
}
|
||||
|
||||
delete = async (req: Request, res: Response) => {
|
||||
const data = SessionDeleteSchema(req.body)
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary)
|
||||
}
|
||||
|
||||
const userId = req.appUser.getUserData()?.id
|
||||
if (!userId) return res.status(401).end()
|
||||
|
||||
const currentSessionId = req.appUser.getTokenId()
|
||||
if (data.id === currentSessionId) {
|
||||
return res.status(400).send('Cannot delete current session')
|
||||
}
|
||||
|
||||
const result = await req.appUser.authManager.sessionStorage.deleteSession(data.id, userId)
|
||||
return res.tvJson(result)
|
||||
}
|
||||
|
||||
deleteAll = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id
|
||||
if (!userId) return res.status(401).end()
|
||||
|
||||
const currentSessionId = req.appUser.getTokenId()
|
||||
const result = await req.appUser.authManager.sessionStorage.deleteAllSessions(userId, currentSessionId)
|
||||
return res.tvJson(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Router } from 'express'
|
||||
import type { Routable } from '../../types/routable.type'
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
|
||||
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth'
|
||||
import { SessionsController } from './SessionsController'
|
||||
|
||||
export default class SessionsRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
private readonly controller: SessionsController
|
||||
|
||||
constructor() {
|
||||
this.router = Router()
|
||||
this.controller = new SessionsController()
|
||||
this.initRoutes()
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('', [IsLoggedIn, RejectApiTokenAuth], this.controller.fetch)
|
||||
this.router.delete('', [IsLoggedIn, RejectApiTokenAuth], this.controller.delete)
|
||||
this.router.delete('/all', [IsLoggedIn, RejectApiTokenAuth], this.controller.deleteAll)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type } from 'arktype'
|
||||
|
||||
export const SessionDeleteSchema = type({
|
||||
id: 'number',
|
||||
})
|
||||
|
||||
export type SessionDeleteArg = typeof SessionDeleteSchema.infer
|
||||
@@ -0,0 +1,246 @@
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import { type } from 'arktype'
|
||||
import { hashSync } from 'bcryptjs'
|
||||
import type { Request, Response } from 'express'
|
||||
import { $logger } from '../../modules/logget'
|
||||
import { logError } from '../../utils/api'
|
||||
import { generateString, isEmail } from '../../utils/helpers'
|
||||
import AuthModel from '../auth/AuthModel'
|
||||
import { OrganizationRepository } from '../organizations/OrganizationRepository'
|
||||
import { createSsoProvider } from './providers/provider-factory'
|
||||
import { SsoRepository } from './SsoRepository'
|
||||
import { parseSamlMetadata } from './saml-metadata-parser'
|
||||
import { generateLoginCode, stripSecrets, validateMetadataUrl } from './sso.utils'
|
||||
import { SsoConfigArkTypeCreate, SsoConfigArkTypeUpdate } from './types'
|
||||
|
||||
export class SsoController {
|
||||
private readonly ssoRepo = new SsoRepository()
|
||||
private readonly authModel = new AuthModel()
|
||||
private readonly orgRepo = new OrganizationRepository()
|
||||
|
||||
initiateLogin = async (req: Request, res: Response) => {
|
||||
const configId = Number(req.params.configId)
|
||||
if (!configId) return res.status(400).tvJson({ message: 'Invalid config ID' })
|
||||
|
||||
const config = await this.ssoRepo.findEnabledById(configId)
|
||||
if (!config) return res.status(404).tvJson({ message: 'SSO provider not found' })
|
||||
|
||||
try {
|
||||
const provider = createSsoProvider(config)
|
||||
const relayState = JSON.stringify({ platform: req.query.platform || '' })
|
||||
await provider.initiateLogin(req, res, relayState)
|
||||
} catch (error) {
|
||||
$logger.error(error, `SSO initiate login error for config ${configId}`)
|
||||
return res.status(500).tvJson({ message: 'Failed to initiate SSO login' })
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback = async (req: Request, res: Response) => {
|
||||
const configId = Number(req.params.configId)
|
||||
if (!configId) return res.status(400).tvJson({ message: 'Invalid config ID' })
|
||||
|
||||
const config = await this.ssoRepo.findEnabledById(configId)
|
||||
if (!config) return res.status(404).tvJson({ message: 'SSO provider not found' })
|
||||
|
||||
try {
|
||||
const provider = createSsoProvider(config)
|
||||
const ssoResult = await provider.handleCallback(req)
|
||||
|
||||
if (config.emailDomainRestriction) {
|
||||
const domain = ssoResult.email.split('@')[1]
|
||||
if (domain !== config.emailDomainRestriction) {
|
||||
return res.status(403).tvJson({ message: 'Email domain not allowed for this SSO provider' })
|
||||
}
|
||||
}
|
||||
|
||||
let userData = await this.authModel.getUserByLogin(ssoResult.email, isEmail(ssoResult.email))
|
||||
|
||||
if (!userData) {
|
||||
const password = generateString(16)
|
||||
const login = generateString(7)
|
||||
const id = await this.authModel.registerUserInDb({
|
||||
login,
|
||||
email: ssoResult.email,
|
||||
password: hashSync(password, 10),
|
||||
block: 0,
|
||||
confirmEmailCode: '',
|
||||
})
|
||||
|
||||
if (!id) {
|
||||
$logger.error('Failed to create user during SSO login')
|
||||
return res.status(500).tvJson({ message: 'Failed to create user' })
|
||||
}
|
||||
|
||||
const personalOrgSlug = `org-${crypto.randomUUID().slice(0, 8)}`
|
||||
const personalOrg = await this.orgRepo.create({ name: `${login}'s workspace`, slug: personalOrgSlug }, id, true)
|
||||
if (personalOrg) {
|
||||
await this.orgRepo.addMember(personalOrg.id, ssoResult.email, 'owner')
|
||||
}
|
||||
|
||||
userData = await this.authModel.getUserByLogin(ssoResult.email, isEmail(ssoResult.email))
|
||||
}
|
||||
|
||||
if (!userData) {
|
||||
return res.status(500).tvJson({ message: 'Failed to resolve user after SSO login' })
|
||||
}
|
||||
|
||||
await this.orgRepo.addMember(config.organizationId, ssoResult.email, config.defaultOrgRole)
|
||||
|
||||
await this.ssoRepo.upsertIdentity({
|
||||
userId: userData.id,
|
||||
ssoConfigId: config.id,
|
||||
externalId: ssoResult.externalId,
|
||||
email: ssoResult.email,
|
||||
})
|
||||
|
||||
const code = generateLoginCode()
|
||||
await this.authModel.updateLoginCode(code, userData.email)
|
||||
|
||||
const authData = {
|
||||
code: code.split(':')[0],
|
||||
email: userData.email,
|
||||
}
|
||||
const encodedAuthData = encodeURIComponent(JSON.stringify(authData))
|
||||
|
||||
try {
|
||||
let relayState = req.body?.RelayState as string | undefined
|
||||
if (!relayState && req.query?.state) {
|
||||
const stateData = JSON.parse(req.query.state as string)
|
||||
relayState = stateData.relay
|
||||
}
|
||||
if (relayState) {
|
||||
const platformData = JSON.parse(relayState)
|
||||
if (platformData.platform === 'mobile') {
|
||||
return res.redirect(`taskview://login?tokens=${encodedAuthData}`)
|
||||
}
|
||||
}
|
||||
} catch { /* relay state parse error — ignore, use web redirect */ }
|
||||
|
||||
return res.redirect(`${process.env.APP_URL}/login?tokens=${encodedAuthData}`)
|
||||
} catch (error) {
|
||||
$logger.error(error, `SSO callback error for config ${configId}`)
|
||||
return res.redirect(`${process.env.APP_URL}/login?sso_error=authentication_failed`)
|
||||
}
|
||||
}
|
||||
|
||||
listPublicProviders = async (req: Request, res: Response) => {
|
||||
const domain = req.query.domain as string
|
||||
if (!domain) return res.tvJson(null)
|
||||
|
||||
const config = await this.ssoRepo.findEnabledByDomain(domain)
|
||||
if (!config) return res.tvJson(null)
|
||||
|
||||
return res.tvJson({
|
||||
id: config.id,
|
||||
displayName: config.displayName,
|
||||
protocol: config.protocol,
|
||||
})
|
||||
}
|
||||
|
||||
listConfigs = async (req: Request, res: Response) => {
|
||||
const orgId = Number(req.query.organizationId)
|
||||
if (!orgId) return res.status(400).tvJson({ message: 'organizationId is required' })
|
||||
|
||||
const configs = await this.ssoRepo.listByOrgId(orgId)
|
||||
return res.tvJson(configs.map(stripSecrets))
|
||||
}
|
||||
|
||||
createConfig = async (req: Request, res: Response) => {
|
||||
const out = SsoConfigArkTypeCreate(req.body)
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary)
|
||||
}
|
||||
|
||||
const existing = await this.ssoRepo.findEnabledByDomain(out.emailDomainRestriction)
|
||||
if (existing) {
|
||||
return res.status(409).tvJson({ message: 'SSO config for this domain already exists' })
|
||||
}
|
||||
|
||||
const config = await req.appUser.ssoManager.createConfig(out).catch(logError)
|
||||
if (!config) {
|
||||
return res.status(500).tvJson({ message: 'Failed to create SSO config' })
|
||||
}
|
||||
return res.tvJson(stripSecrets(config))
|
||||
}
|
||||
|
||||
updateConfig = async (req: Request, res: Response) => {
|
||||
const configId = Number(req.params.configId)
|
||||
if (!configId) return res.status(400).end()
|
||||
|
||||
const out = SsoConfigArkTypeUpdate(req.body)
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary)
|
||||
}
|
||||
|
||||
const config = await req.appUser.ssoManager.updateConfig(configId, out).catch(logError)
|
||||
return res.tvJson(config ? stripSecrets(config) : null)
|
||||
}
|
||||
|
||||
parseMetadata = async (req: Request, res: Response) => {
|
||||
const metadataUrl = req.query.url as string
|
||||
if (!metadataUrl) return res.status(400).tvJson({ message: 'url is required' })
|
||||
|
||||
const urlError = validateMetadataUrl(metadataUrl)
|
||||
if (urlError) return res.status(400).tvJson({ message: urlError })
|
||||
|
||||
try {
|
||||
const response = await fetch(metadataUrl, { redirect: 'error' })
|
||||
if (!response.ok) {
|
||||
return res.status(400).tvJson({ message: `Failed to fetch metadata: ${response.status}` })
|
||||
}
|
||||
|
||||
const xml = await response.text()
|
||||
const parsed = parseSamlMetadata(xml)
|
||||
|
||||
return res.tvJson(parsed)
|
||||
} catch (error) {
|
||||
$logger.error(error, 'Failed to parse SAML metadata')
|
||||
return res.status(400).tvJson({ message: 'Failed to fetch or parse metadata' })
|
||||
}
|
||||
}
|
||||
|
||||
generateScimToken = async (req: Request, res: Response) => {
|
||||
const configId = Number(req.params.configId)
|
||||
if (!configId) return res.status(400).end()
|
||||
|
||||
const rawToken = `tvscim_${randomBytes(32).toString('hex')}`
|
||||
const hashedToken = createHash('sha256').update(rawToken).digest('hex')
|
||||
|
||||
const config = await this.ssoRepo.update(configId, {
|
||||
scimToken: hashedToken,
|
||||
scimEnabled: 1,
|
||||
})
|
||||
|
||||
if (!config) {
|
||||
return res.status(404).tvJson({ message: 'SSO config not found' })
|
||||
}
|
||||
|
||||
return res.tvJson({ token: rawToken })
|
||||
}
|
||||
|
||||
toggleScim = async (req: Request, res: Response) => {
|
||||
const configId = Number(req.params.configId)
|
||||
if (!configId) return res.status(400).end()
|
||||
|
||||
const enabled = req.body.enabled ? 1 : 0
|
||||
|
||||
const config = await this.ssoRepo.update(configId, {
|
||||
scimEnabled: enabled,
|
||||
...(enabled === 0 ? { scimToken: null } : {}),
|
||||
})
|
||||
|
||||
if (!config) {
|
||||
return res.status(404).tvJson({ message: 'SSO config not found' })
|
||||
}
|
||||
|
||||
return res.tvJson({ scimEnabled: config.scimEnabled })
|
||||
}
|
||||
|
||||
deleteConfig = async (req: Request, res: Response) => {
|
||||
const configId = Number(req.params.configId)
|
||||
if (!configId) return res.status(400).end()
|
||||
|
||||
const result = await req.appUser.ssoManager.deleteConfig(configId).catch(logError)
|
||||
return res.tvJson(!!result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AppUser } from '../../core/AppUser'
|
||||
import { encrypt, encryptField } from '../../utils/crypto'
|
||||
import { SsoRepository } from './SsoRepository'
|
||||
import { SSO_SECRET_FIELDS } from './sso.utils'
|
||||
import type { SsoConfigArgCreate, SsoConfigArgUpdate } from './types'
|
||||
|
||||
export class SsoManager {
|
||||
public readonly repository: SsoRepository
|
||||
private readonly user: AppUser
|
||||
|
||||
constructor(user: AppUser) {
|
||||
this.user = user
|
||||
this.repository = new SsoRepository()
|
||||
}
|
||||
|
||||
async listConfigsForOrg(orgId: number) {
|
||||
return await this.repository.listByOrgId(orgId)
|
||||
}
|
||||
|
||||
async createConfig(data: SsoConfigArgCreate) {
|
||||
return await this.repository.create({
|
||||
organizationId: data.organizationId,
|
||||
protocol: data.protocol,
|
||||
displayName: data.displayName,
|
||||
enabled: data.enabled ?? 1,
|
||||
samlEntryPoint: data.samlEntryPoint ?? null,
|
||||
samlIssuer: data.samlIssuer ?? null,
|
||||
samlCert: encryptField(data.samlCert),
|
||||
samlCallbackUrl: data.samlCallbackUrl ?? null,
|
||||
samlSigningKey: encryptField(data.samlSigningKey),
|
||||
samlSigningCert: encryptField(data.samlSigningCert),
|
||||
samlLogoutUrl: data.samlLogoutUrl ?? null,
|
||||
oidcIssuer: data.oidcIssuer ?? null,
|
||||
oidcClientId: data.oidcClientId ?? null,
|
||||
oidcClientSecret: encryptField(data.oidcClientSecret),
|
||||
oidcCallbackUrl: data.oidcCallbackUrl ?? null,
|
||||
oidcScope: data.oidcScope ?? null,
|
||||
defaultOrgRole: data.defaultOrgRole ?? 'member',
|
||||
emailDomainRestriction: data.emailDomainRestriction.toLowerCase(),
|
||||
})
|
||||
}
|
||||
|
||||
async updateConfig(configId: number, data: SsoConfigArgUpdate) {
|
||||
const encrypted: Partial<SsoConfigArgUpdate> = { ...data }
|
||||
for (const field of SSO_SECRET_FIELDS) {
|
||||
if (field in encrypted) {
|
||||
if (encrypted[field]) {
|
||||
encrypted[field] = encrypt(encrypted[field]!)
|
||||
} else {
|
||||
delete encrypted[field]
|
||||
}
|
||||
}
|
||||
}
|
||||
return await this.repository.update(configId, encrypted)
|
||||
}
|
||||
|
||||
async deleteConfig(configId: number) {
|
||||
return await this.repository.delete(configId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import {
|
||||
SsoConfigsSchema,
|
||||
SsoIdentitiesSchema,
|
||||
type SsoConfigsSchemaTypeForSelect,
|
||||
type SsoConfigsSchemaTypeForInsert,
|
||||
type SsoIdentitiesSchemaTypeForSelect,
|
||||
} from 'taskview-db-schemas'
|
||||
import { Database } from '../../modules/db'
|
||||
import { callWithCatch } from '../../utils/helpers'
|
||||
|
||||
export class SsoRepository {
|
||||
private readonly db: Database
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance()
|
||||
}
|
||||
|
||||
async findEnabledByDomain(domain: string): Promise<SsoConfigsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SsoConfigsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(SsoConfigsSchema.emailDomainRestriction, domain.toLowerCase()),
|
||||
eq(SsoConfigsSchema.enabled, 1),
|
||||
)
|
||||
)
|
||||
)
|
||||
if (!result || result.length === 0) return null
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async findEnabledById(id: number): Promise<SsoConfigsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SsoConfigsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(SsoConfigsSchema.id, id),
|
||||
eq(SsoConfigsSchema.enabled, 1),
|
||||
)
|
||||
)
|
||||
)
|
||||
if (!result || result.length === 0) return null
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async listEnabledByOrgId(orgId: number): Promise<SsoConfigsSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SsoConfigsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(SsoConfigsSchema.organizationId, orgId),
|
||||
eq(SsoConfigsSchema.enabled, 1),
|
||||
)
|
||||
)
|
||||
)
|
||||
return result ?? []
|
||||
}
|
||||
|
||||
async listByOrgId(orgId: number): Promise<SsoConfigsSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SsoConfigsSchema)
|
||||
.where(eq(SsoConfigsSchema.organizationId, orgId))
|
||||
)
|
||||
return result ?? []
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<SsoConfigsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SsoConfigsSchema)
|
||||
.where(eq(SsoConfigsSchema.id, id))
|
||||
)
|
||||
if (!result || result.length === 0) return null
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async create(data: SsoConfigsSchemaTypeForInsert): Promise<SsoConfigsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.insert(SsoConfigsSchema)
|
||||
.values(data)
|
||||
.returning()
|
||||
)
|
||||
if (!result || result.length === 0) return null
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async update(id: number, data: Partial<SsoConfigsSchemaTypeForInsert>): Promise<SsoConfigsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.update(SsoConfigsSchema)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(SsoConfigsSchema.id, id))
|
||||
.returning()
|
||||
)
|
||||
if (!result || result.length === 0) return null
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.delete(SsoConfigsSchema)
|
||||
.where(eq(SsoConfigsSchema.id, id))
|
||||
)
|
||||
return !!(result?.rowCount && result.rowCount > 0)
|
||||
}
|
||||
|
||||
async findByScimToken(hashedToken: string): Promise<SsoConfigsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SsoConfigsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(SsoConfigsSchema.scimToken, hashedToken),
|
||||
eq(SsoConfigsSchema.scimEnabled, 1),
|
||||
)
|
||||
)
|
||||
)
|
||||
if (!result || result.length === 0) return null
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async deleteIdentityByUser(userId: number, ssoConfigId: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.delete(SsoIdentitiesSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(SsoIdentitiesSchema.userId, userId),
|
||||
eq(SsoIdentitiesSchema.ssoConfigId, ssoConfigId),
|
||||
)
|
||||
)
|
||||
)
|
||||
return !!(result?.rowCount && result.rowCount > 0)
|
||||
}
|
||||
|
||||
async upsertIdentity(data: {
|
||||
userId: number
|
||||
ssoConfigId: number
|
||||
externalId: string
|
||||
email: string
|
||||
}): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
|
||||
const existing = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SsoIdentitiesSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(SsoIdentitiesSchema.ssoConfigId, data.ssoConfigId),
|
||||
eq(SsoIdentitiesSchema.externalId, data.externalId),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.update(SsoIdentitiesSchema)
|
||||
.set({
|
||||
email: data.email,
|
||||
lastLoginAt: new Date(),
|
||||
})
|
||||
.where(eq(SsoIdentitiesSchema.id, existing[0].id))
|
||||
.returning()
|
||||
)
|
||||
if (!result || result.length === 0) return null
|
||||
return result[0]
|
||||
}
|
||||
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.insert(SsoIdentitiesSchema)
|
||||
.values({
|
||||
userId: data.userId,
|
||||
ssoConfigId: data.ssoConfigId,
|
||||
externalId: data.externalId,
|
||||
email: data.email,
|
||||
lastLoginAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
)
|
||||
if (!result || result.length === 0) return null
|
||||
return result[0]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Router } from 'express'
|
||||
import type { Routable } from '../../types/routable.type'
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
|
||||
import { IsOrgAdmin } from '../organizations/middlewares/IsOrgAdmin'
|
||||
import { IsSsoConfigAdmin } from './middlewares/IsSsoConfigAdmin'
|
||||
import { SsoController } from './SsoController'
|
||||
|
||||
export default class SsoRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
private readonly controller: SsoController
|
||||
|
||||
constructor() {
|
||||
this.router = Router()
|
||||
this.controller = new SsoController()
|
||||
this.initRoutes()
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('/providers', this.controller.listPublicProviders)
|
||||
this.router.get('/login/:configId', this.controller.initiateLogin)
|
||||
this.router.get('/callback/:configId', this.controller.handleCallback)
|
||||
this.router.post('/callback/:configId', this.controller.handleCallback)
|
||||
|
||||
this.router.get('/admin/metadata', [IsLoggedIn, IsOrgAdmin], this.controller.parseMetadata)
|
||||
this.router.get('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.listConfigs)
|
||||
this.router.post('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.createConfig)
|
||||
this.router.patch('/admin/configs/:configId', [IsLoggedIn, IsSsoConfigAdmin], this.controller.updateConfig)
|
||||
this.router.delete('/admin/configs/:configId', [IsLoggedIn, IsSsoConfigAdmin], this.controller.deleteConfig)
|
||||
this.router.post('/admin/configs/:configId/scim-token', [IsLoggedIn, IsSsoConfigAdmin], this.controller.generateScimToken)
|
||||
this.router.patch('/admin/configs/:configId/scim', [IsLoggedIn, IsSsoConfigAdmin], this.controller.toggleScim)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { ORG_ADMIN_ROLES, type OrgRole } from '../../organizations/types'
|
||||
import { SsoRepository } from '../SsoRepository'
|
||||
|
||||
const ssoRepo = new SsoRepository()
|
||||
|
||||
/**
|
||||
* Resolves SSO config by :configId param, finds its organization,
|
||||
* and checks that the current user is admin/owner of that organization.
|
||||
*/
|
||||
export const IsSsoConfigAdmin = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const configId = Number(req.params.configId)
|
||||
if (!configId) return res.status(400).end()
|
||||
|
||||
const config = await ssoRepo.findById(configId)
|
||||
if (!config) return res.status(404).end()
|
||||
|
||||
const member = await req.appUser.organizationManager.getCurrentUserMember(config.organizationId)
|
||||
if (!member || !ORG_ADMIN_ROLES.includes(member.role as OrgRole)) {
|
||||
return res.status(403).end()
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
import * as client from 'openid-client'
|
||||
import type { Request, Response } from 'express'
|
||||
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import type { SsoProvider, SsoAuthResult } from './sso-provider.interface'
|
||||
|
||||
export class OidcProvider implements SsoProvider {
|
||||
private readonly config: SsoConfigsSchemaTypeForSelect
|
||||
private oidcConfig: client.Configuration | null = null
|
||||
|
||||
constructor(config: SsoConfigsSchemaTypeForSelect) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
private async getOidcConfig(): Promise<client.Configuration> {
|
||||
if (!this.oidcConfig) {
|
||||
const issuerUrl = new URL(this.config.oidcIssuer!)
|
||||
const isDev = process.env.NODE_ENV !== 'production'
|
||||
|
||||
this.oidcConfig = await client.discovery(
|
||||
issuerUrl,
|
||||
this.config.oidcClientId!,
|
||||
this.config.oidcClientSecret!,
|
||||
undefined,
|
||||
isDev ? { execute: [client.allowInsecureRequests] } : undefined,
|
||||
)
|
||||
}
|
||||
return this.oidcConfig
|
||||
}
|
||||
|
||||
async initiateLogin(_req: Request, res: Response, relayState?: string): Promise<void> {
|
||||
const config = await this.getOidcConfig()
|
||||
const scope = this.config.oidcScope ?? 'openid email profile'
|
||||
const codeVerifier = client.randomPKCECodeVerifier()
|
||||
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier)
|
||||
const csrfToken = randomBytes(32).toString('hex')
|
||||
const nonce = randomBytes(32).toString('hex')
|
||||
|
||||
const statePayload = JSON.stringify({
|
||||
csrf: csrfToken,
|
||||
relay: relayState ?? '',
|
||||
})
|
||||
|
||||
res.cookie(`sso_cv_${this.config.id}`, codeVerifier, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
res.cookie(`sso_state_${this.config.id}`, csrfToken, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
res.cookie(`sso_nonce_${this.config.id}`, nonce, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const params = new URLSearchParams({
|
||||
redirect_uri: this.config.oidcCallbackUrl!,
|
||||
scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
response_type: 'code',
|
||||
state: statePayload,
|
||||
nonce,
|
||||
})
|
||||
|
||||
const authUrl = client.buildAuthorizationUrl(config, params)
|
||||
res.redirect(authUrl.href)
|
||||
}
|
||||
|
||||
async handleCallback(req: Request): Promise<SsoAuthResult> {
|
||||
const config = await this.getOidcConfig()
|
||||
const codeVerifier = req.cookies[`sso_cv_${this.config.id}`]
|
||||
const storedCsrf = req.cookies[`sso_state_${this.config.id}`]
|
||||
const storedNonce = req.cookies[`sso_nonce_${this.config.id}`]
|
||||
|
||||
if (!codeVerifier) {
|
||||
throw new Error('Missing PKCE code verifier — session may have expired')
|
||||
}
|
||||
|
||||
if (!storedCsrf) {
|
||||
throw new Error('Missing CSRF state — session may have expired')
|
||||
}
|
||||
|
||||
if (!storedNonce) {
|
||||
throw new Error('Missing nonce — session may have expired')
|
||||
}
|
||||
|
||||
const returnedState = req.query.state as string | undefined
|
||||
if (!returnedState) {
|
||||
throw new Error('Missing state parameter in callback')
|
||||
}
|
||||
|
||||
let statePayload: { csrf: string, relay: string }
|
||||
try {
|
||||
statePayload = JSON.parse(returnedState)
|
||||
} catch {
|
||||
throw new Error('Invalid state parameter format')
|
||||
}
|
||||
|
||||
if (statePayload.csrf !== storedCsrf) {
|
||||
throw new Error('CSRF state mismatch — possible CSRF attack')
|
||||
}
|
||||
|
||||
const currentUrl = new URL(req.originalUrl, `${req.protocol}://${req.get('host')}`)
|
||||
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
|
||||
pkceCodeVerifier: codeVerifier,
|
||||
expectedState: returnedState,
|
||||
})
|
||||
|
||||
const claims = tokens.claims()
|
||||
|
||||
if (!claims || !claims.email) {
|
||||
throw new Error('OIDC token missing email claim')
|
||||
}
|
||||
|
||||
if (claims.nonce !== storedNonce) {
|
||||
throw new Error('Nonce mismatch — possible token replay attack')
|
||||
}
|
||||
|
||||
return {
|
||||
email: (claims.email as string).toLowerCase(),
|
||||
externalId: claims.sub,
|
||||
displayName: claims.name as string | undefined,
|
||||
provider: `oidc-${this.config.id}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import { decryptSsoConfig } from '../sso.utils'
|
||||
import type { SsoProvider } from './sso-provider.interface'
|
||||
import { SamlProvider } from './saml.provider'
|
||||
import { OidcProvider } from './oidc.provider'
|
||||
|
||||
export function createSsoProvider(config: SsoConfigsSchemaTypeForSelect): SsoProvider {
|
||||
const decrypted = decryptSsoConfig(config)
|
||||
switch (decrypted.protocol) {
|
||||
case 'saml':
|
||||
return new SamlProvider(decrypted)
|
||||
case 'oidc':
|
||||
return new OidcProvider(decrypted)
|
||||
default:
|
||||
throw new Error(`Unsupported SSO protocol: ${decrypted.protocol}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { CacheItem, CacheProvider } from '@node-saml/node-saml'
|
||||
import { eq, lt } from 'drizzle-orm'
|
||||
import { SamlRequestCacheSchema } from 'taskview-db-schemas'
|
||||
import { Database } from '../../../modules/db'
|
||||
|
||||
export class SamlDbCacheProvider implements CacheProvider {
|
||||
private readonly db: Database
|
||||
private readonly ttlMs: number
|
||||
|
||||
constructor(ttlMs: number = 5 * 60 * 1000) {
|
||||
this.db = Database.getInstance()
|
||||
this.ttlMs = ttlMs
|
||||
}
|
||||
|
||||
async saveAsync(key: string, value: string): Promise<CacheItem | null> {
|
||||
const createdAt = Date.now()
|
||||
await this.db.dbDrizzle
|
||||
.insert(SamlRequestCacheSchema)
|
||||
.values({ key, value, createdAt })
|
||||
.onConflictDoUpdate({
|
||||
target: SamlRequestCacheSchema.key,
|
||||
set: { value, createdAt },
|
||||
})
|
||||
this.cleanup()
|
||||
return { value, createdAt }
|
||||
}
|
||||
|
||||
async getAsync(key: string): Promise<string | null> {
|
||||
const result = await this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SamlRequestCacheSchema)
|
||||
.where(eq(SamlRequestCacheSchema.key, key))
|
||||
|
||||
if (!result.length) return null
|
||||
|
||||
const row = result[0]
|
||||
if (Date.now() - row.createdAt > this.ttlMs) {
|
||||
await this.removeAsync(key)
|
||||
return null
|
||||
}
|
||||
return row.value
|
||||
}
|
||||
|
||||
async removeAsync(key: string | null): Promise<string | null> {
|
||||
if (!key) return null
|
||||
const result = await this.db.dbDrizzle
|
||||
.delete(SamlRequestCacheSchema)
|
||||
.where(eq(SamlRequestCacheSchema.key, key))
|
||||
.returning()
|
||||
|
||||
return result[0]?.value ?? null
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
const cutoff = Date.now() - this.ttlMs
|
||||
this.db.dbDrizzle
|
||||
.delete(SamlRequestCacheSchema)
|
||||
.where(lt(SamlRequestCacheSchema.createdAt, cutoff))
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { SAML, ValidateInResponseTo } from '@node-saml/node-saml'
|
||||
import type { Request, Response } from 'express'
|
||||
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import type { SsoProvider, SsoAuthResult } from './sso-provider.interface'
|
||||
import { SamlDbCacheProvider } from './saml-cache-provider'
|
||||
|
||||
function normalizeCert(cert: string): string {
|
||||
return cert
|
||||
.replace(/-----BEGIN CERTIFICATE-----/g, '')
|
||||
.replace(/-----END CERTIFICATE-----/g, '')
|
||||
.replace(/[\s\r\n]/g, '')
|
||||
}
|
||||
|
||||
function buildSamlOptions(config: SsoConfigsSchemaTypeForSelect, mode: 'assertion' | 'response') {
|
||||
return {
|
||||
entryPoint: config.samlEntryPoint!,
|
||||
issuer: config.samlIssuer!,
|
||||
idpCert: normalizeCert(config.samlCert!),
|
||||
callbackUrl: config.samlCallbackUrl!,
|
||||
wantAssertionsSigned: mode === 'assertion',
|
||||
wantAuthnResponseSigned: mode === 'response',
|
||||
validateInResponseTo: ValidateInResponseTo.always,
|
||||
requestIdExpirationPeriodMs: 5 * 60 * 1000,
|
||||
cacheProvider: new SamlDbCacheProvider(),
|
||||
...(config.samlSigningKey && config.samlSigningCert ? {
|
||||
privateKey: config.samlSigningKey,
|
||||
signingCert: config.samlSigningCert,
|
||||
signatureAlgorithm: 'sha256' as const,
|
||||
} : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export class SamlProvider implements SsoProvider {
|
||||
private readonly samlAssertion: SAML
|
||||
private readonly samlResponse: SAML
|
||||
private readonly config: SsoConfigsSchemaTypeForSelect
|
||||
|
||||
constructor(config: SsoConfigsSchemaTypeForSelect) {
|
||||
this.config = config
|
||||
this.samlAssertion = new SAML(buildSamlOptions(config, 'assertion'))
|
||||
this.samlResponse = new SAML(buildSamlOptions(config, 'response'))
|
||||
}
|
||||
|
||||
async initiateLogin(req: Request, res: Response, relayState?: string): Promise<void> {
|
||||
const loginUrl = await this.samlAssertion.getAuthorizeUrlAsync(relayState ?? '', req.hostname, {})
|
||||
res.redirect(loginUrl)
|
||||
}
|
||||
|
||||
async handleCallback(req: Request): Promise<SsoAuthResult> {
|
||||
let profile
|
||||
|
||||
try {
|
||||
const result = await this.samlAssertion.validatePostResponseAsync(req.body)
|
||||
profile = result.profile
|
||||
} catch {
|
||||
const result = await this.samlResponse.validatePostResponseAsync(req.body)
|
||||
profile = result.profile
|
||||
}
|
||||
|
||||
if (!profile || !profile.nameID) {
|
||||
throw new Error('SAML response missing nameID')
|
||||
}
|
||||
|
||||
const email = (
|
||||
profile.email
|
||||
?? profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']
|
||||
?? profile.nameID
|
||||
) as string
|
||||
|
||||
return {
|
||||
email: email.toLowerCase(),
|
||||
externalId: profile.nameID,
|
||||
displayName: (profile.displayName
|
||||
?? profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name']) as string | undefined,
|
||||
provider: `saml-${this.config.id}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
export type SsoAuthResult = {
|
||||
email: string
|
||||
externalId: string
|
||||
displayName?: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type SsoProvider = {
|
||||
initiateLogin(req: Request, res: Response, relayState?: string): Promise<void>
|
||||
handleCallback(req: Request): Promise<SsoAuthResult>
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { DOMParser, type Document } from '@xmldom/xmldom'
|
||||
|
||||
type SamlMetadataResult = {
|
||||
samlEntryPoint: string
|
||||
samlCert: string
|
||||
samlLogoutUrl: string
|
||||
}
|
||||
|
||||
export function parseSamlMetadata(xml: string): SamlMetadataResult {
|
||||
const doc = new DOMParser().parseFromString(xml, 'text/xml')
|
||||
|
||||
const entryPoint = findSsoLocation(doc)
|
||||
const cert = findSigningCertificate(doc)
|
||||
const logoutUrl = findSloLocation(doc)
|
||||
|
||||
return { samlEntryPoint: entryPoint, samlCert: cert, samlLogoutUrl: logoutUrl }
|
||||
}
|
||||
|
||||
function findSsoLocation(doc: Document): string {
|
||||
const ssoNodes = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:metadata', 'SingleSignOnService')
|
||||
|
||||
for (let i = 0; i < ssoNodes.length; i++) {
|
||||
const node = ssoNodes[i]
|
||||
const binding = node.getAttribute('Binding') ?? ''
|
||||
|
||||
if (binding.includes('HTTP-POST') || binding.includes('HTTP-Redirect')) {
|
||||
return node.getAttribute('Location') ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function findSloLocation(doc: Document): string {
|
||||
const sloNodes = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:metadata', 'SingleLogoutService')
|
||||
|
||||
for (let i = 0; i < sloNodes.length; i++) {
|
||||
const node = sloNodes[i]
|
||||
const binding = node.getAttribute('Binding') ?? ''
|
||||
|
||||
if (binding.includes('HTTP-POST') || binding.includes('HTTP-Redirect')) {
|
||||
return node.getAttribute('Location') ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function findSigningCertificate(doc: Document): string {
|
||||
const keyDescriptors = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:metadata', 'KeyDescriptor')
|
||||
|
||||
for (let i = 0; i < keyDescriptors.length; i++) {
|
||||
const descriptor = keyDescriptors[i]
|
||||
const use = descriptor.getAttribute('use')
|
||||
|
||||
if (use && use !== 'signing') continue
|
||||
|
||||
const certNodes = descriptor.getElementsByTagNameNS('http://www.w3.org/2000/09/xmldsig#', 'X509Certificate')
|
||||
|
||||
if (certNodes.length > 0) {
|
||||
const certText = certNodes[0].textContent ?? ''
|
||||
return certText.replace(/[\s\r\n]/g, '')
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import { decryptField } from '../../utils/crypto'
|
||||
import { generateString } from '../../utils/helpers'
|
||||
|
||||
export const SSO_SECRET_FIELDS = ['samlCert', 'samlSigningKey', 'samlSigningCert', 'oidcClientSecret'] as const
|
||||
|
||||
export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) {
|
||||
const { samlCert, samlSigningKey, samlSigningCert, oidcClientSecret, scimToken, ...safe } = config
|
||||
return {
|
||||
...safe,
|
||||
hasSamlCert: !!samlCert,
|
||||
hasSamlSigningKey: !!samlSigningKey,
|
||||
hasSamlSigningCert: !!samlSigningCert,
|
||||
hasOidcClientSecret: !!oidcClientSecret,
|
||||
hasScimToken: !!scimToken,
|
||||
}
|
||||
}
|
||||
|
||||
export function decryptSsoConfig(config: SsoConfigsSchemaTypeForSelect): SsoConfigsSchemaTypeForSelect {
|
||||
return {
|
||||
...config,
|
||||
samlCert: decryptField(config.samlCert),
|
||||
samlSigningKey: decryptField(config.samlSigningKey),
|
||||
samlSigningCert: decryptField(config.samlSigningCert),
|
||||
oidcClientSecret: decryptField(config.oidcClientSecret),
|
||||
}
|
||||
}
|
||||
|
||||
export function generateLoginCode(): string {
|
||||
return `${generateString(12)}:${Date.now()}`.toLowerCase()
|
||||
}
|
||||
|
||||
const BLOCKED_HOSTNAMES = ['localhost', '127.0.0.1', '0.0.0.0', '[::1]']
|
||||
const PRIVATE_IP_RANGES = [
|
||||
/^10\./,
|
||||
/^172\.(1[6-9]|2\d|3[01])\./,
|
||||
/^192\.168\./,
|
||||
/^169\.254\./,
|
||||
/^fc00:/,
|
||||
/^fd/,
|
||||
/^fe80:/,
|
||||
]
|
||||
|
||||
export function validateMetadataUrl(url: string): string | null {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
return 'Invalid URL format'
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'https:' && process.env.NODE_ENV === 'production') {
|
||||
return 'Only HTTPS URLs are allowed'
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
return 'Only HTTP(S) URLs are allowed'
|
||||
}
|
||||
|
||||
if (BLOCKED_HOSTNAMES.includes(parsed.hostname)) {
|
||||
return 'Localhost URLs are not allowed'
|
||||
}
|
||||
|
||||
for (const range of PRIVATE_IP_RANGES) {
|
||||
if (range.test(parsed.hostname)) {
|
||||
return 'Private IP addresses are not allowed'
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { type } from 'arktype'
|
||||
|
||||
export const SsoProtocols = {
|
||||
SAML: 'saml',
|
||||
OIDC: 'oidc',
|
||||
} as const
|
||||
|
||||
export type SsoProtocol = typeof SsoProtocols[keyof typeof SsoProtocols]
|
||||
|
||||
export const SsoConfigArkTypeCreate = type({
|
||||
organizationId: 'number',
|
||||
protocol: "'saml' | 'oidc'",
|
||||
displayName: 'string > 0',
|
||||
'enabled?': 'number',
|
||||
|
||||
'samlEntryPoint?': 'string',
|
||||
'samlIssuer?': 'string',
|
||||
'samlCert?': 'string',
|
||||
'samlCallbackUrl?': 'string',
|
||||
'samlSigningKey?': 'string',
|
||||
'samlSigningCert?': 'string',
|
||||
'samlLogoutUrl?': 'string',
|
||||
|
||||
'oidcIssuer?': 'string',
|
||||
'oidcClientId?': 'string',
|
||||
'oidcClientSecret?': 'string',
|
||||
'oidcCallbackUrl?': 'string',
|
||||
'oidcScope?': 'string',
|
||||
|
||||
'defaultOrgRole?': "'admin' | 'member'",
|
||||
emailDomainRestriction: 'string > 0',
|
||||
})
|
||||
|
||||
export type SsoConfigArgCreate = typeof SsoConfigArkTypeCreate.infer
|
||||
|
||||
export const SsoConfigArkTypeUpdate = type({
|
||||
'displayName?': 'string',
|
||||
'enabled?': 'number',
|
||||
|
||||
'samlEntryPoint?': 'string',
|
||||
'samlIssuer?': 'string',
|
||||
'samlCert?': 'string',
|
||||
'samlCallbackUrl?': 'string',
|
||||
'samlSigningKey?': 'string',
|
||||
'samlSigningCert?': 'string',
|
||||
'samlLogoutUrl?': 'string',
|
||||
|
||||
'oidcIssuer?': 'string',
|
||||
'oidcClientId?': 'string',
|
||||
'oidcClientSecret?': 'string',
|
||||
'oidcCallbackUrl?': 'string',
|
||||
'oidcScope?': 'string',
|
||||
|
||||
'defaultOrgRole?': "'admin' | 'member'",
|
||||
'emailDomainRestriction?': 'string',
|
||||
})
|
||||
|
||||
export type SsoConfigArgUpdate = typeof SsoConfigArkTypeUpdate.infer
|
||||
@@ -2,17 +2,20 @@ import type { Request, Response } from 'express';
|
||||
|
||||
export class StartController {
|
||||
fetchAllLists = async (req: Request, res: Response) => {
|
||||
return res.tvJson(await req.appUser.startManager.fetchAllLists());
|
||||
const organizationId = req.query.organizationId ? Number(req.query.organizationId) : undefined;
|
||||
return res.tvJson(await req.appUser.startManager.fetchAllLists(organizationId));
|
||||
};
|
||||
|
||||
fetchAllState = async (req: Request, res: Response) => {
|
||||
if (!req?.query?.tz) {
|
||||
return res.status(400).send('tz is required');
|
||||
}
|
||||
return res.tvJson(await req.appUser.startManager.fetchAllState(req?.query?.tz as string));
|
||||
const organizationId = req.query.organizationId ? Number(req.query.organizationId) : undefined;
|
||||
return res.tvJson(await req.appUser.startManager.fetchAllState(req.query.tz as string, organizationId));
|
||||
};
|
||||
|
||||
searchTaskInAllProjects = async (req: Request, res: Response) => {
|
||||
return res.tvJson(await req.appUser.startManager.searchTaskInAllProjects(req?.query?.description as string));
|
||||
const organizationId = req.query.organizationId ? Number(req.query.organizationId) : undefined;
|
||||
return res.tvJson(await req.appUser.startManager.searchTaskInAllProjects(req?.query?.description as string, organizationId));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,14 +15,14 @@ export class StartManager {
|
||||
this.repository = new StartRepository(this.user);
|
||||
}
|
||||
|
||||
async fetchAllLists() {
|
||||
return await this.repository.fetchAllLists(this.user);
|
||||
async fetchAllLists(organizationId?: number) {
|
||||
return await this.repository.fetchAllLists(this.user, organizationId);
|
||||
}
|
||||
|
||||
async fetchAllState(tz: string) {
|
||||
await this.fetchSharedGoals();
|
||||
const goalIds = await this.getAllGoalsIds();
|
||||
const usersAndProjects = await this.fetchProjectsAndUsers();
|
||||
async fetchAllState(tz: string, organizationId?: number) {
|
||||
await this.fetchSharedGoals(organizationId);
|
||||
const goalIds = await this.getAllGoalsIds(organizationId);
|
||||
const usersAndProjects = await this.fetchProjectsAndUsers(organizationId);
|
||||
const tasks = await this.repository.fetchAllActiveTasksForGoals(goalIds, usersAndProjects.assignees);
|
||||
const tasksToday = await this.repository.fetchAllTodayTasksForGoals(goalIds, tz, usersAndProjects.assignees);
|
||||
const tasksUpcoming = await this.repository.fetchUpcomingTasksForGoals(goalIds, tz, usersAndProjects.assignees);
|
||||
@@ -40,70 +40,54 @@ export class StartManager {
|
||||
};
|
||||
}
|
||||
|
||||
async fetchSharedGoals() {
|
||||
this.sharedGoals = await this.user.goalsManager.fetchSharedGoals();
|
||||
async fetchSharedGoals(organizationId?: number) {
|
||||
this.sharedGoals = await this.user.goalsManager.fetchSharedGoals(organizationId);
|
||||
}
|
||||
|
||||
async getAllGoalsIds() {
|
||||
const ownGoals = await this.repository.db
|
||||
.query<{ id: number }>('select id from tasks.goals where owner = $1 and archive = $2', [
|
||||
this.user.getUserData()?.id,
|
||||
0,
|
||||
])
|
||||
.catch(logError);
|
||||
|
||||
if (!ownGoals) {
|
||||
$logger.error(`Can not fetch own goals for all state`);
|
||||
}
|
||||
|
||||
const ids: number[] = [];
|
||||
async getAllGoalsIds(organizationId?: number) {
|
||||
const ownGoalIds = await this.user.goalsManager.fetchAllOwnGoalsIds(organizationId);
|
||||
|
||||
const sharedGoalIds: number[] = [];
|
||||
this.sharedGoals.forEach((g) => {
|
||||
if (g.hasPermissions(GoalPermissions.GOAL_CAN_WATCH_CONTENT)) {
|
||||
ids.push(g.id);
|
||||
sharedGoalIds.push(g.id);
|
||||
}
|
||||
});
|
||||
|
||||
if (ownGoals) {
|
||||
ownGoals.rows.forEach((g) => {
|
||||
ids.push(g.id);
|
||||
});
|
||||
}
|
||||
|
||||
return ids;
|
||||
const ids = new Set([...ownGoalIds, ...sharedGoalIds]);
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
async fetchProjectsAndUsers() {
|
||||
const goalsIdsForUsers: number[] = [];
|
||||
const goalsIdsForAssignee: number[] = [];
|
||||
async fetchProjectsAndUsers(organizationId?: number) {
|
||||
const ownGoalIds = await this.user.goalsManager.fetchAllOwnGoalsIds(organizationId)
|
||||
|
||||
const goalsIdsForUsers: number[] = [...ownGoalIds]
|
||||
const goalsIdsForAssignee: number[] = [...ownGoalIds]
|
||||
|
||||
this.sharedGoals.forEach((goal) => {
|
||||
if (goal.hasPermissions(GoalPermissions.GOAL_CAN_MANAGE_USERS)) {
|
||||
goalsIdsForUsers.push(goal.id);
|
||||
goalsIdsForUsers.push(goal.id)
|
||||
}
|
||||
|
||||
if (goal.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_ASSIGNED_USERS)) {
|
||||
goalsIdsForAssignee.push(goal.id);
|
||||
goalsIdsForAssignee.push(goal.id)
|
||||
}
|
||||
}, []);
|
||||
})
|
||||
|
||||
const users = await this.repository.fetchUsersByProjects(this.user.getUserData()?.id!, goalsIdsForUsers);
|
||||
const assignees = await this.repository.fetchAssigneesForTasks(
|
||||
this.user.getUserData()?.id!,
|
||||
goalsIdsForAssignee
|
||||
);
|
||||
const users = await this.repository.fetchUsersByProjects(goalsIdsForUsers)
|
||||
const assignees = await this.repository.fetchAssigneesForTasks(goalsIdsForAssignee)
|
||||
|
||||
return {
|
||||
users,
|
||||
assignees,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async searchTaskInAllProjects(description?: string) {
|
||||
async searchTaskInAllProjects(description?: string, organizationId?: number) {
|
||||
if (!description) return [];
|
||||
|
||||
await this.fetchSharedGoals();
|
||||
const goalIds = await this.getAllGoalsIds();
|
||||
await this.fetchSharedGoals(organizationId);
|
||||
const goalIds = await this.getAllGoalsIds(organizationId);
|
||||
|
||||
const tasks = await this.repository.searchTask(description.trim(), goalIds);
|
||||
return tasks;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { and, eq, inArray, isNotNull, or } from 'drizzle-orm';
|
||||
import { GoalsSchema, GoalsListSchema } from 'taskview-db-schemas';
|
||||
import type { AppUser } from '../../core/AppUser';
|
||||
import { Database } from '../../modules/db';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import type { TaskItemInDb } from '../../types/tasks.types';
|
||||
import { logError } from '../../utils/api';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import type { TagToTaskInDb } from '../tags/tags.types';
|
||||
import { TaskItemForClient } from '../tasks/TaskItemForClient';
|
||||
import type { AssigneesForTaskFromDb, FetchAllListsResult, UsersByProjectsFromDb } from './start.types';
|
||||
@@ -17,95 +20,87 @@ export class StartRepository {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
async fetchAllLists(user: AppUser): Promise<FetchAllListsResult[] | false> {
|
||||
const sharedGoals = await user.goalsManager.fetchSharedGoals();
|
||||
const sharedGoalsIds = sharedGoals.map((g) => g.id);
|
||||
const archive = 0;
|
||||
async fetchAllLists(user: AppUser, organizationId?: number): Promise<FetchAllListsResult[] | false> {
|
||||
const userId = user.getUserData()?.id
|
||||
if (!userId) return false
|
||||
|
||||
if (!user.getUserData()?.id) {
|
||||
return false;
|
||||
const sharedGoals = await user.goalsManager.fetchSharedGoals(organizationId)
|
||||
const sharedGoalIds = sharedGoals.map((g) => g.id)
|
||||
|
||||
const ownConditions = [eq(GoalsSchema.owner, userId)]
|
||||
if (organizationId) {
|
||||
ownConditions.push(eq(GoalsSchema.organizationId, organizationId))
|
||||
}
|
||||
|
||||
let args: any[] = [archive, user.getUserData()?.id];
|
||||
const goalConditions = sharedGoalIds.length > 0
|
||||
? or(and(...ownConditions), inArray(GoalsSchema.id, sharedGoalIds))
|
||||
: and(...ownConditions)
|
||||
|
||||
let query = `select g.name as "goalName", g.id as "goalId", gl.name as "listName", gl.id as "listId"
|
||||
from tasks.goals g
|
||||
left join tasks.goal_lists gl on gl.goal_id = g.id
|
||||
where gl.archive = $1
|
||||
and g.owner = $2
|
||||
and gl.id is not null `;
|
||||
const placeholders = sharedGoalsIds.map((_item, index) => `$${index + 3}`);
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select({
|
||||
goalName: GoalsSchema.name,
|
||||
goalId: GoalsSchema.id,
|
||||
listName: GoalsListSchema.name,
|
||||
listId: GoalsListSchema.id,
|
||||
})
|
||||
.from(GoalsSchema)
|
||||
.leftJoin(GoalsListSchema, eq(GoalsListSchema.goalId, GoalsSchema.id))
|
||||
.where(
|
||||
and(
|
||||
eq(GoalsListSchema.archive, 0),
|
||||
isNotNull(GoalsListSchema.id),
|
||||
goalConditions,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (sharedGoalsIds.length > 0) {
|
||||
args = [...args, ...sharedGoalsIds];
|
||||
query += ` or g.id in (${placeholders.join(',')})`;
|
||||
}
|
||||
|
||||
const result = await this.db.query<FetchAllListsResult>(query, args).catch(logError);
|
||||
return result?.rows || [];
|
||||
return result ?? []
|
||||
}
|
||||
|
||||
async fetchUsersByProjects(owner: number, sharedGoalsIds: number[] = []) {
|
||||
let additionalRules = '';
|
||||
const args: number[] = [owner];
|
||||
async fetchUsersByProjects(goalIds: number[]) {
|
||||
if (goalIds.length === 0) return []
|
||||
|
||||
if (sharedGoalsIds.length > 0) {
|
||||
const placeholders = sharedGoalsIds.map((id, index) => {
|
||||
args.push(id);
|
||||
return `$${index + 2}`;
|
||||
});
|
||||
additionalRules = ` or g.id in (${placeholders.join(',')}) `;
|
||||
}
|
||||
const args = goalIds
|
||||
const placeholders = goalIds.map((_, i) => `$${i + 1}`).join(',')
|
||||
|
||||
const query = `SELECT
|
||||
const query = `SELECT
|
||||
g.id, g.name,
|
||||
COALESCE(json_agg(json_build_object('id', u.id, 'email', u.email)) FILTER (WHERE u.id IS NOT NULL), '[]') AS users
|
||||
FROM
|
||||
FROM
|
||||
tasks.goals g
|
||||
left join collaboration.users_to_goals utg on utg.goal_id = g.id
|
||||
LEFT JOIN
|
||||
collaboration.users u ON u.id = utg.user_id
|
||||
WHERE
|
||||
g.owner = $1 ${additionalRules}
|
||||
GROUP BY
|
||||
g.id, g.name;`;
|
||||
LEFT JOIN collaboration.users_to_goals utg ON utg.goal_id = g.id
|
||||
LEFT JOIN collaboration.users u ON u.id = utg.user_id
|
||||
WHERE g.id IN (${placeholders})
|
||||
GROUP BY g.id, g.name;`
|
||||
|
||||
const result = await this.db.query<UsersByProjectsFromDb>(query, args);
|
||||
const result = await this.db.query<UsersByProjectsFromDb>(query, args)
|
||||
|
||||
if (!result) {
|
||||
$logger.error(`Can not fetch user by projects`);
|
||||
return [];
|
||||
$logger.error(`Can not fetch user by projects`)
|
||||
return []
|
||||
}
|
||||
|
||||
return result.rows;
|
||||
return result.rows
|
||||
}
|
||||
|
||||
async fetchAssigneesForTasks(owner: number, goalsIds: number[] = []) {
|
||||
let assigneeQuery = `select cu.email, ta.task_id as "taskId", ta.collab_user_id as "collabUserId"
|
||||
from tasks.tasks tt
|
||||
inner join tasks_auth.task_assignee ta on ta.task_id = tt.id
|
||||
inner join collaboration.users cu on cu.id = ta.collab_user_id
|
||||
where ta.collab_user_id is not null`;
|
||||
async fetchAssigneesForTasks(goalIds: number[]) {
|
||||
if (goalIds.length === 0) return []
|
||||
|
||||
const args: number[] = [owner];
|
||||
const placeholders = goalIds.map((_, i) => `$${i + 1}`).join(',')
|
||||
|
||||
if (goalsIds.length > 0) {
|
||||
const placeholders = goalsIds.map((id, index) => {
|
||||
args.push(id);
|
||||
return `$${index + 2}`;
|
||||
});
|
||||
assigneeQuery += ` and (tt.owner = $1 or tt.goal_id in (${placeholders.join(',')})) `;
|
||||
} else {
|
||||
assigneeQuery += ` and (tt.owner = $1)`;
|
||||
}
|
||||
const query = `SELECT cu.email, ta.task_id AS "taskId", ta.collab_user_id AS "collabUserId"
|
||||
FROM tasks.tasks tt
|
||||
INNER JOIN tasks_auth.task_assignee ta ON ta.task_id = tt.id
|
||||
INNER JOIN collaboration.users cu ON cu.id = ta.collab_user_id
|
||||
WHERE ta.collab_user_id IS NOT NULL
|
||||
AND tt.goal_id IN (${placeholders})`
|
||||
|
||||
const result = await this.db.query<AssigneesForTaskFromDb>(assigneeQuery, args);
|
||||
const result = await this.db.query<AssigneesForTaskFromDb>(query, goalIds)
|
||||
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
if (!result) return []
|
||||
|
||||
return result.rows;
|
||||
return result.rows
|
||||
}
|
||||
|
||||
async fetchAllActiveTasksForGoals(
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import { StartController } from './StartController';
|
||||
import { Router } from 'express'
|
||||
import type { Routable } from '../../types/routable.type'
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
|
||||
import { IsOrgMemberIfProvided } from '../../middlewares/is-org-member'
|
||||
import { StartController } from './StartController'
|
||||
|
||||
export default class StartRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly controller: StartController;
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
private readonly controller: StartController
|
||||
|
||||
constructor() {
|
||||
this.router = Router();
|
||||
this.controller = new StartController();
|
||||
this.initRoutes();
|
||||
this.router = Router()
|
||||
this.controller = new StartController()
|
||||
this.initRoutes()
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router;
|
||||
return this.router
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('/fetch/lists', [IsLoggedIn], this.controller.fetchAllLists);
|
||||
this.router.get('/fetchallstate', [IsLoggedIn], this.controller.fetchAllState);
|
||||
this.router.get('/search-task', [IsLoggedIn], this.controller.searchTaskInAllProjects);
|
||||
this.router.get('/fetch/lists', [IsLoggedIn, IsOrgMemberIfProvided], this.controller.fetchAllLists)
|
||||
this.router.get('/fetchallstate', [IsLoggedIn, IsOrgMemberIfProvided], this.controller.fetchAllState)
|
||||
this.router.get('/search-task', [IsLoggedIn, IsOrgMemberIfProvided], this.controller.searchTaskInAllProjects)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export type FetchAllListsResult = {
|
||||
goalName: string;
|
||||
listName: string;
|
||||
listId: number;
|
||||
goalName: string | null;
|
||||
listName: string | null;
|
||||
listId: number | null;
|
||||
goalId: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -39,7 +39,8 @@ export class TagsController {
|
||||
};
|
||||
|
||||
fetchAllTagsForUser = async (req: Request, res: Response) => {
|
||||
return res.tvJson(await req.appUser.tagsManager.fetchAllTagsForUser());
|
||||
const organizationId = req.query.organizationId ? Number(req.query.organizationId) : undefined;
|
||||
return res.tvJson(await req.appUser.tagsManager.fetchAllTagsForUser(organizationId));
|
||||
};
|
||||
|
||||
/** @deprecated use toggleTagNew instead */
|
||||
|
||||
@@ -51,13 +51,13 @@ export class TagsManager {
|
||||
return tags.map((item) => new TagItemForClient(item));
|
||||
}
|
||||
|
||||
async fetchAllTagsForUser() {
|
||||
async fetchAllTagsForUser(organizationId?: number) {
|
||||
const userId = this.user.getUserData()?.id;
|
||||
if (!userId) {
|
||||
$logger.error(`Can not fetch tags for undefined user`);
|
||||
return [];
|
||||
}
|
||||
const tags = await this.repository.fetchAllTagsForUser(userId);
|
||||
const tags = await this.repository.fetchAllTagsForUser(userId, organizationId);
|
||||
return tags;
|
||||
}
|
||||
|
||||
|
||||
@@ -109,9 +109,15 @@ export class TagsRepository {
|
||||
return tags.rows;
|
||||
}
|
||||
|
||||
async fetchAllTagsForUser(userId: number): Promise<TagsSchemaTypeForSelect[]> {
|
||||
async fetchAllTagsForUser(userId: number, organizationId?: number): Promise<TagsSchemaTypeForSelect[]> {
|
||||
const ownTagsConditions = [eq(TagsSchema.owner, userId)];
|
||||
if (organizationId) {
|
||||
ownTagsConditions.push(
|
||||
inArray(TagsSchema.goalId, this.db.dbDrizzle.select({ id: GoalsSchema.id }).from(GoalsSchema).where(eq(GoalsSchema.organizationId, organizationId)))
|
||||
);
|
||||
}
|
||||
const ownTags = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(TagsSchema).where(eq(TagsSchema.owner, userId))
|
||||
this.db.dbDrizzle.select().from(TagsSchema).where(and(...ownTagsConditions))
|
||||
);
|
||||
|
||||
const allTags: TagsSchemaTypeForSelect[] = [];
|
||||
@@ -120,7 +126,7 @@ export class TagsRepository {
|
||||
allTags.push(...ownTags);
|
||||
}
|
||||
|
||||
const sharedGoals = await this.user.goalsManager.fetchSharedGoals();
|
||||
const sharedGoals = await this.user.goalsManager.fetchSharedGoals(organizationId);
|
||||
|
||||
if (sharedGoals.length > 0) {
|
||||
const goalIds: number[] = [];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import { IsOrgMemberIfProvided } from '../../middlewares/is-org-member';
|
||||
import { CanAddTag } from './middlewares/CanAddTag';
|
||||
import { CanDeleteTag } from './middlewares/CanDeleteTag';
|
||||
import { CanToggleTag } from './middlewares/CanToggleTag';
|
||||
@@ -40,7 +41,7 @@ export default class TagsRouter implements Routable {
|
||||
/**
|
||||
* Fetch tags for goal
|
||||
*/
|
||||
this.router.get('', [IsLoggedIn], this.controller.fetchAllTagsForUser);
|
||||
this.router.get('', [IsLoggedIn, IsOrgMemberIfProvided], this.controller.fetchAllTagsForUser);
|
||||
|
||||
/**
|
||||
* Toggle tag for task
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import { WebhooksController } from './WebhooksController';
|
||||
import { Router } from 'express'
|
||||
import type { Routable } from '../../types/routable.type'
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
|
||||
import { WebhooksController } from './WebhooksController'
|
||||
import { IsGoalOwnerByGoalId, IsGoalOwnerByWebhookId } from './middlewares/IsGoalOwner'
|
||||
|
||||
export default class WebhooksRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly controller: WebhooksController;
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
private readonly controller: WebhooksController
|
||||
|
||||
constructor() {
|
||||
this.router = Router();
|
||||
this.controller = new WebhooksController();
|
||||
this.initRoutes();
|
||||
this.router = Router()
|
||||
this.controller = new WebhooksController()
|
||||
this.initRoutes()
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router;
|
||||
return this.router
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('', [IsLoggedIn], this.controller.fetch);
|
||||
this.router.post('', [IsLoggedIn], this.controller.create);
|
||||
this.router.patch('', [IsLoggedIn], this.controller.update);
|
||||
this.router.delete('', [IsLoggedIn], this.controller.delete);
|
||||
this.router.post('/rotate-secret', [IsLoggedIn], this.controller.rotateSecret);
|
||||
this.router.post('/test', [IsLoggedIn], this.controller.testDelivery);
|
||||
this.router.get('/deliveries/:id', [IsLoggedIn], this.controller.fetchDeliveries);
|
||||
this.router.post('/retry', [IsLoggedIn], this.controller.retryDelivery);
|
||||
this.router.get('', [IsLoggedIn, IsGoalOwnerByGoalId], this.controller.fetch)
|
||||
this.router.post('', [IsLoggedIn, IsGoalOwnerByGoalId], this.controller.create)
|
||||
this.router.patch('', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.update)
|
||||
this.router.delete('', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.delete)
|
||||
this.router.post('/rotate-secret', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.rotateSecret)
|
||||
this.router.post('/test', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.testDelivery)
|
||||
this.router.get('/deliveries/:id', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.fetchDeliveries)
|
||||
this.router.post('/retry', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.retryDelivery)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { GoalsSchema, WebhooksSchema } from 'taskview-db-schemas'
|
||||
import { Database } from '../../../modules/db'
|
||||
|
||||
export const IsGoalOwnerByGoalId = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const goalId = Number(req.body.goalId || req.query.goalId)
|
||||
const userId = req.appUser.getUserData()?.id
|
||||
|
||||
if (!goalId || !userId) return res.status(400).end()
|
||||
|
||||
const db = Database.getInstance()
|
||||
const goals = await db.dbDrizzle
|
||||
.select({ owner: GoalsSchema.owner })
|
||||
.from(GoalsSchema)
|
||||
.where(eq(GoalsSchema.id, goalId))
|
||||
|
||||
if (!goals.length || goals[0].owner !== userId) return res.status(403).end()
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
export const IsGoalOwnerByWebhookId = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const webhookId = Number(req.body.id || req.params.id)
|
||||
const userId = req.appUser.getUserData()?.id
|
||||
|
||||
if (!webhookId || !userId) return res.status(400).end()
|
||||
|
||||
const db = Database.getInstance()
|
||||
const result = await db.dbDrizzle
|
||||
.select({ owner: GoalsSchema.owner })
|
||||
.from(WebhooksSchema)
|
||||
.innerJoin(GoalsSchema, eq(WebhooksSchema.goalId, GoalsSchema.id))
|
||||
.where(eq(WebhooksSchema.id, webhookId))
|
||||
|
||||
if (!result.length || result[0].owner !== userId) return res.status(403).end()
|
||||
|
||||
next()
|
||||
}
|
||||
@@ -34,14 +34,6 @@ export type UserJwtPayload = z.infer<typeof UserJwtPayloadSchema>; //{ id: numbe
|
||||
|
||||
export type RegisterUserInDb = z.infer<typeof RegisterUserInDbSchema>;
|
||||
|
||||
export type TokensFromDb = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
user_ip: string;
|
||||
time_creation: string;
|
||||
};
|
||||
|
||||
export const ConfirmEmailReqDataSchema = z.object({
|
||||
login: z.string(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
import { $logger } from '../modules/logget';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
@@ -31,3 +32,18 @@ export function decrypt(encrypted: string): string {
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
export function encryptField(value: string | null | undefined): string | null {
|
||||
if (!value) return null
|
||||
return encrypt(value)
|
||||
}
|
||||
|
||||
export function decryptField(value: string | null): string | null {
|
||||
if (!value) return null
|
||||
try {
|
||||
return decrypt(value)
|
||||
} catch {
|
||||
$logger.warn('Failed to decrypt field — returning raw value (possible migration or key mismatch)')
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { randomInt } from 'crypto';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { $logger } from '../modules/logget';
|
||||
|
||||
export function isEmail(email: string): boolean {
|
||||
@@ -7,15 +9,13 @@ export function isEmail(email: string): boolean {
|
||||
}
|
||||
|
||||
export function generateString(length: number) {
|
||||
let result = '';
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const charactersLength = characters.length;
|
||||
let counter = 0;
|
||||
while (counter < length) {
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||
counter += 1;
|
||||
let result = ''
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
const charactersLength = characters.length
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(randomInt(charactersLength))
|
||||
}
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
|
||||
export function time() {
|
||||
@@ -42,6 +42,24 @@ export async function callWithCatch<T>(func: () => Promise<T>): Promise<T | null
|
||||
}
|
||||
|
||||
|
||||
export function parseDeviceName(userAgent: string | undefined): string {
|
||||
if (!userAgent) return 'Unknown'
|
||||
const parser = new UAParser(userAgent)
|
||||
const result = parser.getResult()
|
||||
|
||||
const parts: string[] = []
|
||||
if (result.browser.name) {
|
||||
parts.push(result.browser.version ? `${result.browser.name} ${result.browser.version.split('.')[0]}` : result.browser.name)
|
||||
}
|
||||
if (result.device.model && result.device.model !== 'undefined') {
|
||||
parts.push(result.device.model)
|
||||
} else if (result.os.name) {
|
||||
parts.push(result.os.name)
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(', ') : 'Unknown'
|
||||
}
|
||||
|
||||
export const chunk = <T>(array: T[], size: number): T[][] => {
|
||||
if (!Array.isArray(array)) {
|
||||
throw new TypeError('Expected array');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user