mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aff4c77f2 | |||
| 1d6af3ab7d | |||
| 5600d261bc | |||
| 35776d9eb5 | |||
| 9f0cfccdc6 | |||
| ba17eff713 | |||
| 08ee2af865 |
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.32.0",
|
||||
"version": "1.41.0",
|
||||
"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",
|
||||
@@ -69,6 +70,7 @@
|
||||
"semver": "^7.6.3",
|
||||
"taskview-db-schemas": "workspace:^",
|
||||
"terser": "^5.36.0",
|
||||
"ua-parser-js": "^2.0.9",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -24,6 +24,9 @@ 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;
|
||||
@@ -75,4 +78,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -372,5 +372,62 @@
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -6,6 +6,8 @@ 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';
|
||||
@@ -29,6 +31,8 @@ const routes: Record<string, RoutableConstructor> = {
|
||||
'/module/integrations': IntegrationsRoutes,
|
||||
'/module/notifications': NotificationsRoutes,
|
||||
'/module/webhooks': WebhooksRoutes,
|
||||
'/module/api-tokens': ApiTokensRoutes,
|
||||
'/module/sessions': SessionsRoutes,
|
||||
};
|
||||
|
||||
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_';
|
||||
@@ -118,7 +118,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 +128,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 +139,17 @@ 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}`);
|
||||
$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 +157,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,8 +203,8 @@ 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`);
|
||||
}
|
||||
|
||||
userData = await req.appUser.authManager.repository.getUserByLogin(
|
||||
@@ -217,7 +214,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 +223,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 +249,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,26 +316,20 @@ 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) {
|
||||
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);
|
||||
@@ -335,7 +349,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 +358,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);
|
||||
@@ -501,7 +508,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 +558,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 +569,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 +606,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 +643,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +122,15 @@ 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) => {
|
||||
|
||||
@@ -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,10 @@ export class CollaborationManager {
|
||||
}
|
||||
|
||||
async addUserNew(args: CollaborationArgAddUser): Promise<CollaborationUserWithRoles | null> {
|
||||
const user = await this.repository.addUserForCollaborationNew(args);
|
||||
const user = await this.repository.addUserForCollaborationNew({
|
||||
...args,
|
||||
email: args.email.toLowerCase(),
|
||||
});
|
||||
if (!user) return null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -142,20 +142,30 @@ export default class GoalsManager {
|
||||
const sharedGoals = await this.goalsRepository.fetchSharedGoalsForUser(this.user!);
|
||||
const ownGoals = await this.goalsRepository.fetchGoalsNew(this.user.getUserData()?.id!);
|
||||
|
||||
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: (
|
||||
|
||||
@@ -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,7 +26,10 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
|
||||
@@ -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,3 +1,4 @@
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { $logger } from '../modules/logget';
|
||||
|
||||
export function isEmail(email: string): boolean {
|
||||
@@ -42,6 +43,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');
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
title: API Tokens
|
||||
description: Create API tokens for programmatic access to TaskView. Tokens support permission scoping, project-level restrictions, and optional expiration.
|
||||
navigation:
|
||||
icon: i-lucide-key-round
|
||||
---
|
||||
|
||||
API tokens let you access the TaskView API without a browser session. Use them for scripts, CI/CD pipelines, bots, and any programmatic integration.
|
||||
|
||||
## Token format
|
||||
|
||||
Tokens use the prefix `tvk_` followed by 64 hex characters:
|
||||
|
||||
```
|
||||
tvk_a1b2c3d4e5f6...
|
||||
```
|
||||
|
||||
The full token is shown **only once** at creation. TaskView stores only the SHA-256 hash - if you lose the token, you'll need to create a new one.
|
||||
|
||||
## Creating a token
|
||||
|
||||
1. Go to **Account Settings** → **API Tokens**
|
||||
2. Click **"Create Token"**
|
||||
3. Enter a name (e.g. "CI pipeline", "Slack bot")
|
||||
4. Optionally restrict permissions and projects
|
||||
5. Optionally set an expiration date
|
||||
6. Click **"Create"**
|
||||
7. Copy the token immediately - it will not be shown again
|
||||
|
||||
## Authentication
|
||||
|
||||
Send the token in the `Authorization` header:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer tvk_a1b2c3d4..." \
|
||||
https://your-instance.com/module/tasks?goalId=1
|
||||
```
|
||||
|
||||
## Permission scoping
|
||||
|
||||
By default a token inherits all permissions of its owner. You can restrict this at creation:
|
||||
|
||||
- **Permissions** - select which operations the token can perform (e.g. only read tasks, only create tasks)
|
||||
- **Projects** - restrict the token to specific projects. If no projects are selected, the token has access to all projects the owner can access
|
||||
|
||||
Permissions are **intersected** with the user's RBAC role. A token cannot have more permissions than the user who created it. See [Roles and Permissions](/collaboration/roles-and-permissions) for details on how RBAC works.
|
||||
|
||||
### Available permission examples
|
||||
|
||||
| Permission | Description |
|
||||
|---|---|
|
||||
| `component_can_watch_content` | Read tasks and lists |
|
||||
| `component_can_add_tasks` | Create new tasks |
|
||||
| `task_can_edit_description` | Edit task descriptions |
|
||||
| `task_can_edit_status` | Change task status |
|
||||
| `task_can_delete` | Delete tasks |
|
||||
| `task_can_assign_users` | Assign users to tasks |
|
||||
|
||||
The full list of available permissions is returned by `GET /module/api-tokens/permissions`.
|
||||
|
||||
## Expiration
|
||||
|
||||
Tokens can optionally have an expiration date. After expiration, the token returns `401 Unauthorized`. Tokens without an expiration date are valid until manually revoked.
|
||||
|
||||
## Security
|
||||
|
||||
- Tokens **cannot manage other tokens** - all token management endpoints reject API token authentication
|
||||
- Only the SHA-256 hash is stored in the database
|
||||
- `lastUsedAt` is updated on each use for audit purposes
|
||||
- Tokens for blocked users are automatically rejected
|
||||
|
||||
## API reference
|
||||
|
||||
All endpoints require JWT authentication (not API token).
|
||||
|
||||
### List tokens
|
||||
|
||||
```
|
||||
GET /module/api-tokens
|
||||
```
|
||||
|
||||
Returns all tokens for the current user (without hashes).
|
||||
|
||||
### Create token
|
||||
|
||||
```
|
||||
POST /module/api-tokens
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "CI pipeline",
|
||||
"allowedPermissions": ["component_can_watch_content"],
|
||||
"allowedGoalIds": [1, 2],
|
||||
"expiresAt": "2026-12-31T23:59:59Z"
|
||||
}
|
||||
```
|
||||
|
||||
All fields except `name` are optional. Returns the full plaintext token once.
|
||||
|
||||
### Delete token
|
||||
|
||||
```
|
||||
DELETE /module/api-tokens
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 5
|
||||
}
|
||||
```
|
||||
|
||||
### List available permissions
|
||||
|
||||
```
|
||||
GET /module/api-tokens/permissions
|
||||
```
|
||||
|
||||
Returns permissions grouped by category.
|
||||
|
||||
## Usage example
|
||||
|
||||
Using the `taskview-api` package:
|
||||
|
||||
```bash
|
||||
npm install taskview-api axios
|
||||
```
|
||||
|
||||
```typescript
|
||||
import axios from 'axios'
|
||||
import { TvApi } from 'taskview-api'
|
||||
|
||||
const GOAL_ID = 1
|
||||
|
||||
const $axios = axios.create({
|
||||
baseURL: 'https://your-instance.com',
|
||||
headers: {
|
||||
Authorization: 'Bearer tvk_your_token_here',
|
||||
},
|
||||
})
|
||||
|
||||
const api = new TvApi($axios)
|
||||
|
||||
// Fetch all tasks in a project
|
||||
const tasks = await api.tasks.fetch({ goalId: GOAL_ID })
|
||||
console.log(`Found ${tasks.length} tasks`)
|
||||
|
||||
// Create a new task
|
||||
const newTask = await api.tasks.createTask({
|
||||
goalId: GOAL_ID,
|
||||
description: 'Task created via API',
|
||||
})
|
||||
console.log('Created task:', newTask.id)
|
||||
|
||||
// Update the task description
|
||||
await api.tasks.updateTask({
|
||||
id: newTask.id,
|
||||
description: 'Updated via API',
|
||||
})
|
||||
console.log('Task updated')
|
||||
|
||||
// Fetch projects
|
||||
const goals = await api.goals.fetchGoals()
|
||||
console.log('Projects:', goals.map((g) => g.name))
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: Sessions & Devices
|
||||
description: Manage active sessions in TaskView. View logged-in devices, close individual sessions, or sign out of all devices at once.
|
||||
navigation:
|
||||
icon: i-lucide-monitor-smartphone
|
||||
---
|
||||
|
||||
TaskView tracks every login as a separate session. You can see all active sessions, identify which device each session belongs to, and close sessions remotely.
|
||||
|
||||
## How sessions work
|
||||
|
||||
When you log in from any device (browser, mobile app), TaskView creates a session record with:
|
||||
|
||||
- **Device name** - automatically parsed from User-Agent (e.g. "Chrome 120, macOS", "Safari 17, iPhone")
|
||||
- **IP address** - the IP used at login time
|
||||
- **Created at** - when the session was created
|
||||
- **Last used** - when the session was last active
|
||||
|
||||
The JWT token issued at login contains the session ID. On each request, TaskView verifies that the session still exists - if it's been revoked, the token is rejected.
|
||||
|
||||
## Viewing sessions
|
||||
|
||||
1. Go to **Account** → **Sessions**
|
||||
2. See all active sessions with device name, IP, and timestamps
|
||||
3. Your current session is marked
|
||||
|
||||
## Closing a session
|
||||
|
||||
Click the close button on any session to revoke it. The user on that device will be signed out on their next request.
|
||||
|
||||
You cannot close your current session from this page - use the regular logout instead.
|
||||
|
||||
## Closing all other sessions
|
||||
|
||||
Click **"Close all other sessions"** to sign out of every device except the one you're currently using. Useful if you suspect unauthorized access.
|
||||
|
||||
## API reference
|
||||
|
||||
All endpoints require JWT authentication. API tokens cannot manage sessions.
|
||||
|
||||
### List sessions
|
||||
|
||||
```
|
||||
GET /module/sessions
|
||||
```
|
||||
|
||||
Returns all active sessions for the current user. Each session includes an `isCurrent` flag.
|
||||
|
||||
### Close a session
|
||||
|
||||
```
|
||||
DELETE /module/sessions
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42
|
||||
}
|
||||
```
|
||||
|
||||
Returns `400` if you try to close the current session.
|
||||
|
||||
### Close all other sessions
|
||||
|
||||
```
|
||||
DELETE /module/sessions/all
|
||||
```
|
||||
|
||||
Closes all sessions except the current one.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-monorepo",
|
||||
"version": "1.32.0",
|
||||
"version": "1.41.0",
|
||||
"private": true,
|
||||
"description": "TaskView CE monorepo containing web, API, and packages",
|
||||
"workspaces": [
|
||||
|
||||
Generated
+40
-4
@@ -138,6 +138,9 @@ importers:
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.9.3
|
||||
ua-parser-js:
|
||||
specifier: ^2.0.9
|
||||
version: 2.0.9
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.25.76
|
||||
@@ -178,6 +181,9 @@ importers:
|
||||
'@types/semver':
|
||||
specifier: ^7.5.8
|
||||
version: 7.7.1
|
||||
'@types/ua-parser-js':
|
||||
specifier: ^0.7.39
|
||||
version: 0.7.39
|
||||
aws-sdk:
|
||||
specifier: ^2.1691.0
|
||||
version: 2.1693.0
|
||||
@@ -187,9 +193,6 @@ importers:
|
||||
nock:
|
||||
specifier: ^13.5.5
|
||||
version: 13.5.6
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
vite:
|
||||
specifier: ^5.4.9
|
||||
version: 5.4.21(@types/node@22.19.7)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)
|
||||
@@ -3234,6 +3237,9 @@ packages:
|
||||
'@types/tough-cookie@4.0.5':
|
||||
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
|
||||
|
||||
'@types/ua-parser-js@0.7.39':
|
||||
resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==}
|
||||
|
||||
'@types/web-bluetooth@0.0.20':
|
||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||
|
||||
@@ -4575,6 +4581,9 @@ packages:
|
||||
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
|
||||
detect-europe-js@0.1.2:
|
||||
resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -5631,6 +5640,9 @@ packages:
|
||||
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-standalone-pwa@0.1.1:
|
||||
resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==}
|
||||
|
||||
is-stream@2.0.1:
|
||||
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -7557,6 +7569,13 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
ua-is-frozen@0.1.2:
|
||||
resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==}
|
||||
|
||||
ua-parser-js@2.0.9:
|
||||
resolution: {integrity: sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==}
|
||||
hasBin: true
|
||||
|
||||
uc.micro@2.1.0:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
|
||||
@@ -11494,6 +11513,8 @@ snapshots:
|
||||
'@types/tough-cookie@4.0.5':
|
||||
optional: true
|
||||
|
||||
'@types/ua-parser-js@0.7.39': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
@@ -13078,6 +13099,8 @@ snapshots:
|
||||
|
||||
destroy@1.2.0: {}
|
||||
|
||||
detect-europe-js@0.1.2: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
dezalgo@1.0.4:
|
||||
@@ -13922,6 +13945,7 @@ snapshots:
|
||||
get-tsconfig@4.13.6:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
optional: true
|
||||
|
||||
giget@2.0.0:
|
||||
dependencies:
|
||||
@@ -14280,6 +14304,8 @@ snapshots:
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
|
||||
is-standalone-pwa@0.1.1: {}
|
||||
|
||||
is-stream@2.0.1: {}
|
||||
|
||||
is-stream@3.0.0: {}
|
||||
@@ -15614,7 +15640,8 @@ snapshots:
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
resolve-pkg-maps@1.0.0:
|
||||
optional: true
|
||||
|
||||
resolve-protobuf-schema@2.1.0:
|
||||
dependencies:
|
||||
@@ -16281,6 +16308,7 @@ snapshots:
|
||||
get-tsconfig: 4.13.6
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
optional: true
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
dependencies:
|
||||
@@ -16330,6 +16358,14 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
ua-is-frozen@0.1.2: {}
|
||||
|
||||
ua-parser-js@2.0.9:
|
||||
dependencies:
|
||||
detect-europe-js: 0.1.2
|
||||
is-standalone-pwa: 0.1.1
|
||||
ua-is-frozen: 0.1.2
|
||||
|
||||
uc.micro@2.1.0: {}
|
||||
|
||||
ufo@1.6.3: {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
DB_HOST=localhost
|
||||
DB_USER=tv-test-db-user
|
||||
DB_PASSWORD=tv-test-db-pass
|
||||
DB_NAME=task_view_test_db
|
||||
DB_PORT=5454
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=12345678pqow
|
||||
DB_NAME=tv_3_dev_db
|
||||
DB_PORT=5432
|
||||
@@ -3,7 +3,7 @@ DB_USER="tv-test-db-user"
|
||||
DB_PASSWORD="tv-test-db-pass"
|
||||
DB_NAME="task_view_test_db"
|
||||
DB_PORT=5432
|
||||
APP_PORT=1420
|
||||
APP_PORT=1401
|
||||
JWT_ALG="HS256"
|
||||
JWT_SIGN="cnxcv&89e&63#"
|
||||
ACCESS_LIFE_TIME="3d"
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
import { TvApi } from '@/tv';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
} from 'vitest';
|
||||
import { initApi, API_URL } from './init-api';
|
||||
import axios from 'axios';
|
||||
import type { ApiTokenItem } from '../api-tokens.types';
|
||||
import { ALL_TASKS_LIST_ID } from '../tasks.api.types';
|
||||
|
||||
describe('API Tokens', () => {
|
||||
let $api: TvApi;
|
||||
let goalId: number;
|
||||
let goalId2: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { $tvApi } = await initApi();
|
||||
$api = $tvApi;
|
||||
|
||||
const goal = await $api.goals.createGoal({
|
||||
name: `Token test project-${Date.now()}`,
|
||||
});
|
||||
goalId = goal!.id!;
|
||||
|
||||
const goal2 = await $api.goals.createGoal({
|
||||
name: `Token test project 2-${Date.now()}`,
|
||||
});
|
||||
goalId2 = goal2!.id!;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await $api.goals.deleteGoal(goalId).catch(() => {});
|
||||
await $api.goals.deleteGoal(goalId2).catch(() => {});
|
||||
});
|
||||
|
||||
describe('CRUD', () => {
|
||||
it('should create a token and return plaintext once', async () => {
|
||||
const result = await $api.apiTokens.create({
|
||||
name: 'Test token',
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.token).toMatch(/^tvk_/);
|
||||
expect(result!.token.length).toBe(68); // tvk_ + 64 hex
|
||||
expect(result!.item.name).toBe('Test token');
|
||||
expect(result!.item.allowedPermissions).toEqual([]);
|
||||
expect(result!.item.allowedGoalIds).toEqual([]);
|
||||
|
||||
// cleanup
|
||||
await $api.apiTokens.delete(result!.item.id);
|
||||
});
|
||||
|
||||
it('should list tokens without tokenHash', async () => {
|
||||
const created = await $api.apiTokens.create({ name: 'List test' });
|
||||
const tokens = await $api.apiTokens.fetch();
|
||||
|
||||
expect(tokens).toBeDefined();
|
||||
expect(tokens!.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const found = tokens!.find((t: ApiTokenItem) => t.id === created!.item.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.name).toBe('List test');
|
||||
expect((found as any).tokenHash).toBeUndefined();
|
||||
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
});
|
||||
|
||||
it('should delete a token', async () => {
|
||||
const created = await $api.apiTokens.create({ name: 'Delete test' });
|
||||
const deleteResult = await $api.apiTokens.delete(created!.item.id);
|
||||
expect(deleteResult).toBe(true);
|
||||
|
||||
const tokens = await $api.apiTokens.fetch();
|
||||
expect(tokens!.find((t: ApiTokenItem) => t.id === created!.item.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fetch available permissions', async () => {
|
||||
const permissions = await $api.apiTokens.fetchPermissions();
|
||||
expect(permissions).toBeDefined();
|
||||
expect(permissions!.length).toBeGreaterThan(0);
|
||||
expect(permissions![0]).toHaveProperty('id');
|
||||
expect(permissions![0]).toHaveProperty('name');
|
||||
expect(permissions![0]).toHaveProperty('permissionGroup');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication via API token', () => {
|
||||
it('should authenticate and perform requests with full-access token', async () => {
|
||||
const created = await $api.apiTokens.create({ name: 'Auth test' });
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created!.token}` },
|
||||
}));
|
||||
|
||||
// Create task
|
||||
const task = await tokenApi.tasks.createTask({
|
||||
goalId,
|
||||
description: `Token task-${Date.now()}`,
|
||||
});
|
||||
expect(task).toBeDefined();
|
||||
expect(task!.id).toBeGreaterThan(0);
|
||||
expect(task!.description).toBeTruthy();
|
||||
|
||||
// Update task
|
||||
const updated = await tokenApi.tasks.updateTask({
|
||||
id: task!.id,
|
||||
description: 'Updated by token',
|
||||
});
|
||||
expect(updated).toBeDefined();
|
||||
expect(updated!.description).toBe('Updated by token');
|
||||
|
||||
// Delete task
|
||||
const deleted = await tokenApi.tasks.deleteTask(task!.id);
|
||||
expect(deleted!.delete).toBe(true);
|
||||
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
});
|
||||
|
||||
it('should return 401 for invalid token', async () => {
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: 'Bearer tvk_invalidtoken000000000000000000000000000000000000000000000000' },
|
||||
}));
|
||||
|
||||
const status = await tokenApi.goals.fetchGoals().catch((err) => err.status);
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Permission scoping', () => {
|
||||
it('should deny task creation when token lacks component_can_add_tasks', async () => {
|
||||
const created = await $api.apiTokens.create({
|
||||
name: 'Read-only token',
|
||||
allowedPermissions: ['component_can_watch_content'],
|
||||
});
|
||||
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created!.token}` },
|
||||
}));
|
||||
|
||||
const status = await tokenApi.tasks.createTask({
|
||||
goalId,
|
||||
description: 'Should fail',
|
||||
}).catch((err) => err.status);
|
||||
|
||||
expect(status).toBe(403);
|
||||
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
});
|
||||
|
||||
it('should allow reading tasks with component_can_watch_content', async () => {
|
||||
// Create task with full API first
|
||||
const task = await $api.tasks.createTask({
|
||||
goalId,
|
||||
description: `Readable task-${Date.now()}`,
|
||||
});
|
||||
|
||||
const created = await $api.apiTokens.create({
|
||||
name: 'Read token',
|
||||
allowedPermissions: ['component_can_watch_content'],
|
||||
});
|
||||
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created!.token}` },
|
||||
}));
|
||||
|
||||
const tasks = await tokenApi.tasks.fetch({
|
||||
goalId,
|
||||
page: 0,
|
||||
showCompleted: 0,
|
||||
firstNew: 0,
|
||||
componentId: ALL_TASKS_LIST_ID,
|
||||
});
|
||||
|
||||
expect(tasks).toBeDefined();
|
||||
expect(tasks!.length).toBeGreaterThanOrEqual(1);
|
||||
expect(tasks!.some(t => t.id === task!.id)).toBe(true);
|
||||
|
||||
await $api.tasks.deleteTask(task!.id);
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
});
|
||||
|
||||
it('should deny task deletion without task_can_delete', async () => {
|
||||
const task = await $api.tasks.createTask({
|
||||
goalId,
|
||||
description: `Undeletable-${Date.now()}`,
|
||||
});
|
||||
|
||||
const created = await $api.apiTokens.create({
|
||||
name: 'No delete token',
|
||||
allowedPermissions: ['component_can_watch_content', 'component_can_add_tasks'],
|
||||
});
|
||||
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created!.token}` },
|
||||
}));
|
||||
|
||||
const status = await tokenApi.tasks.deleteTask(task!.id).catch((err) => err.status);
|
||||
expect(status).toBe(403);
|
||||
|
||||
await $api.tasks.deleteTask(task!.id);
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
});
|
||||
|
||||
it('should allow task deletion when token has task_can_delete', async () => {
|
||||
const task = await $api.tasks.createTask({
|
||||
goalId,
|
||||
description: `Deletable-${Date.now()}`,
|
||||
});
|
||||
|
||||
const created = await $api.apiTokens.create({
|
||||
name: 'Delete token',
|
||||
allowedPermissions: ['component_can_watch_content', 'task_can_delete'],
|
||||
});
|
||||
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created!.token}` },
|
||||
}));
|
||||
|
||||
const deleted = await tokenApi.tasks.deleteTask(task!.id);
|
||||
expect(deleted!.delete).toBe(true);
|
||||
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
});
|
||||
|
||||
it('should allow task creation only with component_can_add_tasks and see description with component_can_watch_content', async () => {
|
||||
const created = await $api.apiTokens.create({
|
||||
name: 'Create and read token',
|
||||
allowedPermissions: ['component_can_add_tasks', 'component_can_watch_content', 'task_can_edit_description'],
|
||||
});
|
||||
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created!.token}` },
|
||||
}));
|
||||
|
||||
const task = await tokenApi.tasks.createTask({
|
||||
goalId,
|
||||
description: `Created with scoped token-${Date.now()}`,
|
||||
});
|
||||
|
||||
expect(task).toBeDefined();
|
||||
expect(task!.description).toBeTruthy();
|
||||
expect(task!.description).toContain('Created with scoped token');
|
||||
|
||||
await $api.tasks.deleteTask(task!.id);
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
});
|
||||
|
||||
it('should hide description when token lacks component_can_watch_content', async () => {
|
||||
const created = await $api.apiTokens.create({
|
||||
name: 'No watch token',
|
||||
allowedPermissions: ['component_can_add_tasks'],
|
||||
});
|
||||
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created!.token}` },
|
||||
}));
|
||||
|
||||
const task = await tokenApi.tasks.createTask({
|
||||
goalId,
|
||||
description: `Hidden description-${Date.now()}`,
|
||||
});
|
||||
|
||||
expect(task).toBeDefined();
|
||||
expect(task!.description).toBeNull();
|
||||
|
||||
await $api.tasks.deleteTask(task!.id);
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
});
|
||||
|
||||
it('should allow update only with task_can_edit_description', async () => {
|
||||
const task = await $api.tasks.createTask({
|
||||
goalId,
|
||||
description: `Original-${Date.now()}`,
|
||||
});
|
||||
|
||||
const created = await $api.apiTokens.create({
|
||||
name: 'Edit only token',
|
||||
allowedPermissions: ['component_can_watch_content', 'task_can_edit_description'],
|
||||
});
|
||||
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created!.token}` },
|
||||
}));
|
||||
|
||||
const updated = await tokenApi.tasks.updateTask({
|
||||
id: task!.id,
|
||||
description: 'Updated by scoped token',
|
||||
});
|
||||
|
||||
expect(updated).toBeDefined();
|
||||
expect(updated!.description).toBe('Updated by scoped token');
|
||||
|
||||
// Should not be able to delete
|
||||
const status = await tokenApi.tasks.deleteTask(task!.id).catch((err) => err.status);
|
||||
expect(status).toBe(403);
|
||||
|
||||
await $api.tasks.deleteTask(task!.id);
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Goal scoping', () => {
|
||||
it('should allow access to allowed goal', async () => {
|
||||
const created = await $api.apiTokens.create({
|
||||
name: 'Goal scoped token',
|
||||
allowedGoalIds: [goalId],
|
||||
});
|
||||
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created!.token}` },
|
||||
}));
|
||||
|
||||
const task = await tokenApi.tasks.createTask({
|
||||
goalId,
|
||||
description: `Allowed goal task-${Date.now()}`,
|
||||
});
|
||||
expect(task).toBeDefined();
|
||||
expect(task!.id).toBeGreaterThan(0);
|
||||
|
||||
await tokenApi.tasks.deleteTask(task!.id);
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
});
|
||||
|
||||
it('should deny access to non-allowed goal', async () => {
|
||||
const created = await $api.apiTokens.create({
|
||||
name: 'Goal restricted token',
|
||||
allowedGoalIds: [goalId],
|
||||
});
|
||||
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created!.token}` },
|
||||
}));
|
||||
|
||||
const status = await tokenApi.tasks.createTask({
|
||||
goalId: goalId2,
|
||||
description: 'Should fail',
|
||||
}).catch((err) => err.status);
|
||||
|
||||
expect(status).toBe(403);
|
||||
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
});
|
||||
|
||||
it('should allow all goals when allowedGoalIds is empty', async () => {
|
||||
const created = await $api.apiTokens.create({
|
||||
name: 'All goals token',
|
||||
allowedGoalIds: [],
|
||||
});
|
||||
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created!.token}` },
|
||||
}));
|
||||
|
||||
const task1 = await tokenApi.tasks.createTask({
|
||||
goalId,
|
||||
description: `Goal1 task-${Date.now()}`,
|
||||
});
|
||||
expect(task1).toBeDefined();
|
||||
|
||||
const task2 = await tokenApi.tasks.createTask({
|
||||
goalId: goalId2,
|
||||
description: `Goal2 task-${Date.now()}`,
|
||||
});
|
||||
expect(task2).toBeDefined();
|
||||
|
||||
await tokenApi.tasks.deleteTask(task1!.id);
|
||||
await tokenApi.tasks.deleteTask(task2!.id);
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Expiration', () => {
|
||||
it('should reject expired token', async () => {
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - 1);
|
||||
|
||||
const created = await $api.apiTokens.create({
|
||||
name: 'Expired token',
|
||||
expiresAt: pastDate.toISOString(),
|
||||
});
|
||||
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created!.token}` },
|
||||
}));
|
||||
|
||||
const status = await tokenApi.goals.fetchGoals().catch((err) => err.status);
|
||||
expect(status).toBe(401);
|
||||
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security', () => {
|
||||
it('should not allow creating tokens via API token', async () => {
|
||||
const created = await $api.apiTokens.create({ name: 'Bootstrap token' });
|
||||
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created!.token}` },
|
||||
}));
|
||||
|
||||
const status = await tokenApi.apiTokens.create({
|
||||
name: 'Should fail',
|
||||
}).catch((err) => err.status);
|
||||
|
||||
expect(status).toBe(403);
|
||||
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
});
|
||||
|
||||
it('should not allow listing tokens via API token', async () => {
|
||||
const created = await $api.apiTokens.create({ name: 'List attempt' });
|
||||
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created!.token}` },
|
||||
}));
|
||||
|
||||
const status = await tokenApi.apiTokens.fetch().catch((err) => err.status);
|
||||
expect(status).toBe(403);
|
||||
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
});
|
||||
|
||||
it('should not allow deleting tokens via API token', async () => {
|
||||
const created = await $api.apiTokens.create({ name: 'Delete attempt' });
|
||||
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created!.token}` },
|
||||
}));
|
||||
|
||||
const status = await tokenApi.apiTokens.delete(created!.item.id).catch((err) => err.status);
|
||||
expect(status).toBe(403);
|
||||
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
export const API_URL = 'http://localhost:1420';
|
||||
export const API_URL = 'http://localhost:1401';
|
||||
export const DEFAULT_USER = 'user';
|
||||
export const DEFAULT_USER_2 = 'user2';
|
||||
export const DEFAULT_PASSWORD = 'user1!#Q';
|
||||
@@ -34,8 +34,8 @@ export const initApi = async () => {
|
||||
|
||||
expect(authResponse.data.access).toBeTruthy();
|
||||
expect(authResponse.data.refresh).toBeTruthy();
|
||||
expect(authResponse.data.userData.id).toBe(1);
|
||||
expect(authResponse.data.userData.email).toBe('test@mail.dest');
|
||||
expect(authResponse.data.userData.id).toBeGreaterThan(0);
|
||||
expect(authResponse.data.userData.email).toBeTruthy();
|
||||
|
||||
const deleteAllGoals = async () => {
|
||||
for (const $api of [$tvApi, $tvApiForSecondUser]) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import TvApiBase from './base';
|
||||
import type { AppResponse } from '@/api/base.types';
|
||||
import type {
|
||||
ApiTokenArgCreate,
|
||||
ApiTokenCreateResponse,
|
||||
ApiTokenItem,
|
||||
ApiTokenPermission,
|
||||
} from './api-tokens.types';
|
||||
|
||||
export default class TvApiTokens extends TvApiBase {
|
||||
protected moduleUrl = '/module/api-tokens';
|
||||
|
||||
public async fetch() {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<ApiTokenItem[]>>(`${this.moduleUrl}`)
|
||||
);
|
||||
}
|
||||
|
||||
public async create(data: ApiTokenArgCreate) {
|
||||
return this.request(
|
||||
this.$axios.post<AppResponse<ApiTokenCreateResponse>>(`${this.moduleUrl}`, data)
|
||||
);
|
||||
}
|
||||
|
||||
public async delete(id: number) {
|
||||
return this.request(
|
||||
this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}`, { data: { id } })
|
||||
);
|
||||
}
|
||||
|
||||
public async fetchPermissions() {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<ApiTokenPermission[]>>(`${this.moduleUrl}/permissions`)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export type ApiTokenItem = {
|
||||
id: number;
|
||||
userId: number;
|
||||
name: string;
|
||||
allowedPermissions: string[];
|
||||
allowedGoalIds: number[];
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string | null;
|
||||
};
|
||||
|
||||
export type ApiTokenCreateResponse = {
|
||||
token: string;
|
||||
item: ApiTokenItem;
|
||||
};
|
||||
|
||||
export type ApiTokenArgCreate = {
|
||||
name: string;
|
||||
allowedPermissions?: string[];
|
||||
allowedGoalIds?: number[];
|
||||
expiresAt?: string | null;
|
||||
};
|
||||
|
||||
export type ApiTokenPermission = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
permissionGroup: number;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import TvApiBase from './base'
|
||||
import type { AppResponse } from '@/api/base.types'
|
||||
import type { SessionItem } from './sessions.types'
|
||||
|
||||
export default class TvSessions extends TvApiBase {
|
||||
protected moduleUrl = '/module/sessions'
|
||||
|
||||
public async fetch() {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<SessionItem[]>>(`${this.moduleUrl}`)
|
||||
)
|
||||
}
|
||||
|
||||
public async delete(id: number) {
|
||||
return this.request(
|
||||
this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}`, { data: { id } })
|
||||
)
|
||||
}
|
||||
|
||||
public async deleteAll() {
|
||||
return this.request(
|
||||
this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/all`)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type SessionItem = {
|
||||
id: number
|
||||
deviceName: string | null
|
||||
userIp: string | null
|
||||
createdAt: string
|
||||
lastUsedAt: string | null
|
||||
isCurrent: boolean
|
||||
}
|
||||
@@ -11,4 +11,6 @@ export * from '@/api/goals-list.types';
|
||||
export * from '@/api/kanban.types';
|
||||
export * from '@/api/integrations.types';
|
||||
export * from '@/api/notifications.api.types';
|
||||
export * from '@/api/webhooks.types';
|
||||
export * from '@/api/webhooks.types';
|
||||
export * from '@/api/api-tokens.types';
|
||||
export * from '@/api/sessions.types';
|
||||
@@ -9,6 +9,8 @@ import TvIntegrationsApi from "./api/integrations";
|
||||
import TvKanban from "./api/kanban";
|
||||
import TvNotificationsApi from "./api/notifications";
|
||||
import TvWebhooks from "./api/webhooks";
|
||||
import TvApiTokens from "./api/api-tokens";
|
||||
import TvSessions from "./api/sessions";
|
||||
|
||||
export class TvApi {
|
||||
|
||||
@@ -34,6 +36,10 @@ export class TvApi {
|
||||
|
||||
public webhooks: TvWebhooks;
|
||||
|
||||
public apiTokens: TvApiTokens;
|
||||
|
||||
public sessions: TvSessions;
|
||||
|
||||
constructor($axios: AxiosInstance) {
|
||||
this.$axios = $axios;
|
||||
|
||||
@@ -56,6 +62,10 @@ export class TvApi {
|
||||
this.notifications = new TvNotificationsApi(this.$axios);
|
||||
|
||||
this.webhooks = new TvWebhooks(this.$axios);
|
||||
|
||||
this.apiTokens = new TvApiTokens(this.$axios);
|
||||
|
||||
this.sessions = new TvSessions(this.$axios);
|
||||
}
|
||||
|
||||
public setBaseUrl(baseUrl: string) {
|
||||
|
||||
@@ -13,3 +13,5 @@ export * from './schemas/notifications.schema';
|
||||
export * from './schemas/device-tokens.schema';
|
||||
export * from './schemas/notification-preferences.schema';
|
||||
export * from './schemas/webhooks.schema';
|
||||
export * from './schemas/api-tokens.schema';
|
||||
export * from './schemas/user-tokens.schema';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { integer, pgSchema, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const ApiTokensSchema = pgSchema('tv_auth').table('api_tokens', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull(),
|
||||
name: varchar({ length: 100 }).notNull(),
|
||||
tokenHash: varchar('token_hash', { length: 64 }).notNull().unique(),
|
||||
allowedPermissions: varchar('allowed_permissions').array().notNull().default([]),
|
||||
allowedGoalIds: integer('allowed_goal_ids').array().notNull().default([]),
|
||||
lastUsedAt: timestamp('last_used_at'),
|
||||
expiresAt: timestamp('expires_at'),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
});
|
||||
|
||||
export type ApiTokensSchemaTypeForSelect = typeof ApiTokensSchema.$inferSelect;
|
||||
export type ApiTokensSchemaTypeForInsert = typeof ApiTokensSchema.$inferInsert;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { integer, pgSchema, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core'
|
||||
|
||||
export const UserTokensSchema = pgSchema('tv_auth').table('user_tokens', {
|
||||
id: serial().primaryKey(),
|
||||
userId: integer('user_id').notNull(),
|
||||
userIp: varchar('user_ip', { length: 50 }),
|
||||
deviceName: varchar('device_name', { length: 200 }),
|
||||
userAgent: text('user_agent'),
|
||||
timeCreation: timestamp('time_creation').defaultNow(),
|
||||
lastUsedAt: timestamp('last_used_at'),
|
||||
})
|
||||
|
||||
export type UserTokensSchemaSelect = typeof UserTokensSchema.$inferSelect
|
||||
export type UserTokensSchemaInsert = typeof UserTokensSchema.$inferInsert
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "web-nuxt-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "1.32.0",
|
||||
"version": "1.41.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build",
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-4 lg:p-6 w-full max-w-full lg:max-w-2xl m-0 lg:mx-auto">
|
||||
<h1 class="text-2xl font-bold">
|
||||
{{ t('account.title') }}
|
||||
</h1>
|
||||
|
||||
<NotificationSettings />
|
||||
|
||||
<UPageCard class="w-full">
|
||||
<SessionsPanel />
|
||||
</UPageCard>
|
||||
|
||||
<UPageCard class="w-full">
|
||||
<ApiTokensPanel />
|
||||
</UPageCard>
|
||||
|
||||
<UPageCard class="w-full">
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-lg font-semibold">
|
||||
@@ -36,6 +40,8 @@ import { useUserStore } from '@/stores/user.store'
|
||||
import DeleteAccountButton from './parts/DeleteAccountButton.vue'
|
||||
import DeleteAccountCodeModal from './parts/DeleteAccountCodeModal.vue'
|
||||
import NotificationSettings from './parts/NotificationSettings.vue'
|
||||
import SessionsPanel from '@/components/features/sessions/SessionsPanel.vue'
|
||||
import ApiTokensPanel from '@/components/features/api-tokens/ApiTokensPanel.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ t('apiTokens.title') }}
|
||||
</h2>
|
||||
<p class="text-sm text-muted">
|
||||
{{ t('apiTokens.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
:label="t('apiTokens.add')"
|
||||
icon="i-lucide-plus"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
@click="isCreateOpen = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="store.loading"
|
||||
class="flex items-center justify-center h-32"
|
||||
>
|
||||
<p>{{ t('common.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="store.tokens.length === 0"
|
||||
class="flex flex-col items-center justify-center h-32 text-muted"
|
||||
>
|
||||
<UIcon name="i-lucide-key-round" class="size-10 mb-3" />
|
||||
<p>{{ t('apiTokens.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<ApiTokenItem
|
||||
v-for="token in store.tokens"
|
||||
:key="token.id"
|
||||
:token="token"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CreateApiTokenModal
|
||||
v-model:open="isCreateOpen"
|
||||
@created="store.fetchTokens()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiTokensStore } from '@/stores/api-tokens.store'
|
||||
import ApiTokenItem from './parts/ApiTokenItem.vue'
|
||||
import CreateApiTokenModal from './parts/CreateApiTokenModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useApiTokensStore()
|
||||
const isCreateOpen = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchTokens()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between p-4 border border-default rounded-lg">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<UIcon
|
||||
name="i-lucide-key-round"
|
||||
class="size-5 shrink-0 text-primary"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium">
|
||||
{{ token.name }}
|
||||
</p>
|
||||
<div class="flex items-center gap-3 text-xs text-muted mt-1">
|
||||
<span>{{ t('apiTokens.lastUsed') }}: {{ token.lastUsedAt ? formatDate(token.lastUsedAt) : t('apiTokens.never') }}</span>
|
||||
<span v-if="token.expiresAt">
|
||||
{{ t('apiTokens.expires') }}: {{ formatDate(token.expiresAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="token.allowedPermissions.length > 0" class="flex items-center gap-1 mt-1">
|
||||
<UBadge variant="subtle" size="xs">
|
||||
{{ token.allowedPermissions.length }} {{ t('apiTokens.permissionsCount') }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="md"
|
||||
@click="showDeleteConfirm = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UModal v-model:open="showDeleteConfirm" :fullscreen="isMobile">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('common.delete') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-sm">
|
||||
{{ t('apiTokens.deleteConfirm') }}
|
||||
</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="w-full flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
variant="ghost"
|
||||
@click="showDeleteConfirm = false"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('common.delete')"
|
||||
color="error"
|
||||
variant="outline"
|
||||
:loading="deleting"
|
||||
@click="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { ApiTokenItem } from 'taskview-api'
|
||||
import { useApiTokensStore } from '@/stores/api-tokens.store'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
|
||||
const props = defineProps<{
|
||||
token: ApiTokenItem
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isMobile } = useTaskView()
|
||||
const store = useApiTokensStore()
|
||||
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
deleting.value = true
|
||||
await store.deleteToken(props.token.id)
|
||||
showDeleteConfirm.value = false
|
||||
deleting.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<UModal v-model:open="isOpen" :fullscreen="isMobile">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('apiTokens.add') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<UFormField :label="t('apiTokens.name')">
|
||||
<UInput
|
||||
v-model="name"
|
||||
:placeholder="t('apiTokens.namePlaceholder')"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField :label="t('apiTokens.expiration')">
|
||||
<USelectMenu
|
||||
v-model="expiration"
|
||||
:items="expirationOptions"
|
||||
value-key="value"
|
||||
:search-input="false"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField :label="t('apiTokens.projects')">
|
||||
<p class="text-xs text-muted mb-2">
|
||||
{{ t('apiTokens.projectsHint') }}
|
||||
</p>
|
||||
<USelectMenu
|
||||
v-model="selectedGoalIds"
|
||||
:items="goalOptions"
|
||||
multiple
|
||||
value-key="value"
|
||||
:search-input="false"
|
||||
class="w-full"
|
||||
:placeholder="t('apiTokens.allProjects')"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField :label="t('apiTokens.permissions')">
|
||||
<p class="text-xs text-muted mb-2">
|
||||
<a
|
||||
href="https://taskview.tech/docs/collaboration/roles-and-permissions#project-permissions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline underline-offset-2 hover:text-default"
|
||||
>{{ t('apiTokens.permissionsDocs') }}</a>
|
||||
</p>
|
||||
<div class="max-h-64 overflow-y-auto border border-default rounded-lg p-3">
|
||||
<div
|
||||
v-for="(group, groupId) in groupedPermissions"
|
||||
:key="groupId"
|
||||
class="mb-3 last:mb-0"
|
||||
>
|
||||
<p class="text-xs font-semibold text-muted mb-1 uppercase">
|
||||
{{ group.name }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<UCheckbox
|
||||
v-for="perm in group.items"
|
||||
:key="perm.id"
|
||||
:model-value="selectedPermissions.includes(perm.name)"
|
||||
:label="perm.description || perm.name"
|
||||
@update:model-value="togglePermission(perm.name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="w-full flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
variant="ghost"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('apiTokens.generate')"
|
||||
color="primary"
|
||||
:disabled="!name"
|
||||
variant="outline"
|
||||
:loading="saving"
|
||||
@click="handleCreate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal v-model:open="showToken" :fullscreen="isMobile">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('apiTokens.tokenCreated') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-sm text-muted mb-3">
|
||||
{{ t('apiTokens.tokenDescription') }}
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-2 p-3 bg-elevated rounded-lg">
|
||||
<span class="font-mono text-xs break-all">{{ createdToken }}</span>
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
@click="copy(createdToken)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="w-full flex justify-end">
|
||||
<UButton
|
||||
:label="t('common.done')"
|
||||
color="primary"
|
||||
variant="outline"
|
||||
@click="showToken = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useApiTokensStore } from '@/stores/api-tokens.store'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isMobile } = useTaskView()
|
||||
const { copy } = useClipboard()
|
||||
const store = useApiTokensStore()
|
||||
const goalsStore = useGoalsStore()
|
||||
|
||||
const name = ref('')
|
||||
const expiration = ref('none')
|
||||
const selectedPermissions = ref<string[]>([])
|
||||
const selectedGoalIds = ref<number[]>([])
|
||||
const saving = ref(false)
|
||||
const showToken = ref(false)
|
||||
const createdToken = ref('')
|
||||
|
||||
const expirationOptions = [
|
||||
{ label: t('apiTokens.noExpiration'), value: 'none' },
|
||||
{ label: t('apiTokens.days30'), value: '30' },
|
||||
{ label: t('apiTokens.days60'), value: '60' },
|
||||
{ label: t('apiTokens.days90'), value: '90' },
|
||||
{ label: t('apiTokens.year1'), value: '365' },
|
||||
]
|
||||
|
||||
const goalOptions = computed(() =>
|
||||
goalsStore.goals
|
||||
.filter((g) => g.archive === 0)
|
||||
.map((g) => ({ label: g.name, value: g.id }))
|
||||
)
|
||||
|
||||
const permissionGroupNames: Record<number, string> = {
|
||||
1: 'Application level',
|
||||
2: 'Project',
|
||||
3: 'Lists',
|
||||
4: 'Tasks',
|
||||
}
|
||||
|
||||
const groupedPermissions = computed(() => {
|
||||
const groups: Record<number, { name: string; items: typeof store.permissions }> = {}
|
||||
for (const perm of store.permissions) {
|
||||
const gid = perm.permissionGroup
|
||||
if (!groups[gid]) {
|
||||
groups[gid] = { name: permissionGroupNames[gid] || `Group ${gid}`, items: [] }
|
||||
}
|
||||
groups[gid].items.push(perm)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function togglePermission(permName: string) {
|
||||
const idx = selectedPermissions.value.indexOf(permName)
|
||||
if (idx === -1) {
|
||||
selectedPermissions.value.push(permName)
|
||||
} else {
|
||||
selectedPermissions.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function getExpiresAt(): string | null {
|
||||
if (expiration.value === 'none') return null
|
||||
const days = parseInt(expiration.value)
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + days)
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await store.createToken({
|
||||
name: name.value,
|
||||
allowedPermissions: selectedPermissions.value.length > 0 ? selectedPermissions.value : undefined,
|
||||
allowedGoalIds: selectedGoalIds.value.length > 0 ? selectedGoalIds.value : undefined,
|
||||
expiresAt: getExpiresAt(),
|
||||
})
|
||||
|
||||
if (result) {
|
||||
isOpen.value = false
|
||||
createdToken.value = result.token
|
||||
showToken.value = true
|
||||
name.value = ''
|
||||
selectedPermissions.value = []
|
||||
selectedGoalIds.value = []
|
||||
expiration.value = 'none'
|
||||
emit('created')
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (store.permissions.length === 0) {
|
||||
store.fetchPermissions()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ t('sessions.title') }}
|
||||
</h2>
|
||||
<p class="text-sm text-muted">
|
||||
{{ t('sessions.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="hasOtherSessions"
|
||||
:label="t('sessions.deleteAll')"
|
||||
icon="i-lucide-log-out"
|
||||
color="error"
|
||||
variant="soft"
|
||||
@click="showDeleteAllConfirm = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="store.loading"
|
||||
class="flex items-center justify-center h-32"
|
||||
>
|
||||
<p>{{ t('common.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="store.sessions.length === 0"
|
||||
class="flex flex-col items-center justify-center h-32 text-muted"
|
||||
>
|
||||
<UIcon name="i-lucide-monitor" class="size-10 mb-3" />
|
||||
<p>{{ t('sessions.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<SessionItem
|
||||
v-for="session in store.sessions"
|
||||
:key="session.id"
|
||||
:session="session"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UModal v-model:open="showDeleteAllConfirm" :fullscreen="isMobile">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('sessions.deleteAll') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-sm">
|
||||
{{ t('sessions.deleteAllConfirm') }}
|
||||
</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="w-full flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
variant="ghost"
|
||||
@click="showDeleteAllConfirm = false"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('sessions.deleteAll')"
|
||||
color="error"
|
||||
variant="outline"
|
||||
:loading="deletingAll"
|
||||
@click="handleDeleteAll"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSessionsStore } from '@/stores/sessions.store'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
import SessionItem from './parts/SessionItem.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isMobile } = useTaskView()
|
||||
const store = useSessionsStore()
|
||||
|
||||
const showDeleteAllConfirm = ref(false)
|
||||
const deletingAll = ref(false)
|
||||
|
||||
const hasOtherSessions = computed(() =>
|
||||
store.sessions.some((s) => !s.isCurrent)
|
||||
)
|
||||
|
||||
async function handleDeleteAll() {
|
||||
deletingAll.value = true
|
||||
await store.deleteAllSessions()
|
||||
showDeleteAllConfirm.value = false
|
||||
deletingAll.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchSessions()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between p-4 border border-default rounded-lg">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<UIcon
|
||||
:name="deviceIcon"
|
||||
class="size-5 shrink-0 text-primary"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium">
|
||||
{{ session.deviceName || t('sessions.unknown') }}
|
||||
</p>
|
||||
<UBadge v-if="session.isCurrent" variant="subtle" color="primary" size="xs">
|
||||
{{ t('sessions.current') }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-muted mt-1">
|
||||
<span v-if="session.userIp">{{ session.userIp }}</span>
|
||||
<span>{{ t('sessions.created') }}: {{ formatDate(session.createdAt) }}</span>
|
||||
<span v-if="session.lastUsedAt">
|
||||
{{ t('sessions.lastUsed') }}: {{ formatDate(session.lastUsedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="!session.isCurrent"
|
||||
icon="i-lucide-log-out"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="md"
|
||||
@click="showDeleteConfirm = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UModal v-model:open="showDeleteConfirm" :fullscreen="isMobile">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('common.delete') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-sm">
|
||||
{{ t('sessions.deleteConfirm') }}
|
||||
</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="w-full flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
variant="ghost"
|
||||
@click="showDeleteConfirm = false"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('common.delete')"
|
||||
color="error"
|
||||
variant="outline"
|
||||
:loading="deleting"
|
||||
@click="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SessionItem } from 'taskview-api'
|
||||
import { useSessionsStore } from '@/stores/sessions.store'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
|
||||
const props = defineProps<{
|
||||
session: SessionItem
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isMobile } = useTaskView()
|
||||
const store = useSessionsStore()
|
||||
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
const deviceIcon = computed(() => {
|
||||
const name = (props.session.deviceName || '').toLowerCase()
|
||||
if (name.includes('iphone') || name.includes('android') || name.includes('mobile')) {
|
||||
return 'i-lucide-smartphone'
|
||||
}
|
||||
return 'i-lucide-monitor'
|
||||
})
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
deleting.value = true
|
||||
await store.deleteSession(props.session.id)
|
||||
showDeleteConfirm.value = false
|
||||
deleting.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -5,13 +5,12 @@
|
||||
<div class="flex items-start gap-3 shadow-sm rounded-lg dark:bg-tv-ui-bg-elevated">
|
||||
<div class="flex-1">
|
||||
<UTextarea
|
||||
:value="task.description"
|
||||
v-model="titleValue"
|
||||
type="text"
|
||||
variant="ghost"
|
||||
:autoresize="true"
|
||||
class="w-full"
|
||||
:class="{ 'text-muted': task.complete }"
|
||||
@blur="updateTaskTitle($event)"
|
||||
>
|
||||
<template #leading>
|
||||
<div class="h-full">
|
||||
@@ -134,7 +133,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTasksStore } from '@/stores/tasks.store'
|
||||
import NoteEditor from '@/components/features/tasks/parts/NoteEditor.vue'
|
||||
@@ -158,6 +158,34 @@ const {
|
||||
} = useGoalPermissions()
|
||||
const task = computed(() => tasksStore.selectedTask ?? null)
|
||||
const projectId = computed(() => task.value?.goalId ?? 0)
|
||||
const titleValue = ref(task.value?.description ?? '')
|
||||
let lastTaskId: number | null = task.value?.id ?? null
|
||||
let savedDescription: string = task.value?.description ?? ''
|
||||
|
||||
watch(task, (newTask) => {
|
||||
titleValue.value = newTask?.description ?? ''
|
||||
lastTaskId = newTask?.id ?? null
|
||||
savedDescription = newTask?.description ?? ''
|
||||
})
|
||||
|
||||
function saveDescription() {
|
||||
if (!lastTaskId || titleValue.value === savedDescription) return
|
||||
savedDescription = titleValue.value
|
||||
tasksStore.updateTaskDescription({
|
||||
id: lastTaskId,
|
||||
description: titleValue.value,
|
||||
})
|
||||
}
|
||||
|
||||
const debouncedSave = useDebounceFn(saveDescription, 500)
|
||||
|
||||
watch(titleValue, () => {
|
||||
debouncedSave()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
saveDescription()
|
||||
})
|
||||
|
||||
async function toggleComplete() {
|
||||
if (!task.value) return
|
||||
@@ -174,16 +202,6 @@ async function toggleComplete() {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTaskTitle(event: Event) {
|
||||
if (!task.value) return
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.value !== task.value.description) {
|
||||
await tasksStore.updateTaskDescription({
|
||||
id: task.value.id,
|
||||
description: input.value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNote(note: string) {
|
||||
if (!task.value) return
|
||||
|
||||
@@ -120,6 +120,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import { type Task } from 'taskview-api'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import { useGoalListsStore } from '@/stores/goal-lists.store'
|
||||
import { useBaseScreenStore } from '@/stores/base-screen.store'
|
||||
import { useCollaborationStore } from '@/stores/collaboration.store'
|
||||
import { useTagsStore } from '@/stores/tag.store'
|
||||
import { formatDate } from '@vueuse/core'
|
||||
@@ -162,6 +163,7 @@ function handleOpenTask() {
|
||||
}
|
||||
|
||||
const goalListsStore = useGoalListsStore()
|
||||
const baseScreenStore = useBaseScreenStore()
|
||||
const collaborationStore = useCollaborationStore()
|
||||
const tagsStore = useTagsStore()
|
||||
|
||||
@@ -183,9 +185,10 @@ const projectName = computed(() => {
|
||||
})
|
||||
|
||||
const listName = computed(() => {
|
||||
return props.task.goalListId
|
||||
? goalListsStore.listMap.get(props.task.goalListId)?.name || ''
|
||||
: ''
|
||||
if (!props.task.goalListId) return ''
|
||||
return goalListsStore.listMap.get(props.task.goalListId)?.name
|
||||
|| baseScreenStore.listMap.get(props.task.goalListId)?.name
|
||||
|| ''
|
||||
})
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
|
||||
@@ -97,11 +97,12 @@ function isListSelected(listId: number): boolean {
|
||||
}
|
||||
|
||||
async function selectList(listId: number | null) {
|
||||
if (listId === props.currentListId) return
|
||||
const normalizedId = listId === ALL_TASKS_LIST_ID ? null : listId
|
||||
if (normalizedId === props.currentListId) return
|
||||
|
||||
await tasksStore.moveTaskToAnotherList({
|
||||
id: props.taskId,
|
||||
goalListId: listId,
|
||||
goalListId: normalizedId,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import type { RealtimeEventMap } from '../types'
|
||||
|
||||
export function handleGoalsChanged(_data: RealtimeEventMap['goals.changed']) {
|
||||
const goalsStore = useGoalsStore()
|
||||
goalsStore.fetchGoals()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { RealtimeEventMap, RealtimeHandler } from '../types'
|
||||
import { handleNotification } from './notification'
|
||||
import { handleGoalsChanged } from './goals-changed'
|
||||
|
||||
export const eventHandlers: { [K in keyof RealtimeEventMap]: RealtimeHandler<K> } = {
|
||||
'notification': handleNotification,
|
||||
'goals.changed': handleGoalsChanged,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useNotificationsStore } from '@/stores/notifications.store'
|
||||
import type { RealtimeEventMap } from '../types'
|
||||
|
||||
export function handleNotification(data: RealtimeEventMap['notification']) {
|
||||
const notificationsStore = useNotificationsStore()
|
||||
notificationsStore.addRealtimeNotification({
|
||||
...data.notification,
|
||||
goalId: data.goalId ?? null,
|
||||
goalListId: data.goalListId ?? null,
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ref } from 'vue'
|
||||
import { Centrifuge } from 'centrifuge'
|
||||
import type { Subscription } from 'centrifuge'
|
||||
import type { PublicationContext, Subscription } from 'centrifuge'
|
||||
import { $tvApi } from '@/plugins/axios'
|
||||
import { useNotificationsStore } from '@/stores/notifications.store'
|
||||
import { useUserStore } from '@/stores/user.store'
|
||||
import { parseJwt } from '@/helpers/Helper'
|
||||
import type { RealtimeEvent, RealtimeHandler } from './types'
|
||||
import { eventHandlers } from './handlers'
|
||||
|
||||
let centrifuge: Centrifuge | null = null
|
||||
let subscription: Subscription | null = null
|
||||
@@ -44,14 +45,14 @@ export function useCentrifugo() {
|
||||
})
|
||||
|
||||
subscription = centrifuge.newSubscription(`personal:#${userId}`)
|
||||
subscription.on('publication', (ctx) => {
|
||||
const notificationsStore = useNotificationsStore()
|
||||
if (ctx.data?.event === 'notification' && ctx.data?.notification) {
|
||||
notificationsStore.addRealtimeNotification({
|
||||
...ctx.data.notification,
|
||||
goalId: ctx.data.goalId ?? null,
|
||||
goalListId: ctx.data.goalListId ?? null,
|
||||
})
|
||||
subscription.on('publication', (ctx: PublicationContext) => {
|
||||
const data = ctx.data as RealtimeEvent
|
||||
if (!data?.event) return
|
||||
|
||||
const handler = eventHandlers[data.event]
|
||||
if (handler) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(handler as RealtimeHandler<any>)(data)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Notification } from 'taskview-api'
|
||||
|
||||
export type RealtimeEventMap = {
|
||||
'notification': {
|
||||
event: 'notification'
|
||||
notification: Notification
|
||||
goalId: number | null
|
||||
goalListId: number | null
|
||||
}
|
||||
'goals.changed': {
|
||||
event: 'goals.changed'
|
||||
goalId: number
|
||||
}
|
||||
}
|
||||
|
||||
export type RealtimeEvent = RealtimeEventMap[keyof RealtimeEventMap]
|
||||
|
||||
export type RealtimeHandler<T extends keyof RealtimeEventMap> = (data: RealtimeEventMap[T]) => void
|
||||
@@ -11,7 +11,7 @@ import { useKanbanStore } from '@/stores/kanban.store'
|
||||
|
||||
export type ProjectContext = {
|
||||
tags: ComputedRef<TagItem[]>
|
||||
users: Ref<CollaborationResponseFetchAllUsers[]>
|
||||
users: ComputedRef<CollaborationResponseFetchAllUsers[]>
|
||||
statuses: Ref<KanbanColumnItem[]>
|
||||
lists: Ref<GoalListItem[]>
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const PROJECT_CONTEXT_KEY: InjectionKey<ProjectContext> = Symbol('projectContext
|
||||
export function provideProjectContext(goalId: Ref<number>) {
|
||||
const tagsStore = useTagsStore()
|
||||
const goalsStore = useGoalsStore()
|
||||
const collaborationStore = useCollaborationStore()
|
||||
|
||||
const tags = computed(() => {
|
||||
const id = goalId.value
|
||||
@@ -30,14 +31,19 @@ export function provideProjectContext(goalId: Ref<number>) {
|
||||
)
|
||||
})
|
||||
|
||||
const users = ref<CollaborationResponseFetchAllUsers[]>([])
|
||||
const isCurrentProject = ref(false)
|
||||
const fetchedUsers = ref<CollaborationResponseFetchAllUsers[]>([])
|
||||
const users = computed(() =>
|
||||
isCurrentProject.value ? collaborationStore.users : fetchedUsers.value,
|
||||
)
|
||||
const statuses = ref<KanbanColumnItem[]>([])
|
||||
const lists = ref<GoalListItem[]>([])
|
||||
let requestId = 0
|
||||
|
||||
watch(goalId, async (id) => {
|
||||
if (id <= 0) {
|
||||
users.value = []
|
||||
fetchedUsers.value = []
|
||||
isCurrentProject.value = false
|
||||
statuses.value = []
|
||||
lists.value = []
|
||||
return
|
||||
@@ -49,14 +55,14 @@ export function provideProjectContext(goalId: Ref<number>) {
|
||||
|
||||
// If we're inside this project already, reuse data from global stores
|
||||
if (goalsStore.selectedItemId === id) {
|
||||
const collaborationStore = useCollaborationStore()
|
||||
const kanbanStore = useKanbanStore()
|
||||
const goalListsStore = useGoalListsStore()
|
||||
users.value = collaborationStore.users
|
||||
isCurrentProject.value = true
|
||||
statuses.value = kanbanStore.statuses
|
||||
lists.value = goalListsStore.lists
|
||||
return
|
||||
}
|
||||
isCurrentProject.value = false
|
||||
|
||||
// Otherwise fetch project data without touching global stores
|
||||
const currentRequestId = ++requestId
|
||||
@@ -67,10 +73,9 @@ export function provideProjectContext(goalId: Ref<number>) {
|
||||
$tvApi.goalLists.fetchLists({ goalId: id }).catch(() => null),
|
||||
])
|
||||
|
||||
// Stale check — goalId could have changed while requests were in flight
|
||||
if (currentRequestId !== requestId) return
|
||||
|
||||
if (usersResult) users.value = usersResult
|
||||
if (usersResult) fetchedUsers.value = usersResult
|
||||
if (columnsResult) {
|
||||
statuses.value = [
|
||||
{ id: DEFAULT_ID, name: 'msg.allTasks', goalId: id, viewOrder: 0 },
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { App } from '@capacitor/app'
|
||||
import { CapacitorUpdater } from '@capgo/capacitor-updater'
|
||||
import { useDashboard } from '@/composables/useDashboard'
|
||||
@@ -48,12 +49,29 @@ import ProjectsSidebar from '@/components/features/projects/ProjectsSidebar.vue'
|
||||
import NotificationBell from '@/components/NotificationBell.vue'
|
||||
import { useCentrifugo } from '@/composables/useCentrifugo'
|
||||
import { usePushNotifications } from '@/composables/usePushNotifications'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import { useUserStore } from '@/stores/user.store'
|
||||
|
||||
const { isSidebarOpen, isSidebarCollapsed } = useDashboard()
|
||||
const { connect: connectCentrifugo } = useCentrifugo()
|
||||
const { init: initPush } = usePushNotifications()
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const goalsStore = useGoalsStore()
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
watch(
|
||||
() => [goalsStore.initialized, goalsStore.goals, route.params.projectId] as const,
|
||||
([initialized, goals, projectId]) => {
|
||||
if (!initialized || !projectId) return
|
||||
const exists = goals.some((g) => g.id === Number(projectId))
|
||||
if (!exists) {
|
||||
router.replace(`/${userStore.login}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
let updateInProgress = false
|
||||
|
||||
|
||||
@@ -445,6 +445,45 @@ export default {
|
||||
deleted: 'Account deleted',
|
||||
codeSendError: 'Unable to send the code. Please contact support to delete your account.',
|
||||
},
|
||||
apiTokens: {
|
||||
title: 'API Tokens',
|
||||
description: 'Create tokens to access the API programmatically.',
|
||||
add: 'Generate new token',
|
||||
name: 'Token name',
|
||||
namePlaceholder: 'e.g. CI/CD, Mobile App',
|
||||
expiration: 'Expiration',
|
||||
noExpiration: 'No expiration',
|
||||
days30: '30 days',
|
||||
days60: '60 days',
|
||||
days90: '90 days',
|
||||
year1: '1 year',
|
||||
projects: 'Projects',
|
||||
projectsHint: 'Restrict token to specific projects. Leave empty for access to all projects.',
|
||||
allProjects: 'All projects',
|
||||
permissions: 'Permissions',
|
||||
permissionsCount: 'permissions',
|
||||
permissionsDocs: 'Learn more about permissions',
|
||||
generate: 'Generate token',
|
||||
tokenCreated: 'Token created',
|
||||
tokenDescription: 'Copy this token now. It will not be shown again.',
|
||||
lastUsed: 'Last used',
|
||||
never: 'Never',
|
||||
expires: 'Expires',
|
||||
deleteConfirm: 'Are you sure you want to delete this token? Any applications using it will lose access.',
|
||||
empty: 'No API tokens yet.',
|
||||
},
|
||||
sessions: {
|
||||
title: 'Active Sessions',
|
||||
description: 'Manage your active sessions. You can revoke access for any session except the current one.',
|
||||
current: 'Current session',
|
||||
lastUsed: 'Last used',
|
||||
created: 'Created',
|
||||
deleteConfirm: 'Are you sure you want to end this session? The device will be logged out.',
|
||||
deleteAll: 'End all other sessions',
|
||||
deleteAllConfirm: 'Are you sure you want to end all other sessions? All devices except the current one will be logged out.',
|
||||
empty: 'No other active sessions.',
|
||||
unknown: 'Unknown device',
|
||||
},
|
||||
notifications: {
|
||||
title: 'Notifications',
|
||||
empty: 'No notifications',
|
||||
|
||||
@@ -423,6 +423,18 @@ export default {
|
||||
markAllRead: 'Прочитать все',
|
||||
loadMore: 'Загрузить ещё',
|
||||
},
|
||||
sessions: {
|
||||
title: 'Активные сессии',
|
||||
description: 'Управляйте активными сессиями. Вы можете завершить любую сессию кроме текущей.',
|
||||
current: 'Текущая сессия',
|
||||
lastUsed: 'Последнее использование',
|
||||
created: 'Создана',
|
||||
deleteConfirm: 'Вы уверены, что хотите завершить эту сессию? Устройство будет разлогинено.',
|
||||
deleteAll: 'Завершить все остальные',
|
||||
deleteAllConfirm: 'Вы уверены, что хотите завершить все остальные сессии? Все устройства кроме текущего будут разлогинены.',
|
||||
empty: 'Нет других активных сессий.',
|
||||
unknown: 'Неизвестное устройство',
|
||||
},
|
||||
server: {
|
||||
selectServer: 'Сервер',
|
||||
addServer: 'Добавить сервер',
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
<template>
|
||||
<AccountSettings />
|
||||
<UDashboardPanel id="account">
|
||||
<template #header>
|
||||
<UDashboardNavbar :title="t('account.title')">
|
||||
<template #leading>
|
||||
<UDashboardSidebarCollapse />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
<template #body>
|
||||
<AccountSettings />
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AccountSettings from '@/components/features/account/AccountSettings.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
@@ -51,6 +51,13 @@ const api = {
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config
|
||||
const isRefreshRequest = originalRequest.url?.includes('/auth/refresh/token')
|
||||
if (isRefreshRequest) {
|
||||
$ls.invalidateTokens()
|
||||
app.config.globalProperties.$router.push('/')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (error.response.status === 401 && !originalRequest._retry) {
|
||||
console.debug('We have 401')
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { ApiTokenItem, ApiTokenPermission } from 'taskview-api'
|
||||
import { $tvApi } from '@/plugins/axios'
|
||||
|
||||
interface ApiTokensStoreState {
|
||||
tokens: ApiTokenItem[]
|
||||
permissions: ApiTokenPermission[]
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export const useApiTokensStore = defineStore('apiTokens', {
|
||||
state: (): ApiTokensStoreState => ({
|
||||
tokens: [],
|
||||
permissions: [],
|
||||
loading: false,
|
||||
}),
|
||||
actions: {
|
||||
async fetchTokens(): Promise<void> {
|
||||
this.loading = true
|
||||
const result = await $tvApi.apiTokens.fetch()
|
||||
this.tokens = result || []
|
||||
this.loading = false
|
||||
},
|
||||
|
||||
async createToken(data: { name: string; allowedPermissions?: string[]; allowedGoalIds?: number[]; expiresAt?: string | null }): Promise<{ token: string; item: ApiTokenItem } | null> {
|
||||
const result = await $tvApi.apiTokens.create(data)
|
||||
if (!result) return null
|
||||
this.tokens.push(result.item)
|
||||
return result
|
||||
},
|
||||
|
||||
async deleteToken(id: number): Promise<void> {
|
||||
const result = await $tvApi.apiTokens.delete(id)
|
||||
if (!result) return
|
||||
const index = this.tokens.findIndex((t) => t.id === id)
|
||||
if (index !== -1) {
|
||||
this.tokens.splice(index, 1)
|
||||
}
|
||||
},
|
||||
|
||||
async fetchPermissions(): Promise<void> {
|
||||
const result = await $tvApi.apiTokens.fetchPermissions()
|
||||
this.permissions = result || []
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -15,32 +15,35 @@ import type { AppResponse } from '@/types/global-app.types'
|
||||
import type { TaskItem } from '@/types/tasks.types'
|
||||
|
||||
export const useBaseScreenStore = defineStore('base-screen-store', {
|
||||
state(): BaseScreenState {
|
||||
return {
|
||||
activeWidgetInMobile: 'today',
|
||||
wasCalled: false,
|
||||
tasks: [], //all tasks in the app
|
||||
tasksToday: [],
|
||||
tasksUpcoming: [],
|
||||
tasksLastCompleted: [],
|
||||
//todo rename "users" to "usersByProject"
|
||||
users: [], //show users by project
|
||||
searchTask: '',
|
||||
// addTaskDialog: false,//shoud we offer add goals
|
||||
lists: [],
|
||||
// listToGoal: {},
|
||||
loading: false,
|
||||
//todo rename "assignees" to "assigneesByTask"
|
||||
assignees: [],
|
||||
filterAndSorting: {
|
||||
sort: 'new',
|
||||
filters: {
|
||||
...FILTER_DEFAULT,
|
||||
},
|
||||
},
|
||||
taskIdToUser: {},
|
||||
}
|
||||
getters: {
|
||||
listMap: (state) => {
|
||||
return new Map(state.lists.map((item) => [item.listId, { name: item.listName, goalId: item.goalId }]))
|
||||
},
|
||||
},
|
||||
state: (): BaseScreenState => ({
|
||||
activeWidgetInMobile: 'today',
|
||||
wasCalled: false,
|
||||
tasks: [], //all tasks in the app
|
||||
tasksToday: [],
|
||||
tasksUpcoming: [],
|
||||
tasksLastCompleted: [],
|
||||
//todo rename "users" to "usersByProject"
|
||||
users: [], //show users by project
|
||||
searchTask: '',
|
||||
// addTaskDialog: false,//shoud we offer add goals
|
||||
lists: [],
|
||||
// listToGoal: {},
|
||||
loading: false,
|
||||
//todo rename "assignees" to "assigneesByTask"
|
||||
assignees: [],
|
||||
filterAndSorting: {
|
||||
sort: 'new',
|
||||
filters: {
|
||||
...FILTER_DEFAULT,
|
||||
},
|
||||
},
|
||||
taskIdToUser: {},
|
||||
}),
|
||||
actions: {
|
||||
async fetchAllState() {
|
||||
this.taskIdToUser = {}
|
||||
@@ -101,18 +104,18 @@ export const useBaseScreenStore = defineStore('base-screen-store', {
|
||||
localTask.complete = task.complete
|
||||
|
||||
switch (prop) {
|
||||
case 'tasks':
|
||||
this.processLastAdded(task)
|
||||
break
|
||||
case 'tasksToday':
|
||||
this.processToday(task)
|
||||
break
|
||||
case 'tasksUpcoming':
|
||||
this.processUpcoming(task)
|
||||
break
|
||||
case 'tasksLastCompleted':
|
||||
this.processLastCompleted(task)
|
||||
break
|
||||
case 'tasks':
|
||||
this.processLastAdded(task)
|
||||
break
|
||||
case 'tasksToday':
|
||||
this.processToday(task)
|
||||
break
|
||||
case 'tasksUpcoming':
|
||||
this.processUpcoming(task)
|
||||
break
|
||||
case 'tasksLastCompleted':
|
||||
this.processLastCompleted(task)
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,12 +12,13 @@ export const useCollaborationStore = defineStore('collaboration', {
|
||||
state(): CollaborationStore {
|
||||
return {
|
||||
users: [],
|
||||
allUsers: [],
|
||||
}
|
||||
},
|
||||
|
||||
getters: {
|
||||
userMap: (state) => {
|
||||
return new Map(state.users.map((user) => [user.id, user]))
|
||||
return new Map(state.allUsers.map((user) => [user.id, user]))
|
||||
},
|
||||
},
|
||||
|
||||
@@ -30,10 +31,10 @@ export const useCollaborationStore = defineStore('collaboration', {
|
||||
async fetchAllCollaborationUsers(): Promise<void> {
|
||||
const users = await $tvApi.collaboration.fetchAllUsers()
|
||||
if (!users) return
|
||||
this.users = users
|
||||
this.allUsers = users
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
/**
|
||||
* Fetch users for a specific goal for collaboration section
|
||||
*/
|
||||
async fetchCollaborationUsersForGoal(goalId: GoalItem['id']): Promise<void> {
|
||||
@@ -46,16 +47,15 @@ export const useCollaborationStore = defineStore('collaboration', {
|
||||
const result = await $tvApi.collaboration.inviteUserToGoal(data)
|
||||
if (!result) return false
|
||||
this.users.push(result)
|
||||
this.allUsers.push(result)
|
||||
return true
|
||||
},
|
||||
|
||||
async deleteUserFromCollaboration(data: CollaborationArgDeleteUser): Promise<void> {
|
||||
const result = await $tvApi.collaboration.deleteUserFromGoal(data)
|
||||
if (!result) return
|
||||
const index = this.users.findIndex((usr) => usr.id === data.id)
|
||||
if (index !== -1) {
|
||||
this.users.splice(index, 1)
|
||||
}
|
||||
this.users = this.users.filter((usr) => usr.id !== data.id)
|
||||
this.allUsers = this.allUsers.filter((usr) => !(usr.id === data.id && usr.goalId === data.goalId))
|
||||
},
|
||||
|
||||
async toggleUserRole(data: CollaborationArgToggleUserRoles) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useUserStore } from './user.store'
|
||||
|
||||
export const useGoalsStore = defineStore('goals', {
|
||||
state: (): GoalsStoreState => ({
|
||||
initialized: false,
|
||||
loading: false,
|
||||
selectedItemId: -1,
|
||||
goals: [],
|
||||
@@ -41,6 +42,7 @@ export const useGoalsStore = defineStore('goals', {
|
||||
}
|
||||
this.goals = result || []
|
||||
this.loading = false
|
||||
this.initialized = true
|
||||
},
|
||||
|
||||
async addGoal(goal: GoalArgItemAdd): Promise<GoalItem | null> {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { SessionItem } from 'taskview-api'
|
||||
import { $tvApi } from '@/plugins/axios'
|
||||
|
||||
interface SessionsStoreState {
|
||||
sessions: SessionItem[]
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export const useSessionsStore = defineStore('sessions', {
|
||||
state: (): SessionsStoreState => ({
|
||||
sessions: [],
|
||||
loading: false,
|
||||
}),
|
||||
actions: {
|
||||
async fetchSessions(): Promise<void> {
|
||||
this.loading = true
|
||||
this.sessions = []
|
||||
const result = await $tvApi.sessions.fetch()
|
||||
this.sessions = result || []
|
||||
this.loading = false
|
||||
},
|
||||
|
||||
async deleteSession(id: number): Promise<void> {
|
||||
const result = await $tvApi.sessions.delete(id)
|
||||
if (!result) return
|
||||
const index = this.sessions.findIndex((s) => s.id === id)
|
||||
if (index !== -1) {
|
||||
this.sessions.splice(index, 1)
|
||||
}
|
||||
},
|
||||
|
||||
async deleteAllSessions(): Promise<void> {
|
||||
const result = await $tvApi.sessions.deleteAll()
|
||||
if (!result) return
|
||||
this.sessions = this.sessions.filter((s) => s.isCurrent)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -4,4 +4,5 @@ export type CollaborationUsers = CollaborationResponseFetchAllUsers[];
|
||||
|
||||
export type CollaborationStore = {
|
||||
users: CollaborationUsers;
|
||||
allUsers: CollaborationUsers;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ export const DEFAULT_GOAL_ITEM: GoalItem = {
|
||||
export const AllGoalPermissions = TvPermissions
|
||||
|
||||
export type GoalsStoreState = {
|
||||
initialized: boolean;
|
||||
loading: boolean;
|
||||
selectedItemId: GoalItem['id'];
|
||||
goals: GoalItem[];
|
||||
|
||||
Reference in New Issue
Block a user