Compare commits

..

7 Commits

Author SHA1 Message Date
Gimanh 3aff4c77f2 Merge pull request #44 from Gimanh/chore/version-1-40
chore: new version 1.41
2026-03-30 00:20:56 +02:00
Nikolai Giman 1d6af3ab7d chore: new version 1.41 2026-03-30 00:20:34 +02:00
Gimanh 5600d261bc Merge pull request #43 from Gimanh/feat/api-tokens
feat: api-tokens
2026-03-29 22:42:55 +02:00
Nikolai Giman 35776d9eb5 fix: filter only allowed projects for api-tokens 2026-03-29 15:55:09 +02:00
Nikolai Giman 9f0cfccdc6 feat: api-tokens 2026-03-29 14:29:31 +02:00
Gimanh ba17eff713 Merge pull request #42 from Gimanh/fix/migration
fix: migration
2026-03-23 22:13:07 +01:00
Nikolai Giman 08ee2af865 fix: migration 2026-03-23 22:12:44 +01:00
81 changed files with 3234 additions and 313 deletions
+3 -1
View File
@@ -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": {
+25
View File
@@ -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;
}
}
+3
View File
@@ -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;
+14 -3
View File
@@ -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(),
};
+2
View File
@@ -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(),
];
+28 -3
View File
@@ -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);
}
+57
View File
@@ -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();
+4
View File
@@ -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();
};
+18
View File
@@ -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_';
+65 -66
View File
@@ -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();
+3 -3
View File
@@ -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();
}
}
-76
View File
@@ -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;
}
}
}
+80
View File
@@ -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 {
+15 -5
View File
@@ -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)
}
}
+7
View File
@@ -0,0 +1,7 @@
import { type } from 'arktype'
export const SessionDeleteSchema = type({
id: 'number',
})
export type SessionDeleteArg = typeof SessionDeleteSchema.infer
-8
View File
@@ -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(),
+19
View File
@@ -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');
+165
View File
@@ -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))
```
+69
View File
@@ -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
View File
@@ -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": [
+40 -4
View File
@@ -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
}
+3 -1
View File
@@ -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';
+10
View File
@@ -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
View File
@@ -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
+12 -7
View File
@@ -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 },
+19 -1
View File
@@ -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
+39
View File
@@ -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',
+12
View File
@@ -423,6 +423,18 @@ export default {
markAllRead: 'Прочитать все',
loadMore: 'Загрузить ещё',
},
sessions: {
title: 'Активные сессии',
description: 'Управляйте активными сессиями. Вы можете завершить любую сессию кроме текущей.',
current: 'Текущая сессия',
lastUsed: 'Последнее использование',
created: 'Создана',
deleteConfirm: 'Вы уверены, что хотите завершить эту сессию? Устройство будет разлогинено.',
deleteAll: 'Завершить все остальные',
deleteAllConfirm: 'Вы уверены, что хотите завершить все остальные сессии? Все устройства кроме текущего будут разлогинены.',
empty: 'Нет других активных сессий.',
unknown: 'Неизвестное устройство',
},
server: {
selectServer: 'Сервер',
addServer: 'Добавить сервер',
+15 -1
View File
@@ -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>
+7
View File
@@ -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')
+46
View File
@@ -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 || []
},
},
})
+40 -37
View File
@@ -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
}
}
},
+8 -8
View File
@@ -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) {
+2
View File
@@ -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> {
+39
View File
@@ -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)
},
},
})
+1
View File
@@ -4,4 +4,5 @@ export type CollaborationUsers = CollaborationResponseFetchAllUsers[];
export type CollaborationStore = {
users: CollaborationUsers;
allUsers: CollaborationUsers;
};
+1
View File
@@ -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[];