mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 784652ef5b | |||
| 1f1a1b770f | |||
| e80ab33dda | |||
| 8c7be7362f | |||
| 57ec7c01b6 | |||
| d0f664f78e | |||
| 7bbb36d45e | |||
| 9934bf06d8 | |||
| fd13b33915 | |||
| 64089fd6e7 | |||
| b7a50049bb | |||
| 504ae503dd | |||
| 263883f32d | |||
| 90b55fd82a | |||
| 2e9a4945ce | |||
| 0d70023fb3 | |||
| 645f2e21e5 | |||
| adce016a05 | |||
| b7f2380d45 | |||
| 58eb93e564 | |||
| 4e5e5fb579 | |||
| 314e5a6377 | |||
| 955697f40f | |||
| e5dbba8e2a | |||
| bb28bac9f7 | |||
| 6c990aff4f | |||
| b296046605 | |||
| 40cd8064f5 | |||
| f4a765f40b | |||
| b9ce7261f0 | |||
| bc08c839bc | |||
| 83979ac47c | |||
| a9be0d987a | |||
| 30274069a0 | |||
| 0a8497af59 | |||
| 284a49ce8c | |||
| 64a227303e | |||
| ae88d0f42a | |||
| d40cc0aa1b | |||
| e99d9d5515 | |||
| 9b38e3cd9d | |||
| 9c6d33cefe | |||
| d008fa4f78 |
@@ -27,6 +27,10 @@ SMTP_PASSWORD=your_smtp_password_here
|
||||
SMTP_ENCRYPTION=ssl
|
||||
SMTP_FROM_NAME=TaskView
|
||||
SMTP_FROM_EMAIL=your_email@example.com
|
||||
# Email a person when they are invited to a project (requires SMTP)
|
||||
INVITE_EMAIL_ENABLED=false
|
||||
# Max invite emails one user may trigger per hour (default 30)
|
||||
# INVITE_EMAIL_HOURLY_LIMIT=30
|
||||
|
||||
# Encryption (32-byte hex key for AES-256-GCM)
|
||||
# Generate a key: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
@@ -48,6 +52,14 @@ GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
|
||||
# GITLAB_BASE_URL=https://gitlab.yourcompany.com
|
||||
# GITLAB_API_URL=https://gitlab.yourcompany.com/api/v4
|
||||
|
||||
# Gitea Integration OAuth
|
||||
GITEA_INTEGRATION_CLIENT_ID=
|
||||
GITEA_INTEGRATION_CLIENT_SECRET=
|
||||
GITEA_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitea/callback
|
||||
# For self-hosted Gitea, override these:
|
||||
# GITEA_BASE_URL=https://gitea.yourcompany.com
|
||||
# GITEA_API_URL=https://gitea.yourcompany.com/api/v1
|
||||
|
||||
# Firebase Cloud Messaging (push notifications for mobile, optional)
|
||||
# Path to Firebase service account JSON file
|
||||
# FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
|
||||
|
||||
+30
-1
@@ -1,9 +1,38 @@
|
||||
// https://github.com/Gimanh/taskview-community/issues/88
|
||||
// GH-88: one worker per core ('max') multiplied by the per-worker DB pool
|
||||
// (DB_POOL_MAX, default 20) exhausts Postgres max_connections (default 100)
|
||||
// on many-core hosts. Default to 2 workers; scale explicitly via PM2_INSTANCES.
|
||||
// If you set PM2_INSTANCES to 'max', size DB_POOL_MAX yourself so that
|
||||
// workers × DB_POOL_MAX stays below the Postgres max_connections limit.
|
||||
const rawInstances = process.env.PM2_INSTANCES;
|
||||
const instances = rawInstances === 'max'
|
||||
? 'max'
|
||||
: Number(rawInstances) > 0
|
||||
? Number(rawInstances)
|
||||
: 2;
|
||||
|
||||
const poolMax = Number(process.env.DB_POOL_MAX) > 0 ? Number(process.env.DB_POOL_MAX) : 20;
|
||||
|
||||
if (instances === 'max') {
|
||||
console.warn(
|
||||
'[taskview] PM2_INSTANCES=max spawns one worker per CPU core, each with its own '
|
||||
+ `DB pool (${poolMax} connections). Make sure workers x DB_POOL_MAX stays below `
|
||||
+ 'the Postgres max_connections limit (default 100).'
|
||||
);
|
||||
} else if (instances * poolMax > 80) {
|
||||
console.warn(
|
||||
`[taskview] DB connection budget: ${instances} worker(s) x ${poolMax} pool connections = `
|
||||
+ `${instances * poolMax} potential connections. Postgres default max_connections is 100 - `
|
||||
+ 'lower PM2_INSTANCES or DB_POOL_MAX if the database rejects connections.'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'taskview-server',
|
||||
script: 'taskview-server.js',
|
||||
instances: 'max',
|
||||
instances,
|
||||
watch: true,
|
||||
ignore_watch: ['logs'],
|
||||
autorestart: true,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.49.0",
|
||||
"version": "1.51.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch ./server.ts",
|
||||
"start": "NODE_ENV=production node ./dist/taskview-server.js",
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
// GH-88: one worker per core ('max') multiplied by the per-worker DB pool
|
||||
// (DB_POOL_MAX, default 20) exhausts Postgres max_connections (default 100)
|
||||
// on many-core hosts. Default to 2 workers; scale explicitly via PM2_INSTANCES.
|
||||
// If you set PM2_INSTANCES to 'max', size DB_POOL_MAX yourself so that
|
||||
// workers × DB_POOL_MAX stays below the Postgres max_connections limit.
|
||||
const rawInstances = process.env.PM2_INSTANCES;
|
||||
const instances = rawInstances === 'max'
|
||||
? 'max'
|
||||
: Number(rawInstances) > 0
|
||||
? Number(rawInstances)
|
||||
: 2;
|
||||
|
||||
const poolMax = Number(process.env.DB_POOL_MAX) > 0 ? Number(process.env.DB_POOL_MAX) : 20;
|
||||
|
||||
if (instances === 'max') {
|
||||
console.warn(
|
||||
'[taskview] PM2_INSTANCES=max spawns one worker per CPU core, each with its own '
|
||||
+ `DB pool (${poolMax} connections). Make sure workers x DB_POOL_MAX stays below `
|
||||
+ 'the Postgres max_connections limit (default 100).'
|
||||
);
|
||||
} else if (instances * poolMax > 80) {
|
||||
console.warn(
|
||||
`[taskview] DB connection budget: ${instances} worker(s) x ${poolMax} pool connections = `
|
||||
+ `${instances * poolMax} potential connections. Postgres default max_connections is 100 - `
|
||||
+ 'lower PM2_INSTANCES or DB_POOL_MAX if the database rejects connections.'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'taskview-server',
|
||||
script: 'taskview-server.js',
|
||||
instances: 'max',
|
||||
watch: true,
|
||||
ignore_watch: ['logs'],
|
||||
instances,
|
||||
watch: false,
|
||||
autorestart: true,
|
||||
max_memory_restart: '1G',
|
||||
env_production: {
|
||||
|
||||
@@ -5,6 +5,9 @@ import { corsMiddleware } from './middlewares/cors';
|
||||
import errorHandler from './middlewares/error-handler';
|
||||
import routes from './routes';
|
||||
import passport, { initPassportLogin } from './tv-modules/auth/strategies/passport-login';
|
||||
import { LoginMethods } from './tv-modules/auth/LoginMethods';
|
||||
import { InviteEmailDispatcher } from './tv-modules/collaboration/InviteEmailDispatcher';
|
||||
import { PublicApiUrl } from './modules/public-url';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { registerAllEventHandlers, startAllWorkers } from './core/all-events';
|
||||
|
||||
@@ -13,6 +16,10 @@ export default class App {
|
||||
public port: number;
|
||||
|
||||
constructor(port: number) {
|
||||
LoginMethods.validateOnStartup();
|
||||
PublicApiUrl.validateOnStartup();
|
||||
InviteEmailDispatcher.validateOnStartup();
|
||||
|
||||
this.app = express();
|
||||
this.port = port;
|
||||
|
||||
@@ -31,7 +38,17 @@ export default class App {
|
||||
protected extendApp(): void { }
|
||||
protected extendMiddlewares(): void { }
|
||||
|
||||
private resolveTrustProxy(): boolean | number | string {
|
||||
const raw = process.env.TRUST_PROXY?.trim();
|
||||
if (!raw || raw.toLowerCase() === 'false') return false;
|
||||
if (raw.toLowerCase() === 'true') return true;
|
||||
if (/^\d+$/.test(raw)) return Number(raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
private initializeMiddlewares() {
|
||||
this.app.set('trust proxy', this.resolveTrustProxy());
|
||||
|
||||
//add tvJson method, clien need response format like {response: data}
|
||||
this.app.use((_req: Request, res: Response, next) => {
|
||||
res.tvJson = function (data: any) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { RecurrenceRulesSchemaTypeForSelect, SprintsSchemaTypeForSelect, TasksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { TimeEntryWithUser } from '../tv-modules/time-tracking/types';
|
||||
import type { InviteEmailLocale } from '../tv-modules/collaboration/collaboration.server.types';
|
||||
import { $logger } from '../modules/logget';
|
||||
|
||||
export interface AppEvents {
|
||||
@@ -8,7 +9,7 @@ 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.userAdded': { goalId: number; email: string; initiatorId: number; locale: InviteEmailLocale };
|
||||
'collaboration.userRemoved': { goalId: number; collaborationUserId: number; initiatorId: number };
|
||||
'collaboration.rolesChanged': { goalId: number; collaborationUserId: number; initiatorId: number };
|
||||
'time-entry.started': { entry: TimeEntryWithUser; taskId: number; userId: number; goalId: number };
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TimeTrackingDispatcher } from '../tv-modules/time-tracking/TimeTracking
|
||||
import { SprintsDispatcher } from '../tv-modules/sprints/SprintsDispatcher';
|
||||
import { RecurrenceDispatcher } from '../tv-modules/recurrence/RecurrenceDispatcher';
|
||||
import { MessagingDispatcher } from '../tv-modules/messaging/MessagingDispatcher';
|
||||
import { InviteEmailDispatcher } from '../tv-modules/collaboration/InviteEmailDispatcher';
|
||||
|
||||
const dispatchers: Dispatcher[] = [
|
||||
new NotificationDispatcher(),
|
||||
@@ -16,6 +17,7 @@ const dispatchers: Dispatcher[] = [
|
||||
new SprintsDispatcher(),
|
||||
new RecurrenceDispatcher(),
|
||||
new MessagingDispatcher(),
|
||||
new InviteEmailDispatcher(),
|
||||
];
|
||||
|
||||
export function registerAllEventHandlers() {
|
||||
|
||||
@@ -704,5 +704,38 @@
|
||||
"description": [
|
||||
"Add external_team_id to messaging_identity_map so Slack identities are keyed by (provider, team, user) — prevents cross-workspace identity collision"
|
||||
]
|
||||
},
|
||||
"55": {
|
||||
"version": "1.60.0",
|
||||
"name": "Recurrence schedule mode",
|
||||
"releaseDate": "20260712",
|
||||
"scripts": [
|
||||
"/1.60.0/0.alter-recurrence-add-schedule-mode.sql"
|
||||
],
|
||||
"description": [
|
||||
"Add schedule_mode to recurrence_rules: 'fixed' (calendar schedule) or 'after-completion' (next occurrence = completion day + interval)"
|
||||
]
|
||||
},
|
||||
"56": {
|
||||
"version": "1.61.0",
|
||||
"name": "Gitea integration provider",
|
||||
"releaseDate": "20260726",
|
||||
"scripts": [
|
||||
"/1.61.0/0.alter-integrations-provider-check-gitea.sql"
|
||||
],
|
||||
"description": [
|
||||
"Extend integrations_provider_check constraint to allow the 'gitea' provider alongside 'github' and 'gitlab'"
|
||||
]
|
||||
},
|
||||
"57": {
|
||||
"version": "1.62.0",
|
||||
"name": "Invite email rate limiting",
|
||||
"releaseDate": "20260730",
|
||||
"scripts": [
|
||||
"/1.62.0/0.create-invite-emails.sql"
|
||||
],
|
||||
"description": [
|
||||
"Log of sent project-invite emails (collaboration.invite_emails) backing the per-recipient cooldown and the hourly per-initiator sending cap"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 'fixed' — occurrences follow the calendar schedule (rrule anchored at dtstart);
|
||||
-- 'after-completion' — the next occurrence is one FREQ/INTERVAL step after the
|
||||
-- day the current instance was completed (Todoist "every!"), no calendar anchor.
|
||||
ALTER TABLE tasks.recurrence_rules
|
||||
ADD COLUMN IF NOT EXISTS schedule_mode VARCHAR(20) NOT NULL DEFAULT 'fixed';
|
||||
|
||||
ALTER TABLE tasks.recurrence_rules
|
||||
DROP CONSTRAINT IF EXISTS recurrence_schedule_mode_valid;
|
||||
ALTER TABLE tasks.recurrence_rules
|
||||
ADD CONSTRAINT recurrence_schedule_mode_valid CHECK (schedule_mode IN ('fixed', 'after-completion'));
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tasks.integrations DROP CONSTRAINT IF EXISTS integrations_provider_check;
|
||||
ALTER TABLE tasks.integrations ADD CONSTRAINT integrations_provider_check CHECK (provider IN ('github', 'gitlab', 'gitea'));
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Log of sent project-invite emails, used to rate-limit sending:
|
||||
-- a 24h per-recipient cooldown and an hourly cap per initiator.
|
||||
-- Rows older than 24 hours are pruned opportunistically before each insert.
|
||||
CREATE TABLE IF NOT EXISTS collaboration.invite_emails (
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
initiator_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
|
||||
sent_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_emails_initiator_sent ON collaboration.invite_emails(initiator_id, sent_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_emails_goal_email_sent ON collaboration.invite_emails(goal_id, email, sent_at);
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
export class PublicApiUrl {
|
||||
static configured(): string | null {
|
||||
const raw = process.env.API_PUBLIC_URL;
|
||||
if (!raw || !raw.trim()) return null;
|
||||
return raw.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
static base(req: Request): string {
|
||||
return PublicApiUrl.configured() ?? `${req.protocol}://${req.get('host')}`;
|
||||
}
|
||||
|
||||
static validateOnStartup(): void {
|
||||
const raw = process.env.API_PUBLIC_URL;
|
||||
if (!raw || !raw.trim()) return;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw.trim());
|
||||
} catch {
|
||||
throw new Error(`API_PUBLIC_URL is not a valid URL: "${raw}"`);
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`API_PUBLIC_URL must be an http(s) URL, got: "${raw}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { AnalyticsDataset, AnalyticsSeriesPayload, LocalizedText } from 'ta
|
||||
import type { AmountPerTagMonthSectionRow } from '../row.types'
|
||||
import { UNTAGGED_TAG_ID } from '../../types'
|
||||
|
||||
const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged' }
|
||||
const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged', de: 'Ohne Tags', es: 'Sin etiquetas' }
|
||||
|
||||
export type BuildTagAmountPayloadArgs = {
|
||||
rows: AmountPerTagMonthSectionRow[]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,11 @@ import { z } from 'zod';
|
||||
import { Email } from '../../core/Email';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import {
|
||||
ChangeDefaultUserCredentialsSchema,
|
||||
ChangeOwnPasswordByPasswordSchema,
|
||||
ChangeOwnPasswordSchema,
|
||||
ChangePasswordDataScheme,
|
||||
type PasswordChangeConfirmationMode,
|
||||
ConfirmEmailReqDataSchema,
|
||||
RefreshTokenSchema,
|
||||
RemindPasswordSchema,
|
||||
@@ -15,6 +19,7 @@ import {
|
||||
UserJwtPayloadSchema,
|
||||
} from '../../types/auth.types';
|
||||
import { generateString, isEmail, time } from '../../utils/helpers';
|
||||
import { LoginMethods } from './LoginMethods';
|
||||
import EnEmailTemplate from './mail/confirm-email-en';
|
||||
import RuEmailTemplate from './mail/confirm-email-ru';
|
||||
import LoginCodeEmailTemplate from './mail/login-code-en';
|
||||
@@ -23,6 +28,10 @@ import { OrganizationRepository } from '../organizations/OrganizationRepository'
|
||||
import { GoalsRepository } from '../goals/GoalsRepository';
|
||||
|
||||
const LOGIN_CODE_TTL_MS = 5 * 60 * 1000;
|
||||
const PASSWORD_CHANGE_CODE_TTL_S = 15 * 60;
|
||||
const PASSWORD_CHANGE_CODE_RESEND_COOLDOWN_S = 60;
|
||||
// Seeded by migration 0.0.0 (app_permissions.sql) on self-hosted installs.
|
||||
const DEFAULT_USER_EMAIL = 'test@mail.dest';
|
||||
|
||||
export default class AuthController {
|
||||
private readonly jwtAlg: Algorithm = process.env.JWT_ALG as Algorithm;
|
||||
@@ -144,6 +153,11 @@ export default class AuthController {
|
||||
}
|
||||
|
||||
if (!userData) {
|
||||
if (!(await this.canCreateAccount(req, email))) {
|
||||
$logger.info(`[AuthController:sendLoginCode] public registration disabled, email not invited`);
|
||||
return res.status(403).send({ registrationDisabled: true });
|
||||
}
|
||||
|
||||
const password = this.makeidLogin(7),
|
||||
login = this.makeidLogin(7);
|
||||
|
||||
@@ -218,6 +232,11 @@ export default class AuthController {
|
||||
);
|
||||
|
||||
if (!userData) {
|
||||
if (!(await this.canCreateAccount(req, user.email))) {
|
||||
$logger.info(`[AuthController:loginByProvider] public registration disabled, email not invited`);
|
||||
return res.redirect(`${process.env.APP_URL}/login?sso_error=registration-disabled`);
|
||||
}
|
||||
|
||||
const password = this.makeidLogin(7);
|
||||
const login = this.makeidLogin(7);
|
||||
|
||||
@@ -421,6 +440,11 @@ export default class AuthController {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
if (!(await this.canCreateAccount(req, email))) {
|
||||
$logger.info(`[AuthController:registration] public registration disabled, email not invited`);
|
||||
return res.status(403).send({ registrationDisabled: true });
|
||||
}
|
||||
|
||||
password = hashSync(password, 10);
|
||||
|
||||
if (!(await this.comparePasswords(passwordRepeat, password))) {
|
||||
@@ -656,6 +680,193 @@ export default class AuthController {
|
||||
return res.json(newTokens);
|
||||
};
|
||||
|
||||
getLoginOptions = async (_req: Request, res: Response) => {
|
||||
return res.status(200).send({
|
||||
magicLink: LoginMethods.isEnabled('magic-link'),
|
||||
password: LoginMethods.isEnabled('password'),
|
||||
sso: LoginMethods.isEnabled('sso'),
|
||||
socialProviders: LoginMethods.availableSocialProviders(),
|
||||
publicRegistration: LoginMethods.publicRegistrationAllowed(),
|
||||
});
|
||||
};
|
||||
|
||||
private canCreateAccount = async (req: Request, email: string): Promise<boolean> => {
|
||||
if (LoginMethods.publicRegistrationAllowed()) return true;
|
||||
return await req.appUser.authManager.repository.isEmailInvited(email);
|
||||
};
|
||||
|
||||
private passwordChangeConfirmationMode(): PasswordChangeConfirmationMode {
|
||||
return process.env.PASSWORD_CHANGE_CONFIRMATION === 'password' ? 'password' : 'email';
|
||||
}
|
||||
|
||||
getPasswordChangeMode = async (_req: Request, res: Response) => {
|
||||
return res.status(200).send({ mode: this.passwordChangeConfirmationMode() });
|
||||
};
|
||||
|
||||
sendPasswordChangeCode = async (req: Request, res: Response) => {
|
||||
if (this.passwordChangeConfirmationMode() !== 'email') {
|
||||
return res.status(403).send();
|
||||
}
|
||||
|
||||
const userEmail = req.appUser.getUserData()?.email;
|
||||
if (!userEmail) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const userData = await req.appUser.authManager.repository.getUserByLogin(userEmail, true);
|
||||
if (!userData) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sinceLastCode = userData.remind_password_time ? now - userData.remind_password_time : null;
|
||||
if (sinceLastCode !== null && sinceLastCode < PASSWORD_CHANGE_CODE_RESEND_COOLDOWN_S) {
|
||||
return res.status(429).send({
|
||||
message: 'Please wait before requesting another code.',
|
||||
retryAfter: PASSWORD_CHANGE_CODE_RESEND_COOLDOWN_S - sinceLastCode,
|
||||
});
|
||||
}
|
||||
|
||||
// High-entropy code: the shared remind_password_code column is also redeemable
|
||||
// via the unauthenticated /password/reset endpoint, so a short numeric code
|
||||
// would be brute-forceable there.
|
||||
const code = generateString(12);
|
||||
const saved = await req.appUser.authManager.repository.setReminderCodeAndTime(userEmail, code, now);
|
||||
if (!saved) {
|
||||
$logger.error(`Can not save password change code for user ${userData.id}`);
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
const text = `Your TaskView password change code is ${code}\n\nUse this code to confirm your new password. The code expires in 15 minutes.\n\nIf you didn't request this change, ignore this email.`;
|
||||
|
||||
Email.send({
|
||||
text,
|
||||
to: userEmail,
|
||||
subject: `Your TaskView password change code: ${code}`,
|
||||
from: process.env.SMTP_FROM_EMAIL as string,
|
||||
})
|
||||
.then((ok) => {
|
||||
if (!ok) $logger.error({ to: userEmail }, 'Failed to send password change code email');
|
||||
})
|
||||
.catch((err) => $logger.error({ err, to: userEmail }, 'Failed to send password change code email'));
|
||||
|
||||
return res.status(200).end();
|
||||
};
|
||||
|
||||
changeOwnPassword = async (req: Request, res: Response) => {
|
||||
const userEmail = req.appUser.getUserData()?.email;
|
||||
if (!userEmail) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const userData = await req.appUser.authManager.repository.getUserByLogin(userEmail, true);
|
||||
if (!userData) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
if (this.passwordChangeConfirmationMode() === 'password') {
|
||||
const parsedData = ChangeOwnPasswordByPasswordSchema.safeParse(req.body);
|
||||
if (!parsedData.success) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
const validPassword = await this.comparePasswords(parsedData.data.currentPassword, userData.password);
|
||||
if (!validPassword) {
|
||||
return res.status(403).send({ field: 'currentPassword' });
|
||||
}
|
||||
|
||||
return this.applyNewPassword(res, req, userData.id, parsedData.data.password);
|
||||
}
|
||||
|
||||
const parsedData = ChangeOwnPasswordSchema.safeParse(req.body);
|
||||
if (!parsedData.success) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
if (!userData.remind_password_code || !userData.remind_password_time) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (now > userData.remind_password_time + PASSWORD_CHANGE_CODE_TTL_S) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
if (userData.remind_password_code !== parsedData.data.code) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
await req.appUser.authManager.repository.setReminderCodeAndTime(userEmail, null, null);
|
||||
|
||||
return this.applyNewPassword(res, req, userData.id, parsedData.data.password);
|
||||
};
|
||||
|
||||
private async applyNewPassword(res: Response, req: Request, userId: number, newPassword: string) {
|
||||
const passwordHash = hashSync(newPassword, 10);
|
||||
const result = await req.appUser.authManager.repository.updateUserPassword(passwordHash, userId);
|
||||
if (!result) {
|
||||
$logger.error(`Can not update password for user ${userId}`);
|
||||
return res.status(500).send();
|
||||
}
|
||||
|
||||
const currentSessionId = req.appUser.getTokenId();
|
||||
await req.appUser.authManager.sessionStorage.deleteAllSessions(userId, currentSessionId);
|
||||
|
||||
return res.status(200).send({ changed: true });
|
||||
}
|
||||
|
||||
changeDefaultUserCredentials = async (req: Request, res: Response) => {
|
||||
const parsedData = ChangeDefaultUserCredentialsSchema.safeParse(req.body);
|
||||
if (!parsedData.success) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
const userEmail = req.appUser.getUserData()?.email;
|
||||
if (!userEmail) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const userData = await req.appUser.authManager.repository.getUserByLogin(userEmail, true);
|
||||
if (!userData || userData.email.toLowerCase() !== DEFAULT_USER_EMAIL) {
|
||||
return res.status(403).send();
|
||||
}
|
||||
|
||||
const validPassword = await this.comparePasswords(parsedData.data.currentPassword, userData.password);
|
||||
if (!validPassword) {
|
||||
return res.status(403).send({ field: 'currentPassword' });
|
||||
}
|
||||
|
||||
const { login, email } = parsedData.data;
|
||||
|
||||
if (login !== userData.login && (await req.appUser.authManager.repository.getUserByLogin(login))) {
|
||||
return res.status(409).send({ field: 'login' });
|
||||
}
|
||||
if (email !== userData.email && (await req.appUser.authManager.repository.getUserByLogin(email, true))) {
|
||||
return res.status(409).send({ field: 'email' });
|
||||
}
|
||||
|
||||
const updated = await req.appUser.authManager.repository.updateUserCredentials({
|
||||
userId: userData.id,
|
||||
oldEmail: userData.email,
|
||||
login,
|
||||
email,
|
||||
passwordHash: hashSync(parsedData.data.password, 10),
|
||||
});
|
||||
if (updated === 'conflict') {
|
||||
return res.status(409).send({ field: 'email' });
|
||||
}
|
||||
if (updated !== 'ok') {
|
||||
return res.status(500).send();
|
||||
}
|
||||
|
||||
// JWTs carry login/email and refresh does not re-read them from the DB,
|
||||
// so drop every session and make the user sign in with the new credentials.
|
||||
await req.appUser.authManager.sessionStorage.deleteAllSessions(userData.id);
|
||||
this.clearRefreshToken(res);
|
||||
|
||||
return res.status(200).send({ changed: true });
|
||||
};
|
||||
|
||||
sendDeleteAccountCode = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
const userEmail = req.appUser.getUserData()?.email;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { CollaborationUsersSchema, OrganizationMembersSchema, SsoIdentitiesSchema, UsersSchema } from 'taskview-db-schemas';
|
||||
import { Database } from '../../modules/db';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import type { RegisterUserInDb, UserDbRecord } from '../../types/auth.types';
|
||||
import type { RegisterUserInDb, UpdateUserCredentialsArgs, UpdateUserCredentialsResult, UserDbRecord } from '../../types/auth.types';
|
||||
|
||||
export default class AuthModel {
|
||||
private readonly db: Database;
|
||||
@@ -67,6 +69,28 @@ export default class AuthModel {
|
||||
}
|
||||
}
|
||||
|
||||
async isEmailInvited(email: string): Promise<boolean> {
|
||||
const normalized = email.toLowerCase();
|
||||
try {
|
||||
const orgMembers = await this.db.dbDrizzle
|
||||
.select({ email: OrganizationMembersSchema.email })
|
||||
.from(OrganizationMembersSchema)
|
||||
.where(sql`lower(${OrganizationMembersSchema.email}) = ${normalized}`)
|
||||
.limit(1);
|
||||
if (orgMembers.length > 0) return true;
|
||||
|
||||
const collaborators = await this.db.dbDrizzle
|
||||
.select({ email: CollaborationUsersSchema.email })
|
||||
.from(CollaborationUsersSchema)
|
||||
.where(sql`lower(${CollaborationUsersSchema.email}) = ${normalized}`)
|
||||
.limit(1);
|
||||
return collaborators.length > 0;
|
||||
} catch (error: unknown) {
|
||||
$logger.error(error, '[AuthModel:isEmailInvited] failed to check invitations');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchUserById(id: number): Promise<UserDbRecord | false> {
|
||||
const query = 'SELECT * FROM tv_auth.users WHERE id = $1;';
|
||||
try {
|
||||
@@ -133,6 +157,38 @@ export default class AuthModel {
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserCredentials(args: UpdateUserCredentialsArgs): Promise<UpdateUserCredentialsResult> {
|
||||
try {
|
||||
await this.db.dbDrizzle.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(UsersSchema)
|
||||
.set({ login: args.login, email: args.email, password: args.passwordHash })
|
||||
.where(eq(UsersSchema.id, args.userId));
|
||||
await tx
|
||||
.update(OrganizationMembersSchema)
|
||||
.set({ email: args.email })
|
||||
.where(eq(OrganizationMembersSchema.email, args.oldEmail));
|
||||
await tx
|
||||
.update(CollaborationUsersSchema)
|
||||
.set({ email: args.email })
|
||||
.where(eq(CollaborationUsersSchema.email, args.oldEmail));
|
||||
await tx
|
||||
.update(SsoIdentitiesSchema)
|
||||
.set({ email: args.email })
|
||||
.where(eq(SsoIdentitiesSchema.userId, args.userId));
|
||||
});
|
||||
return 'ok';
|
||||
} catch (error) {
|
||||
// unique(organization_id, email): the new email is already an invited member of one of the user's orgs
|
||||
const pgCode = (error as { code?: string })?.code ?? (error as { cause?: { code?: string } })?.cause?.code;
|
||||
if (pgCode === '23505') {
|
||||
return 'conflict';
|
||||
}
|
||||
$logger.error(error, `Can not update credentials for user ${args.userId}`);
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserPassword(password: string, userId: number): Promise<boolean> {
|
||||
try {
|
||||
const query = 'UPDATE tv_auth.users SET password = $1 WHERE id = $2';
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Router, type NextFunction, type Request, type Response } from 'express'
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import AuthController from './AuthController';
|
||||
import { IsLoggedIn } from './middlewares/is-logged-in';
|
||||
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth';
|
||||
import { RequireAnyLoginMethod, RequireLoginMethod, RequireSocialProvider } from './middlewares/require-login-method';
|
||||
import passport from './strategies/passport-login';
|
||||
import { ExternalProviderScope } from './strategies/external-auth.types';
|
||||
export default class AuthRoutes implements Routable {
|
||||
@@ -19,13 +21,20 @@ export default class AuthRoutes implements Routable {
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.post('/send-login-code', this.authController.sendLoginCode);
|
||||
this.router.post('/login-by-code', this.authController.loginByCode);
|
||||
this.router.post('/login', this.authController.login);
|
||||
this.router.get('/login-options', this.authController.getLoginOptions);
|
||||
this.router.post('/send-login-code', [RequireLoginMethod('magic-link')], this.authController.sendLoginCode);
|
||||
// Shared one-time-code redemption: magic-link emails, SSO callbacks and social
|
||||
// OAuth callbacks all complete the login through this endpoint
|
||||
this.router.post('/login-by-code', [RequireAnyLoginMethod(['magic-link', 'sso', 'social'])], this.authController.loginByCode);
|
||||
this.router.post('/login', [RequireLoginMethod('password')], this.authController.login);
|
||||
this.router.post('/registration', this.authController.registration);
|
||||
this.router.get('/confirm/email/:code/login/:login', this.authController.confirmEmail);
|
||||
this.router.post('/email/recovery', this.authController.remindPassword);
|
||||
this.router.post('/password/reset', this.authController.changeRemindedPassword);
|
||||
this.router.post('/email/recovery', [RequireLoginMethod('password')], this.authController.remindPassword);
|
||||
this.router.post('/password/reset', [RequireLoginMethod('password')], this.authController.changeRemindedPassword);
|
||||
this.router.get('/password/change/mode', [IsLoggedIn], this.authController.getPasswordChangeMode);
|
||||
this.router.post('/password/change/code', [IsLoggedIn, RejectApiTokenAuth], this.authController.sendPasswordChangeCode);
|
||||
this.router.post('/password/change', [IsLoggedIn, RejectApiTokenAuth], this.authController.changeOwnPassword);
|
||||
this.router.post('/credentials/change', [IsLoggedIn, RejectApiTokenAuth], this.authController.changeDefaultUserCredentials);
|
||||
this.router.post('/logout', [IsLoggedIn], this.authController.logout);
|
||||
this.router.post('/refresh/token', this.authController.refreshTokens);
|
||||
this.router.post('/delete/account/code', [IsLoggedIn], this.authController.sendDeleteAccountCode);
|
||||
@@ -33,6 +42,7 @@ export default class AuthRoutes implements Routable {
|
||||
|
||||
this.router.get(
|
||||
'/provider/:providerName',
|
||||
RequireSocialProvider,
|
||||
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
|
||||
scope: ExternalProviderScope[req.params.providerName],
|
||||
session: false,
|
||||
@@ -43,6 +53,7 @@ export default class AuthRoutes implements Routable {
|
||||
);
|
||||
this.router.get(
|
||||
'/provider/:providerName/callback',
|
||||
RequireSocialProvider,
|
||||
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
|
||||
scope: ExternalProviderScope[req.params.providerName], session: false
|
||||
})(req, res, next),
|
||||
@@ -51,6 +62,7 @@ export default class AuthRoutes implements Routable {
|
||||
|
||||
this.router.post(
|
||||
'/provider/:providerName/callback',
|
||||
RequireSocialProvider,
|
||||
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
|
||||
scope: ExternalProviderScope[req.params.providerName], session: false
|
||||
})(req, res, next),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { LoginMethod } from '../../types/auth.types';
|
||||
|
||||
export class LoginMethods {
|
||||
static readonly ALL: LoginMethod[] = ['magic-link', 'password', 'sso', 'social'];
|
||||
|
||||
static enabled(): Set<LoginMethod> {
|
||||
const raw = process.env.AUTH_LOGIN_METHODS;
|
||||
if (!raw || !raw.trim()) {
|
||||
return new Set(LoginMethods.ALL);
|
||||
}
|
||||
return new Set(LoginMethods.parse(raw).valid);
|
||||
}
|
||||
|
||||
static isEnabled(method: LoginMethod): boolean {
|
||||
return LoginMethods.enabled().has(method);
|
||||
}
|
||||
|
||||
static publicRegistrationAllowed(): boolean {
|
||||
return process.env.ALLOW_PUBLIC_REGISTRATION?.trim().toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
static validateOnStartup(): void {
|
||||
const registrationRaw = process.env.ALLOW_PUBLIC_REGISTRATION;
|
||||
if (registrationRaw !== undefined && registrationRaw.trim() !== '') {
|
||||
const normalized = registrationRaw.trim().toLowerCase();
|
||||
if (normalized !== 'true' && normalized !== 'false') {
|
||||
throw new Error(
|
||||
`ALLOW_PUBLIC_REGISTRATION has unrecognized value "${registrationRaw}". Allowed: true, false`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const raw = process.env.AUTH_LOGIN_METHODS;
|
||||
if (!raw || !raw.trim()) return;
|
||||
|
||||
const { valid, invalid } = LoginMethods.parse(raw);
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`AUTH_LOGIN_METHODS contains unknown values: ${invalid.join(', ')}. Allowed: ${LoginMethods.ALL.join(', ')}`
|
||||
);
|
||||
}
|
||||
if (valid.length === 0) {
|
||||
throw new Error('AUTH_LOGIN_METHODS disables every login method — nobody would be able to sign in');
|
||||
}
|
||||
}
|
||||
|
||||
static configuredSocialProviders(): string[] {
|
||||
const providers: string[] = [];
|
||||
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET && process.env.GOOGLE_CALLBACK_URL) {
|
||||
providers.push('google');
|
||||
}
|
||||
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && process.env.GITHUB_CALLBACK_URL) {
|
||||
providers.push('github');
|
||||
}
|
||||
if (
|
||||
process.env.APPLE_CLIENT_ID &&
|
||||
process.env.APPLE_TEAM_ID &&
|
||||
process.env.APPLE_KEY_ID &&
|
||||
process.env.APPLE_CALLBACK_URL &&
|
||||
process.env.APPLE_KEY_LOCATION
|
||||
) {
|
||||
providers.push('apple');
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
static availableSocialProviders(): string[] {
|
||||
return LoginMethods.isEnabled('social') ? LoginMethods.configuredSocialProviders() : [];
|
||||
}
|
||||
|
||||
private static parse(raw: string): { valid: LoginMethod[]; invalid: string[] } {
|
||||
const values = raw
|
||||
.split(',')
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const valid = values.filter((value): value is LoginMethod => (LoginMethods.ALL as string[]).includes(value));
|
||||
const invalid = values.filter((value) => !(LoginMethods.ALL as string[]).includes(value));
|
||||
return { valid, invalid };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type { LoginMethod } from '../../../types/auth.types';
|
||||
import { LoginMethods } from '../LoginMethods';
|
||||
|
||||
export const RequireLoginMethod = (method: LoginMethod) => {
|
||||
return (_req: Request, res: Response, next: NextFunction) => {
|
||||
if (!LoginMethods.isEnabled(method)) {
|
||||
return res.status(403).send();
|
||||
}
|
||||
return next();
|
||||
};
|
||||
};
|
||||
|
||||
export const RequireAnyLoginMethod = (methods: LoginMethod[]) => {
|
||||
return (_req: Request, res: Response, next: NextFunction) => {
|
||||
if (!methods.some((method) => LoginMethods.isEnabled(method))) {
|
||||
return res.status(403).send();
|
||||
}
|
||||
return next();
|
||||
};
|
||||
};
|
||||
|
||||
export const RequireSocialProvider = (req: Request, res: Response, next: NextFunction) => {
|
||||
const providerName = String(req.params.providerName || '').toLowerCase();
|
||||
if (!LoginMethods.availableSocialProviders().includes(providerName)) {
|
||||
return res.status(403).send();
|
||||
}
|
||||
return next();
|
||||
};
|
||||
@@ -16,8 +16,7 @@ export function initAppleStrategy() {
|
||||
!process.env.APPLE_KEY_ID ||
|
||||
!process.env.APPLE_CALLBACK_URL ||
|
||||
!process.env.APPLE_KEY_LOCATION) {
|
||||
$logger.warn("APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_CALLBACK_URL, and APPLE_KEY_LOCATION must be set");
|
||||
console.warn("APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_CALLBACK_URL, and APPLE_KEY_LOCATION must be set");
|
||||
$logger.debug("Apple login is not configured (APPLE_CLIENT_ID / APPLE_TEAM_ID / APPLE_KEY_ID / APPLE_CALLBACK_URL / APPLE_KEY_LOCATION) — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ import type { VerifyCallback } from "passport-google-oauth20";
|
||||
|
||||
export function initGithubStrategy() {
|
||||
if (!process.env.GITHUB_CLIENT_ID || !process.env.GITHUB_CLIENT_SECRET || !process.env.GITHUB_CALLBACK_URL) {
|
||||
$logger.warn("GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_CALLBACK_URL must be set");
|
||||
console.warn("GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_CALLBACK_URL must be set");
|
||||
$logger.debug("GitHub login is not configured (GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET / GITHUB_CALLBACK_URL) — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,7 @@ import type { ExternalAuthUser } from "./external-auth.types";
|
||||
|
||||
export function initGoogleStrategy() {
|
||||
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET || !process.env.GOOGLE_CALLBACK_URL) {
|
||||
$logger.warn("GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_CALLBACK_URL must be set");
|
||||
console.warn("GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_CALLBACK_URL must be set");
|
||||
$logger.debug("Google login is not configured (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_CALLBACK_URL) — skipping");
|
||||
return;
|
||||
}
|
||||
const options = {
|
||||
|
||||
+16
@@ -1,5 +1,8 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
import { GoalPermissions } from '../../../types/auth.types';
|
||||
import { logError } from '../../../utils/api';
|
||||
|
||||
export const CanFetchRolesPermissionsCollaborationRoles = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const goalId = req.body.goalId ? req.body.goalId : req.params.goalId;
|
||||
@@ -19,5 +22,18 @@ export const CanFetchRolesPermissionsCollaborationRoles = async (req: Request, r
|
||||
return next();
|
||||
}
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(Number(goalId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
|
||||
.catch(logError);
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for CanFetchRolesPermissionsCollaborationRoles middleware');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (permissions.hasPermissions(GoalPermissions.GOAL_CAN_MANAGE_USERS)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './collaboration.types';
|
||||
|
||||
export class CollaborationController {
|
||||
/** @deprecated */
|
||||
fetchAllUsers = async (req: Request, res: Response) => {
|
||||
const users = await req.appUser.collaborationManager.fetchAllUsers();
|
||||
return res.tvJson(users);
|
||||
@@ -82,19 +83,30 @@ export class CollaborationController {
|
||||
return res.status(400).send(output.summary);
|
||||
}
|
||||
|
||||
const user = await req.appUser.collaborationManager.addUserNew(output);
|
||||
const result = await req.appUser.collaborationManager.addUserNew(output);
|
||||
|
||||
if (user) {
|
||||
// created=false means the person was already in the goal — re-POSTing must not re-notify
|
||||
if (result?.created) {
|
||||
eventBus.emit('collaboration.userAdded', {
|
||||
goalId: output.goalId,
|
||||
email: output.email.toLowerCase(),
|
||||
initiatorId: req.appUser.getUserData()!.id,
|
||||
locale: this.resolveLocale(req),
|
||||
});
|
||||
}
|
||||
|
||||
return res.tvJson(user ?? null);
|
||||
return res.tvJson(result?.user ?? null);
|
||||
};
|
||||
|
||||
// The invitee has no stored locale (often no account yet), so localize by the inviter's browser language
|
||||
private resolveLocale(req: Request): 'en' | 'ru' {
|
||||
const acceptLanguage = req.headers['accept-language'];
|
||||
if (!acceptLanguage) return 'en';
|
||||
|
||||
const languages = acceptLanguage.split(',').map((lang) => lang.split(';')[0].trim().toLowerCase());
|
||||
return languages.some((lang) => lang === 'ru' || lang.startsWith('ru-')) ? 'ru' : 'en';
|
||||
}
|
||||
|
||||
deleteUserNew = async (req: Request, res: Response) => {
|
||||
const output = CollaborationArkTypeDeleteUser(req.body);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AppUser } from '../../core/AppUser';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import { CollaborationRepository } from './CollaborationRepository';
|
||||
import type {
|
||||
CollaborationAddUserResult,
|
||||
CollaborationArgAddUser,
|
||||
CollaborationArgDeleteUser,
|
||||
CollaborationArgToggleUserRoles,
|
||||
@@ -24,6 +25,7 @@ export class CollaborationManager {
|
||||
this.repository = new CollaborationRepository();
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async fetchAllUsers(): Promise<CollaborationUserWithRoles[] | false> {
|
||||
const sharedGoals = await this.user.goalsManager.fetchSharedGoals();
|
||||
|
||||
@@ -69,6 +71,7 @@ export class CollaborationManager {
|
||||
return Object.values(resultMap);
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async fetchUsersForGoal(args: FetchGoalUsersArg): Promise<CollaborationUserWithRoles[] | false> {
|
||||
const users = await this.repository.fetchUsersForGoal(args.goalId);
|
||||
|
||||
@@ -103,6 +106,7 @@ export class CollaborationManager {
|
||||
return Object.values(resultMap);
|
||||
}
|
||||
|
||||
/** @deprecated*/
|
||||
async toggleUserRoles(args: ToggleUserRolesArg): Promise<number[] | false> {
|
||||
return await this.repository.updateUserRoles(args.userId, args.roles);
|
||||
}
|
||||
@@ -120,7 +124,7 @@ export class CollaborationManager {
|
||||
return await this.repository.deleteUser(args);
|
||||
}
|
||||
|
||||
async addUserNew(args: CollaborationArgAddUser): Promise<CollaborationUserWithRoles | null> {
|
||||
async addUserNew(args: CollaborationArgAddUser): Promise<CollaborationAddUserResult | null> {
|
||||
const email = args.email.toLowerCase();
|
||||
|
||||
const goal = await this.user.goalsManager.goalsRepository.findGoalById(args.goalId);
|
||||
@@ -131,19 +135,22 @@ export class CollaborationManager {
|
||||
}
|
||||
}
|
||||
|
||||
const user = await this.repository.addUserForCollaborationNew({
|
||||
const result = await this.repository.addUserForCollaborationNew({
|
||||
...args,
|
||||
email,
|
||||
});
|
||||
if (!user) return null;
|
||||
if (!result) return null;
|
||||
|
||||
return {
|
||||
...user,
|
||||
goalId: args.goalId,
|
||||
goal_id: args.goalId,
|
||||
invitation_date: user.invitationDate,
|
||||
roles: [],
|
||||
goalOwner: false,
|
||||
user: {
|
||||
...result.user,
|
||||
goalId: args.goalId,
|
||||
goal_id: args.goalId,
|
||||
invitation_date: result.user.invitationDate,
|
||||
roles: [],
|
||||
goalOwner: false,
|
||||
},
|
||||
created: result.created,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,7 +184,7 @@ export class CollaborationManager {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
|
||||
const resultMap: Record<string, CollaborationUserWithRoles> = {};
|
||||
|
||||
users.forEach((item) => {
|
||||
@@ -210,7 +217,7 @@ export class CollaborationManager {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
|
||||
const resultMap: Record<string, CollaborationUserWithRoles> = {};
|
||||
|
||||
users.forEach((item) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { and, eq, exists, inArray } from 'drizzle-orm';
|
||||
import {
|
||||
CollaborationRolesSchema,
|
||||
CollaborationUsersSchema,
|
||||
type CollaborationUsersSchemaTypeForSelect,
|
||||
CollaborationUsersToGoalsSchema,
|
||||
@@ -10,6 +11,7 @@ import { $logger } from '../../modules/logget';
|
||||
import { logError } from '../../utils/api';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import type {
|
||||
CollaborationAddUserRepoResult,
|
||||
CollaborationArgAddUser,
|
||||
CollaborationArgDeleteUser,
|
||||
CollaborationArgToggleUserRoles,
|
||||
@@ -23,6 +25,7 @@ export class CollaborationRepository {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async fetchAllUsers(goalIds: number[]): Promise<FetchUsersForGoal[] | false> {
|
||||
if (goalIds.length === 0) {
|
||||
return [];
|
||||
@@ -34,6 +37,10 @@ export class CollaborationRepository {
|
||||
FROM collaboration.users u
|
||||
left join collaboration.users_to_goals utg on u.id = utg.user_id
|
||||
LEFT JOIN collaboration.users_to_roles utr ON u.id = utr.user_id
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM collaboration.roles r
|
||||
WHERE r.id = utr.role_id AND r.goal_id = utg.goal_id
|
||||
)
|
||||
WHERE utg.goal_id IN (${placeholders})
|
||||
`;
|
||||
|
||||
@@ -91,7 +98,8 @@ export class CollaborationRepository {
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** @deprecated */
|
||||
async fetchUsersForGoal(goalId: number): Promise<FetchUsersForGoal[] | false> {
|
||||
const query = `
|
||||
SELECT u.*, u.invitation_date::text, utr.role_id, utg.goal_id
|
||||
@@ -111,6 +119,7 @@ export class CollaborationRepository {
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async fetchUsersForGoals(goalIds: number[]): Promise<FetchUsersForGoal[] | false> {
|
||||
if (goalIds.length === 0) {
|
||||
return [];
|
||||
@@ -145,6 +154,7 @@ export class CollaborationRepository {
|
||||
return !!(result.rowCount && result.rowCount > 0);
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async updateUserRoles(userId: number, roles: number[]): Promise<number[] | false> {
|
||||
const deleteQuery = `DELETE FROM collaboration.users_to_roles WHERE user_id = $1`;
|
||||
let i = 1;
|
||||
@@ -197,8 +207,8 @@ export class CollaborationRepository {
|
||||
|
||||
async addUserForCollaborationNew(
|
||||
args: CollaborationArgAddUser
|
||||
): Promise<CollaborationUsersSchemaTypeForSelect | null> {
|
||||
const user = await callWithCatch(() =>
|
||||
): Promise<CollaborationAddUserRepoResult | null> {
|
||||
return await callWithCatch(() =>
|
||||
this.db.dbDrizzle.transaction(async (tx) => {
|
||||
let userId: number;
|
||||
let user: CollaborationUsersSchemaTypeForSelect;
|
||||
@@ -217,18 +227,14 @@ export class CollaborationRepository {
|
||||
user = userTransaction;
|
||||
}
|
||||
|
||||
await tx.insert(CollaborationUsersToGoalsSchema).values({
|
||||
const linked = await tx.insert(CollaborationUsersToGoalsSchema).values({
|
||||
userId: userId,
|
||||
goalId: args.goalId,
|
||||
}).onConflictDoNothing();
|
||||
}).onConflictDoNothing().returning();
|
||||
|
||||
return user;
|
||||
return { user, created: linked.length > 0 };
|
||||
})
|
||||
);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async deleteUserNew(args: CollaborationArgDeleteUser) {
|
||||
@@ -250,13 +256,29 @@ export class CollaborationRepository {
|
||||
async toggleUserRolesNew(args: CollaborationArgToggleUserRoles): Promise<number[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(CollaborationUsersToRolesSchema)
|
||||
.where(eq(CollaborationUsersToRolesSchema.userId, args.userId));
|
||||
if (args.roles.length > 0) {
|
||||
|
||||
const goalRoles = await tx
|
||||
.select({ id: CollaborationRolesSchema.id })
|
||||
.from(CollaborationRolesSchema)
|
||||
.where(eq(CollaborationRolesSchema.goalId, args.goalId));
|
||||
const goalRoleIds = goalRoles.map((role) => role.id);
|
||||
|
||||
if (goalRoleIds.length > 0) {
|
||||
await tx
|
||||
.delete(CollaborationUsersToRolesSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(CollaborationUsersToRolesSchema.userId, args.userId),
|
||||
inArray(CollaborationUsersToRolesSchema.roleId, goalRoleIds)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const rolesToAssign = args.roles.filter((roleId) => goalRoleIds.includes(roleId));
|
||||
if (rolesToAssign.length > 0) {
|
||||
return await tx
|
||||
.insert(CollaborationUsersToRolesSchema)
|
||||
.values(args.roles.map((roleId) => ({ userId: args.userId, roleId })))
|
||||
.values(rolesToAssign.map((roleId) => ({ userId: args.userId, roleId })))
|
||||
.returning();
|
||||
}
|
||||
return [];
|
||||
@@ -287,7 +309,20 @@ export class CollaborationRepository {
|
||||
)
|
||||
.leftJoin(
|
||||
CollaborationUsersToRolesSchema,
|
||||
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId)
|
||||
and(
|
||||
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId),
|
||||
exists(
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(CollaborationRolesSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(CollaborationRolesSchema.id, CollaborationUsersToRolesSchema.roleId),
|
||||
eq(CollaborationRolesSchema.goalId, CollaborationUsersToGoalsSchema.goalId)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.where(inArray(CollaborationUsersToGoalsSchema.goalId, goalIds))
|
||||
);
|
||||
@@ -311,7 +346,20 @@ export class CollaborationRepository {
|
||||
)
|
||||
.leftJoin(
|
||||
CollaborationUsersToRolesSchema,
|
||||
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId)
|
||||
and(
|
||||
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId),
|
||||
exists(
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(CollaborationRolesSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(CollaborationRolesSchema.id, CollaborationUsersToRolesSchema.roleId),
|
||||
eq(CollaborationRolesSchema.goalId, CollaborationUsersToGoalsSchema.goalId)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId))
|
||||
);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { and, count, eq, gte, lt, sql } from 'drizzle-orm';
|
||||
import { GoalsSchema, InviteEmailsSchema, OrganizationsSchema, UsersSchema } from 'taskview-db-schemas';
|
||||
import type { Dispatcher } from '../../core/Dispatcher';
|
||||
import { Email } from '../../core/Email';
|
||||
import { eventBus, type AppEvents } from '../../core/EventBus';
|
||||
import { Database } from '../../modules/db';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { escapeHtml, parsePositiveInt } from '../../utils/helpers';
|
||||
import InviteEmailTemplateEn from './mail/invite-en';
|
||||
import InviteEmailTemplateRu from './mail/invite-ru';
|
||||
import type { InviteEmailRateLimitArgs, InviteEmailSendArgs } from './collaboration.server.types';
|
||||
|
||||
const DEFAULT_HOURLY_LIMIT = 30;
|
||||
|
||||
export class InviteEmailDispatcher implements Dispatcher {
|
||||
static enabled(): boolean {
|
||||
return process.env.INVITE_EMAIL_ENABLED?.trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
static hourlyLimit(): number {
|
||||
return parsePositiveInt(process.env.INVITE_EMAIL_HOURLY_LIMIT) ?? DEFAULT_HOURLY_LIMIT;
|
||||
}
|
||||
|
||||
static validateOnStartup(): void {
|
||||
const enabledRaw = process.env.INVITE_EMAIL_ENABLED;
|
||||
if (enabledRaw !== undefined && enabledRaw.trim() !== '') {
|
||||
const normalized = enabledRaw.trim().toLowerCase();
|
||||
if (normalized !== 'true' && normalized !== 'false') {
|
||||
throw new Error(`INVITE_EMAIL_ENABLED has unrecognized value "${enabledRaw}". Allowed: true, false`);
|
||||
}
|
||||
}
|
||||
|
||||
const limitRaw = process.env.INVITE_EMAIL_HOURLY_LIMIT;
|
||||
if (limitRaw !== undefined && limitRaw.trim() !== '' && parsePositiveInt(limitRaw) === null) {
|
||||
throw new Error(
|
||||
`INVITE_EMAIL_HOURLY_LIMIT has unrecognized value "${limitRaw}". Expected a positive integer`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
register(): void {
|
||||
eventBus.on('collaboration.userAdded', (data) => this.onUserAdded(data));
|
||||
}
|
||||
|
||||
async registerWorkers(): Promise<void> {}
|
||||
|
||||
private async onUserAdded(data: AppEvents['collaboration.userAdded']): Promise<void> {
|
||||
if (!InviteEmailDispatcher.enabled() || !process.env.SMTP_HOST) return;
|
||||
|
||||
const db = Database.getInstance();
|
||||
|
||||
const [goal] = await db.dbDrizzle
|
||||
.select({ name: GoalsSchema.name, organizationId: GoalsSchema.organizationId })
|
||||
.from(GoalsSchema)
|
||||
.where(eq(GoalsSchema.id, data.goalId))
|
||||
.limit(1);
|
||||
if (!goal) return;
|
||||
|
||||
const [inviter] = await db.dbDrizzle
|
||||
.select({ login: UsersSchema.login })
|
||||
.from(UsersSchema)
|
||||
.where(eq(UsersSchema.id, data.initiatorId))
|
||||
.limit(1);
|
||||
if (!inviter) return;
|
||||
|
||||
const allowed = await this.passesRateLimit({
|
||||
initiatorId: data.initiatorId,
|
||||
email: data.email,
|
||||
goalId: data.goalId,
|
||||
});
|
||||
if (!allowed) return;
|
||||
|
||||
const link = await this.buildGoalLink(data.goalId, goal.organizationId);
|
||||
if (!link) {
|
||||
$logger.warn('APP_URL is not set — skipping invite email');
|
||||
return;
|
||||
}
|
||||
|
||||
await db.dbDrizzle.insert(InviteEmailsSchema).values({
|
||||
initiatorId: data.initiatorId,
|
||||
email: data.email,
|
||||
goalId: data.goalId,
|
||||
});
|
||||
|
||||
const fallbackName = data.locale === 'ru' ? 'Пользователь TaskView' : 'A TaskView user';
|
||||
|
||||
await this.sendInviteEmail({
|
||||
email: data.email,
|
||||
inviterName: this.truncate(inviter.login?.trim() || fallbackName),
|
||||
goalName: this.truncate(goal.name || ''),
|
||||
link,
|
||||
locale: data.locale,
|
||||
});
|
||||
}
|
||||
|
||||
// Two rules: a 24h cooldown per (goal, recipient) — closes the delete/re-add resend loop —
|
||||
// and an hourly cap per initiator against using the instance as a mail relay.
|
||||
// Rows older than the cooldown window are pruned first, keeping the table tiny.
|
||||
private async passesRateLimit(args: InviteEmailRateLimitArgs): Promise<boolean> {
|
||||
const db = Database.getInstance();
|
||||
|
||||
await db.dbDrizzle
|
||||
.delete(InviteEmailsSchema)
|
||||
.where(lt(InviteEmailsSchema.sentAt, sql`now() - interval '24 hours'`));
|
||||
|
||||
const [cooldown] = await db.dbDrizzle
|
||||
.select({ id: InviteEmailsSchema.id })
|
||||
.from(InviteEmailsSchema)
|
||||
.where(and(eq(InviteEmailsSchema.goalId, args.goalId), eq(InviteEmailsSchema.email, args.email)))
|
||||
.limit(1);
|
||||
if (cooldown) return false;
|
||||
|
||||
const [hourly] = await db.dbDrizzle
|
||||
.select({ count: count() })
|
||||
.from(InviteEmailsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(InviteEmailsSchema.initiatorId, args.initiatorId),
|
||||
gte(InviteEmailsSchema.sentAt, sql`now() - interval '1 hour'`)
|
||||
)
|
||||
);
|
||||
if ((hourly?.count ?? 0) >= InviteEmailDispatcher.hourlyLimit()) {
|
||||
$logger.warn(
|
||||
{ initiatorId: args.initiatorId, goalId: args.goalId },
|
||||
'Invite email hourly limit reached — skipping send'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async sendInviteEmail(args: InviteEmailSendArgs): Promise<void> {
|
||||
const template = args.locale === 'ru' ? InviteEmailTemplateRu : InviteEmailTemplateEn;
|
||||
const subject =
|
||||
args.locale === 'ru'
|
||||
? `${args.inviterName} приглашает вас в проект «${args.goalName}» в TaskView`
|
||||
: `${args.inviterName} invited you to "${args.goalName}" on TaskView`;
|
||||
const text =
|
||||
args.locale === 'ru'
|
||||
? `${args.inviterName} приглашает вас присоединиться к проекту «${args.goalName}» в TaskView.\n\nОткрыть проект: ${args.link}`
|
||||
: `${args.inviterName} has invited you to join the project "${args.goalName}" on TaskView.\n\nOpen the project: ${args.link}`;
|
||||
|
||||
// Single-pass replace with a function: no re-substitution of placeholders inside
|
||||
// inserted values, and no special treatment of $-patterns in the replacement
|
||||
const values: Record<string, string> = {
|
||||
inviter: args.inviterName,
|
||||
project: args.goalName,
|
||||
link: args.link,
|
||||
};
|
||||
const html = template.replace(/\{(inviter|project|link)\}/g, (_, key: string) => escapeHtml(values[key]));
|
||||
|
||||
await Email.send({
|
||||
text,
|
||||
subject,
|
||||
to: args.email,
|
||||
from: process.env.SMTP_FROM_EMAIL as string,
|
||||
attachment: [{ data: html, alternative: true }],
|
||||
});
|
||||
}
|
||||
|
||||
// Frontend project route is /:orgSlug/:projectId; goals without an organization fall back to the app root
|
||||
private async buildGoalLink(goalId: number, organizationId: number | null): Promise<string | null> {
|
||||
const appUrl = (process.env.APP_URL ?? '').replace(/\/+$/, '');
|
||||
if (!appUrl) return null;
|
||||
if (!organizationId) return appUrl;
|
||||
|
||||
const db = Database.getInstance();
|
||||
const [org] = await db.dbDrizzle
|
||||
.select({ slug: OrganizationsSchema.slug })
|
||||
.from(OrganizationsSchema)
|
||||
.where(eq(OrganizationsSchema.id, organizationId))
|
||||
.limit(1);
|
||||
if (!org?.slug) return appUrl;
|
||||
|
||||
return `${appUrl}/${encodeURIComponent(org.slug)}/${goalId}`;
|
||||
}
|
||||
|
||||
private truncate(value: string): string {
|
||||
const max = 80;
|
||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Email } from '../../../core/Email';
|
||||
import type { AppEvents } from '../../../core/EventBus';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { InviteEmailDispatcher } from '../InviteEmailDispatcher';
|
||||
|
||||
vi.mock('../../../core/Email', () => ({
|
||||
Email: {
|
||||
send: vi.fn().mockResolvedValue(true),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../modules/db', () => ({
|
||||
Database: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Each select() call consumes the next result; the returned query is both awaitable
|
||||
// (count query) and .limit()-able (lookups), matching the Drizzle chains in the dispatcher
|
||||
function mockDb(selectResults: unknown[][]) {
|
||||
const queue = [...selectResults];
|
||||
const insertValues = vi.fn(async () => undefined);
|
||||
const dbDrizzle = {
|
||||
select: vi.fn(() => {
|
||||
const rows = queue.shift() ?? [];
|
||||
const query = {
|
||||
limit: async () => rows,
|
||||
then: (resolve: (rows: unknown[]) => void, reject: (err: unknown) => void) =>
|
||||
Promise.resolve(rows).then(resolve, reject),
|
||||
};
|
||||
return { from: () => ({ where: () => query }) };
|
||||
}),
|
||||
delete: vi.fn(() => ({ where: async () => undefined })),
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
};
|
||||
vi.mocked(Database.getInstance).mockReturnValue({ dbDrizzle } as any);
|
||||
return { dbDrizzle, insertValues };
|
||||
}
|
||||
|
||||
const goalRow = { name: 'Marketing', organizationId: 3 };
|
||||
const inviterRow = { login: 'Alice' };
|
||||
const noCooldown: unknown[] = [];
|
||||
const underLimit = [{ count: 0 }];
|
||||
const orgRow = [{ slug: 'acme' }];
|
||||
|
||||
const inviteEvent: AppEvents['collaboration.userAdded'] = {
|
||||
goalId: 42,
|
||||
email: 'invitee@example.com',
|
||||
initiatorId: 7,
|
||||
locale: 'en',
|
||||
};
|
||||
|
||||
describe('InviteEmailDispatcher', () => {
|
||||
const dispatcher = new InviteEmailDispatcher();
|
||||
const onUserAdded = (data: typeof inviteEvent) => (dispatcher as any).onUserAdded(data);
|
||||
const sentHtml = () => (vi.mocked(Email.send).mock.calls[0][0] as any).attachment[0].data as string;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.INVITE_EMAIL_ENABLED = 'true';
|
||||
process.env.SMTP_HOST = 'smtp.test';
|
||||
process.env.SMTP_FROM_EMAIL = 'noreply@test';
|
||||
process.env.APP_URL = 'http://localhost:3000';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.INVITE_EMAIL_ENABLED;
|
||||
delete process.env.INVITE_EMAIL_HOURLY_LIMIT;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not send when the flag is off', async () => {
|
||||
process.env.INVITE_EMAIL_ENABLED = 'false';
|
||||
mockDb([]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not send when the flag is unset', async () => {
|
||||
delete process.env.INVITE_EMAIL_ENABLED;
|
||||
mockDb([]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a localized email with a project deep link and records the send', async () => {
|
||||
const { insertValues } = mockDb([[goalRow], [inviterRow], noCooldown, underLimit, orgRow]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).toHaveBeenCalledTimes(1);
|
||||
const message = vi.mocked(Email.send).mock.calls[0][0] as any;
|
||||
expect(message.to).toBe('invitee@example.com');
|
||||
expect(message.from).toBe('noreply@test');
|
||||
expect(message.subject).toBe('Alice invited you to "Marketing" on TaskView');
|
||||
expect(message.text).toContain('http://localhost:3000/acme/42');
|
||||
|
||||
const html = sentHtml();
|
||||
expect(html).toContain("You've been invited to a project");
|
||||
expect(html).toContain('Alice');
|
||||
expect(html).toContain('href="http://localhost:3000/acme/42"');
|
||||
|
||||
expect(insertValues).toHaveBeenCalledWith({
|
||||
initiatorId: 7,
|
||||
email: 'invitee@example.com',
|
||||
goalId: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the Russian template for the ru locale', async () => {
|
||||
mockDb([[{ name: 'Маркетинг', organizationId: null }], [{ login: 'Алиса' }], noCooldown, underLimit]);
|
||||
|
||||
await onUserAdded({ ...inviteEvent, locale: 'ru' });
|
||||
|
||||
const message = vi.mocked(Email.send).mock.calls[0][0] as any;
|
||||
expect(message.subject).toBe('Алиса приглашает вас в проект «Маркетинг» в TaskView');
|
||||
expect(sentHtml()).toContain('Вас пригласили в проект');
|
||||
expect(sentHtml()).toContain('href="http://localhost:3000"');
|
||||
});
|
||||
|
||||
it('skips the send during the per-recipient cooldown', async () => {
|
||||
const { insertValues } = mockDb([[goalRow], [inviterRow], [{ id: 1 }]]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the send when the hourly limit is reached', async () => {
|
||||
const { insertValues } = mockDb([[goalRow], [inviterRow], noCooldown, [{ count: 30 }]]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('respects a custom INVITE_EMAIL_HOURLY_LIMIT', async () => {
|
||||
process.env.INVITE_EMAIL_HOURLY_LIMIT = '2';
|
||||
mockDb([[goalRow], [inviterRow], noCooldown, [{ count: 2 }]]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
|
||||
mockDb([[goalRow], [inviterRow], noCooldown, [{ count: 1 }], orgRow]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('escapes user-controlled values in the html', async () => {
|
||||
mockDb([
|
||||
[{ name: '<img src=x onerror=alert(1)>', organizationId: null }],
|
||||
[{ login: 'Bob & "Co"' }],
|
||||
noCooldown,
|
||||
underLimit,
|
||||
]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
const html = sentHtml();
|
||||
expect(html).not.toContain('<img src=x');
|
||||
expect(html).toContain('<img src=x onerror=alert(1)>');
|
||||
expect(html).toContain('Bob & "Co"');
|
||||
});
|
||||
|
||||
it('is immune to $-patterns and placeholder strings in user values', async () => {
|
||||
mockDb([
|
||||
[{ name: 'Project $` name', organizationId: null }],
|
||||
[{ login: '{link}' }],
|
||||
noCooldown,
|
||||
underLimit,
|
||||
]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
const html = sentHtml();
|
||||
expect(html).toContain('Project $` name');
|
||||
expect(html).toContain('{link}');
|
||||
expect(html).toContain('href="http://localhost:3000"');
|
||||
});
|
||||
|
||||
it('truncates overlong user values', async () => {
|
||||
mockDb([
|
||||
[{ name: 'p'.repeat(200), organizationId: null }],
|
||||
[{ login: 'i'.repeat(200) }],
|
||||
noCooldown,
|
||||
underLimit,
|
||||
]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
const message = vi.mocked(Email.send).mock.calls[0][0] as any;
|
||||
expect(message.subject).toContain(`"${'p'.repeat(80)}…"`);
|
||||
expect(message.text).toContain(`${'i'.repeat(80)}… has invited`);
|
||||
});
|
||||
|
||||
it('does not send when the goal no longer exists', async () => {
|
||||
mockDb([[]]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validateOnStartup rejects unrecognized values', () => {
|
||||
process.env.INVITE_EMAIL_ENABLED = 'ture';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_ENABLED');
|
||||
|
||||
process.env.INVITE_EMAIL_ENABLED = 'false';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow();
|
||||
|
||||
process.env.INVITE_EMAIL_HOURLY_LIMIT = 'abc';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_HOURLY_LIMIT');
|
||||
|
||||
process.env.INVITE_EMAIL_HOURLY_LIMIT = '0';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_HOURLY_LIMIT');
|
||||
|
||||
process.env.INVITE_EMAIL_HOURLY_LIMIT = '10';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow();
|
||||
|
||||
delete process.env.INVITE_EMAIL_ENABLED;
|
||||
delete process.env.INVITE_EMAIL_HOURLY_LIMIT;
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type } from 'arktype';
|
||||
import type { CollaborationUsersSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
|
||||
export const CollaborationArkTypeAddUser = type({
|
||||
goalId: 'number',
|
||||
@@ -97,3 +98,30 @@ export const CollaborationArkTypeToggleRolePermission = type({
|
||||
});
|
||||
|
||||
export type CollaborationArgToggleRolePermission = typeof CollaborationArkTypeToggleRolePermission.infer;
|
||||
|
||||
// created=false means the person was already a collaborator of the goal — no invitation happened
|
||||
export type CollaborationAddUserRepoResult = {
|
||||
user: CollaborationUsersSchemaTypeForSelect;
|
||||
created: boolean;
|
||||
};
|
||||
|
||||
export type CollaborationAddUserResult = {
|
||||
user: CollaborationUserWithRoles;
|
||||
created: boolean;
|
||||
};
|
||||
|
||||
export type InviteEmailLocale = 'en' | 'ru';
|
||||
|
||||
export type InviteEmailSendArgs = {
|
||||
email: string;
|
||||
inviterName: string;
|
||||
goalName: string;
|
||||
link: string;
|
||||
locale: InviteEmailLocale;
|
||||
};
|
||||
|
||||
export type InviteEmailRateLimitArgs = {
|
||||
initiatorId: number;
|
||||
email: string;
|
||||
goalId: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export default `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="color-scheme" content="only" />
|
||||
<title>Project invitation</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f7fa;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; background-color: #ffffff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);">
|
||||
<tr>
|
||||
<td style="padding: 40px 32px 24px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: 600; color: #000000; letter-spacing: 0.5px;">TaskView</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 16px; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #18181b;">You've been invited to a project</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 28px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #71717a;"><span style="font-weight: 600; color: #18181b;">{inviter}</span> has invited you to join the project<br /><span style="font-weight: 600; color: #18181b;">{project}</span></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 32px 28px;">
|
||||
<a href="{link}" style="display: inline-block; padding: 12px 32px; background-color: #16a34a; border-radius: 8px; font-size: 15px; font-weight: 600; color: #ffffff; text-decoration: none;">Open project</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 32px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 1.5; color: #a1a1aa;">If the button doesn't work, copy this link into your browser:<br /><a href="{link}" style="color: #16a34a; word-break: break-all;">{link}</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 40px; text-align: center; border-top: 1px solid #f4f4f5;">
|
||||
<p style="margin: 24px 0 0; font-size: 13px; line-height: 1.5; color: #a1a1aa;">You received this email because someone invited you to a project on TaskView. If you weren't expecting it, you can safely ignore this email.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 24px 0 0; font-size: 12px; color: #a1a1aa; text-align: center;">© TaskView</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -0,0 +1,50 @@
|
||||
export default `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="color-scheme" content="only" />
|
||||
<title>Приглашение в проект</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f7fa;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; background-color: #ffffff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);">
|
||||
<tr>
|
||||
<td style="padding: 40px 32px 24px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: 600; color: #000000; letter-spacing: 0.5px;">TaskView</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 16px; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #18181b;">Вас пригласили в проект</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 28px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #71717a;"><span style="font-weight: 600; color: #18181b;">{inviter}</span> приглашает вас присоединиться к проекту<br /><span style="font-weight: 600; color: #18181b;">{project}</span></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 32px 28px;">
|
||||
<a href="{link}" style="display: inline-block; padding: 12px 32px; background-color: #16a34a; border-radius: 8px; font-size: 15px; font-weight: 600; color: #ffffff; text-decoration: none;">Открыть проект</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 32px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 1.5; color: #a1a1aa;">Если кнопка не работает, скопируйте эту ссылку в браузер:<br /><a href="{link}" style="color: #16a34a; word-break: break-all;">{link}</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 40px; text-align: center; border-top: 1px solid #f4f4f5;">
|
||||
<p style="margin: 24px 0 0; font-size: 13px; line-height: 1.5; color: #a1a1aa;">Вы получили это письмо, потому что вас пригласили в проект в TaskView. Если вы не ожидали приглашения, просто проигнорируйте это письмо.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 24px 0 0; font-size: 12px; color: #a1a1aa; text-align: center;">© TaskView</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -237,7 +237,7 @@ export default class GoalsManager {
|
||||
|
||||
await this.user.collaborationManager.repository.toggleUserRolesNew({
|
||||
goalId,
|
||||
userId: collabUser.id,
|
||||
userId: collabUser.user.id,
|
||||
roles: [role.id],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { type } from 'arktype';
|
||||
import type { Request, Response } from 'express';
|
||||
import { logError } from '../../utils/api';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { integrationsDebugLog } from './debugLog';
|
||||
import { decrypt } from '../../utils/crypto';
|
||||
import AuthController from '../auth/AuthController';
|
||||
import { IntegrationsRepository } from './IntegrationsRepository';
|
||||
import { verifyGitHubWebhookSignature, GITHUB_BASE_URL } from './providers/github.provider';
|
||||
import { verifyGitLabWebhookToken, GITLAB_BASE_URL } from './providers/gitlab.provider';
|
||||
import { verifyGiteaWebhookSignature, GITEA_BASE_URL } from './providers/gitea.provider';
|
||||
import { IntegrationsArkTypeAdd, IntegrationsArkTypeDelete, IntegrationsArkTypeFetch, IntegrationsArkTypeSelectRepo, IntegrationsArkTypeToggle } from './types';
|
||||
|
||||
export default class IntegrationsController {
|
||||
@@ -47,23 +50,29 @@ export default class IntegrationsController {
|
||||
|
||||
initiateOAuth = async (req: Request, res: Response) => {
|
||||
try {
|
||||
integrationsDebugLog({ step: 'initiate:start', data: { provider: req.params.provider, projectId: req.query.projectId, hasToken: !!req.query.token } });
|
||||
const token = req.query.token as string;
|
||||
if (!token) {
|
||||
integrationsDebugLog({ step: 'initiate:reject', data: 'token is required' });
|
||||
return res.status(401).send('token is required');
|
||||
}
|
||||
const userPayload = await AuthController.validateTokens(token);
|
||||
if (!userPayload?.userData?.id) {
|
||||
integrationsDebugLog({ step: 'initiate:reject', data: 'invalid token' });
|
||||
return res.status(401).send('Invalid token');
|
||||
}
|
||||
|
||||
const provider = req.params.provider;
|
||||
const projectId = Number(req.query.projectId);
|
||||
if (!projectId || isNaN(projectId)) {
|
||||
integrationsDebugLog({ step: 'initiate:reject', data: 'projectId is required' });
|
||||
return res.status(400).send('projectId is required');
|
||||
}
|
||||
const url = req.appUser.integrationsManager.getOAuthUrl(provider, projectId, userPayload.userData.id);
|
||||
integrationsDebugLog({ step: 'initiate:redirect', data: { userId: userPayload.userData.id, url } });
|
||||
return res.redirect(url);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
integrationsDebugLog({ step: 'initiate:error', data: { message: err?.message, stack: err?.stack } });
|
||||
logError(err);
|
||||
return res.status(500).send('Failed to initiate OAuth');
|
||||
}
|
||||
@@ -74,15 +83,36 @@ export default class IntegrationsController {
|
||||
const provider = req.params.provider;
|
||||
const code = req.query.code as string;
|
||||
const state = req.query.state as string;
|
||||
integrationsDebugLog({ step: 'callback:start', data: { provider, hasCode: !!code, hasState: !!state, queryKeys: Object.keys(req.query) } });
|
||||
|
||||
if (!code || !state) {
|
||||
integrationsDebugLog({ step: 'callback:reject', data: 'missing code or state' });
|
||||
return res.redirect(`${process.env.APP_URL}?oauth=error`);
|
||||
}
|
||||
|
||||
const { projectId, userLogin } = await req.appUser.integrationsManager.handleOAuthCallback(provider, code, state);
|
||||
return res.redirect(`${process.env.APP_URL}/${userLogin}/${projectId}/integrations?oauth=success`);
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
const { projectId, orgSlug } = await req.appUser.integrationsManager.handleOAuthCallback(provider, code, state);
|
||||
integrationsDebugLog({ step: 'callback:success', data: { projectId, orgSlug } });
|
||||
return res.redirect(`${process.env.APP_URL}/${orgSlug}/${projectId}/integrations?oauth=success`);
|
||||
} catch (err: any) {
|
||||
integrationsDebugLog({
|
||||
step: 'callback:error',
|
||||
data: {
|
||||
message: err?.message,
|
||||
responseStatus: err?.response?.status,
|
||||
responseData: err?.response?.data,
|
||||
stack: err?.stack,
|
||||
},
|
||||
});
|
||||
$logger.error(
|
||||
{
|
||||
provider: req.params.provider,
|
||||
errorMessage: err?.message,
|
||||
responseStatus: err?.response?.status,
|
||||
responseData: err?.response?.data,
|
||||
stack: err?.stack,
|
||||
},
|
||||
'[integrations] OAuth callback failed',
|
||||
);
|
||||
return res.redirect(`${process.env.APP_URL}?oauth=error`);
|
||||
}
|
||||
};
|
||||
@@ -210,6 +240,91 @@ export default class IntegrationsController {
|
||||
}
|
||||
};
|
||||
|
||||
handleGiteaWebhook = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const signature = req.headers['x-gitea-signature'] as string;
|
||||
const event = req.headers['x-gitea-event'] as string;
|
||||
|
||||
if (!signature) {
|
||||
return res.status(401).send('Missing signature');
|
||||
}
|
||||
|
||||
if (event !== 'issues') {
|
||||
return res.status(200).send('OK');
|
||||
}
|
||||
|
||||
const repoFullName = req.body?.repository?.full_name;
|
||||
if (!repoFullName) {
|
||||
return res.status(400).send('Missing repository');
|
||||
}
|
||||
|
||||
const repo = new IntegrationsRepository();
|
||||
const integrations = await repo.fetchAllActiveByRepoFullName(repoFullName);
|
||||
if (integrations.length === 0) {
|
||||
return res.status(404).send('Integration not found');
|
||||
}
|
||||
|
||||
// Verify signature with the first integration that has a webhook secret
|
||||
const withSecret = integrations.find((i) => i.webhookSecretEncrypted);
|
||||
if (!withSecret) {
|
||||
return res.status(401).send('No webhook secret');
|
||||
}
|
||||
const secret = decrypt(withSecret.webhookSecretEncrypted!);
|
||||
const rawBody = (req as any).rawBody as Buffer;
|
||||
if (!rawBody || !verifyGiteaWebhookSignature({ rawBody, signature, secret })) {
|
||||
return res.status(401).send('Invalid signature');
|
||||
}
|
||||
|
||||
const action = req.body.action as string;
|
||||
const issue = req.body.issue;
|
||||
if (!issue) {
|
||||
return res.status(200).send('OK');
|
||||
}
|
||||
|
||||
const issueNumber = issue.number as number;
|
||||
const issueTitle = issue.title as string;
|
||||
const issueBody = (issue.body as string) || null;
|
||||
|
||||
for (const integration of integrations) {
|
||||
const mapping = await repo.fetchMappingByIssueNumber(integration.id, issueNumber);
|
||||
|
||||
if (action === 'opened') {
|
||||
if (!mapping) {
|
||||
await repo.createTaskAndMapping(
|
||||
integration.projectId,
|
||||
issueTitle,
|
||||
integration.id,
|
||||
issueNumber,
|
||||
'open',
|
||||
issueBody,
|
||||
false,
|
||||
`${GITEA_BASE_URL}/${repoFullName}/issues/${issueNumber}`,
|
||||
);
|
||||
}
|
||||
} else if (action === 'edited') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskTitleAndNote(mapping.taskId, issueTitle, issueBody);
|
||||
}
|
||||
} else if (action === 'closed') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskComplete(mapping.taskId, true);
|
||||
await repo.updateMappingState(mapping.id, 'closed');
|
||||
}
|
||||
} else if (action === 'reopened') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskComplete(mapping.taskId, false);
|
||||
await repo.updateMappingState(mapping.id, 'open');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send('OK');
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
return res.status(500).send('Webhook processing failed');
|
||||
}
|
||||
};
|
||||
|
||||
handleGitLabWebhook = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const token = req.headers['x-gitlab-token'] as string;
|
||||
|
||||
@@ -8,10 +8,12 @@ import { $logger } from '../../modules/logget';
|
||||
import { IntegrationsRepository } from './IntegrationsRepository';
|
||||
import { TasksRepository } from '../tasks/TasksRepository';
|
||||
import type { IntegrationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgFetch, IntegrationsArgSelectRepo, IntegrationsArgToggle, OAuthStatePayload, RepoItemForClient } from './types';
|
||||
import type { IntegrationProvider, IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgFetch, IntegrationsArgSelectRepo, IntegrationsArgToggle, OAuthStatePayload, RepoItemForClient } from './types';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { getGitHubOAuthUrl, exchangeGitHubCode, fetchGitHubRepos, fetchGitHubIssues, createGitHubWebhook, updateGitHubIssueState, GITHUB_BASE_URL } from './providers/github.provider';
|
||||
import { getGitLabOAuthUrl, exchangeGitLabCode, fetchGitLabRepos, fetchGitLabIssues, createGitLabWebhook, updateGitLabIssueState, refreshGitLabToken, GITLAB_BASE_URL } from './providers/gitlab.provider';
|
||||
import { getGiteaOAuthUrl, exchangeGiteaCode, fetchGiteaRepos, fetchGiteaIssues, createGiteaWebhook, updateGiteaIssueState, refreshGiteaToken, verifyGiteaToken, GITEA_BASE_URL } from './providers/gitea.provider';
|
||||
import { integrationsDebugLog } from './debugLog';
|
||||
|
||||
export class IntegrationsManager {
|
||||
public readonly repository: IntegrationsRepository;
|
||||
@@ -55,13 +57,16 @@ export class IntegrationsManager {
|
||||
return getGitHubOAuthUrl(state);
|
||||
} else if (provider === 'gitlab') {
|
||||
return getGitLabOAuthUrl(state);
|
||||
} else if (provider === 'gitea') {
|
||||
return getGiteaOAuthUrl(state);
|
||||
}
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
|
||||
async handleOAuthCallback(provider: string, code: string, state: string): Promise<{ projectId: number; userLogin: string }> {
|
||||
async handleOAuthCallback(provider: string, code: string, state: string): Promise<{ projectId: number; orgSlug: string }> {
|
||||
$logger.debug({ provider }, '[integrations] handleOAuthCallback start');
|
||||
const payload = jwt.verify(state, process.env.JWT_SIGN as string) as OAuthStatePayload;
|
||||
integrationsDebugLog({ step: 'callback:state-verified', data: { userId: payload.userId, projectId: payload.projectId, provider: payload.provider } });
|
||||
|
||||
if (payload.provider !== provider) {
|
||||
$logger.error({ provider, payloadProvider: payload.provider }, '[integrations] provider mismatch in state');
|
||||
@@ -69,6 +74,7 @@ export class IntegrationsManager {
|
||||
}
|
||||
|
||||
const userLogin = await this.repository.fetchUserLogin(payload.userId);
|
||||
integrationsDebugLog({ step: 'callback:user-fetched', data: { userLogin } });
|
||||
if (!userLogin) {
|
||||
$logger.error({ userId: payload.userId }, '[integrations] user not found during OAuth callback');
|
||||
throw new Error('User not found');
|
||||
@@ -84,19 +90,35 @@ export class IntegrationsManager {
|
||||
const tokens = await exchangeGitLabCode(code);
|
||||
accessTokenEncrypted = encrypt(tokens.accessToken);
|
||||
refreshTokenEncrypted = encrypt(tokens.refreshToken);
|
||||
} else if (provider === 'gitea') {
|
||||
const tokens = await exchangeGiteaCode(code);
|
||||
integrationsDebugLog({ step: 'callback:token-exchanged', data: { hasAccessToken: !!tokens.accessToken, hasRefreshToken: !!tokens.refreshToken } });
|
||||
accessTokenEncrypted = encrypt(tokens.accessToken);
|
||||
refreshTokenEncrypted = tokens.refreshToken ? encrypt(tokens.refreshToken) : null;
|
||||
} else {
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
integrationsDebugLog({ step: 'callback:tokens-encrypted' });
|
||||
|
||||
await this.repository.createWithToken(
|
||||
provider as 'github' | 'gitlab',
|
||||
const created = await this.repository.createWithToken(
|
||||
provider as IntegrationProvider,
|
||||
payload.projectId,
|
||||
accessTokenEncrypted,
|
||||
refreshTokenEncrypted,
|
||||
);
|
||||
if (!created) {
|
||||
integrationsDebugLog({ step: 'callback:db-insert-failed' });
|
||||
throw new Error('Failed to store integration record');
|
||||
}
|
||||
integrationsDebugLog({ step: 'callback:integration-created', data: { integrationId: created.id } });
|
||||
|
||||
$logger.debug({ provider, projectId: payload.projectId, userLogin }, '[integrations] OAuth callback completed');
|
||||
return { projectId: payload.projectId, userLogin };
|
||||
// The app routes are /:orgSlug/:projectId/... — redirect must use the slug
|
||||
// of the project's organization, falling back to the user login for legacy
|
||||
// projects without an organization.
|
||||
const orgSlug = await this.repository.fetchProjectOrgSlug(payload.projectId) ?? userLogin;
|
||||
|
||||
$logger.debug({ provider, projectId: payload.projectId, orgSlug }, '[integrations] OAuth callback completed');
|
||||
return { projectId: payload.projectId, orgSlug };
|
||||
}
|
||||
|
||||
async fetchRepos(integrationId: number): Promise<RepoItemForClient[]> {
|
||||
@@ -126,6 +148,16 @@ export class IntegrationsManager {
|
||||
description: r.description,
|
||||
url: r.web_url,
|
||||
}));
|
||||
} else if (integration.provider === 'gitea') {
|
||||
const repos = await fetchGiteaRepos(accessToken);
|
||||
return repos.map((r) => ({
|
||||
id: r.id,
|
||||
fullName: r.full_name,
|
||||
name: r.name,
|
||||
isPrivate: r.private,
|
||||
description: r.description,
|
||||
url: r.html_url,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
@@ -174,6 +206,14 @@ export class IntegrationsManager {
|
||||
} else if (integration.provider === 'gitlab' && integration.repoExternalId) {
|
||||
const result = await createGitLabWebhook(accessToken, Number(integration.repoExternalId), webhookUrl, webhookSecret);
|
||||
webhookId = String(result.id);
|
||||
} else if (integration.provider === 'gitea') {
|
||||
const result = await createGiteaWebhook({
|
||||
accessToken,
|
||||
repoFullName: integration.repoFullName,
|
||||
webhookUrl,
|
||||
secret: webhookSecret,
|
||||
});
|
||||
webhookId = String(result.id);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -197,12 +237,11 @@ export class IntegrationsManager {
|
||||
const existingMappings = await this.repository.fetchMappingsByIntegrationId(integrationId);
|
||||
const mappingsByIssueNumber = new Map(existingMappings.map((m) => [m.issueNumber, m]));
|
||||
|
||||
const issueUrlPrefix = this.getIssueUrlPrefix(integration);
|
||||
|
||||
// Backfill sourceUrl for existing tasks that don't have it yet
|
||||
if (existingMappings.length > 0) {
|
||||
const baseUrl = integration.provider === 'github' ? GITHUB_BASE_URL : GITLAB_BASE_URL;
|
||||
const issuePath = integration.provider === 'gitlab' ? '/-/issues/' : '/issues/';
|
||||
const prefix = `${baseUrl}/${integration.repoFullName}${issuePath}`;
|
||||
await this.repository.backfillSourceUrls(integrationId, prefix).catch(logError);
|
||||
await this.repository.backfillSourceUrls(integrationId, issueUrlPrefix).catch(logError);
|
||||
}
|
||||
|
||||
type NewIssueItem = { goalId: number; description: string; integrationId: number; issueNumber: number; issueState: string; note: string | null; complete: boolean; kanbanOrder: number; sourceUrl: string | null };
|
||||
@@ -263,6 +302,34 @@ export class IntegrationsManager {
|
||||
sourceUrl: `${GITLAB_BASE_URL}/${integration.repoFullName}/-/issues/${issue.iid}`,
|
||||
});
|
||||
}
|
||||
} else if (integration.provider === 'gitea') {
|
||||
const issues = await fetchGiteaIssues({ accessToken, repoFullName: integration.repoFullName, since });
|
||||
for (const issue of issues) {
|
||||
const existing = mappingsByIssueNumber.get(issue.number);
|
||||
if (existing) {
|
||||
const isClosed = issue.state === 'closed';
|
||||
const targetState = isClosed ? 'closed' : 'open';
|
||||
await this.repository.updateTaskComplete(existing.taskId, isClosed).catch(logError);
|
||||
if (existing.issueState !== targetState) {
|
||||
await this.repository.updateMappingState(existing.id, targetState).catch(logError);
|
||||
}
|
||||
await this.repository.updateTaskTitleAndNote(existing.taskId, issue.title, issue.body ?? null).catch(logError);
|
||||
await this.repository.updateTaskSourceUrl(existing.taskId, `${issueUrlPrefix}${issue.number}`).catch(logError);
|
||||
continue;
|
||||
}
|
||||
const isClosed = issue.state === 'closed';
|
||||
newItems.push({
|
||||
goalId: integration.projectId,
|
||||
description: issue.title,
|
||||
integrationId,
|
||||
issueNumber: issue.number,
|
||||
issueState: isClosed ? 'closed' : 'open',
|
||||
note: issue.body ?? null,
|
||||
complete: isClosed,
|
||||
kanbanOrder: 0,
|
||||
sourceUrl: `${issueUrlPrefix}${issue.number}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Issues come newest-first from API.
|
||||
@@ -319,6 +386,13 @@ export class IntegrationsManager {
|
||||
mapping.issueNumber,
|
||||
complete ? 'close' : 'reopen',
|
||||
);
|
||||
} else if (integration.provider === 'gitea') {
|
||||
await updateGiteaIssueState({
|
||||
accessToken,
|
||||
repoFullName: integration.repoFullName,
|
||||
issueNumber: mapping.issueNumber,
|
||||
state: targetState,
|
||||
});
|
||||
}
|
||||
|
||||
await this.repository.updateMappingState(mapping.id, targetState);
|
||||
@@ -326,41 +400,56 @@ export class IntegrationsManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
private getIssueUrlPrefix(integration: IntegrationsSchemaTypeForSelect): string {
|
||||
if (integration.provider === 'gitlab') {
|
||||
return `${GITLAB_BASE_URL}/${integration.repoFullName}/-/issues/`;
|
||||
}
|
||||
const baseUrl = integration.provider === 'gitea' ? GITEA_BASE_URL : GITHUB_BASE_URL;
|
||||
return `${baseUrl}/${integration.repoFullName}/issues/`;
|
||||
}
|
||||
|
||||
private async getAccessToken(integration: IntegrationsSchemaTypeForSelect): Promise<string | null> {
|
||||
if (!integration.accessTokenEncrypted) return null;
|
||||
|
||||
const accessToken = decrypt(integration.accessTokenEncrypted);
|
||||
|
||||
if (integration.provider !== 'gitlab' || !integration.refreshTokenEncrypted) {
|
||||
const hasExpiringToken = integration.provider === 'gitlab' || integration.provider === 'gitea';
|
||||
if (!hasExpiringToken || !integration.refreshTokenEncrypted) {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
// Try the current token, refresh on 401
|
||||
try {
|
||||
const axios = (await import('axios')).default;
|
||||
const gitlabApiUrl = process.env.GITLAB_API_URL || 'https://gitlab.com/api/v4';
|
||||
await axios.get(`${gitlabApiUrl}/user`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (integration.provider === 'gitea') {
|
||||
await verifyGiteaToken(accessToken);
|
||||
} else {
|
||||
const axios = (await import('axios')).default;
|
||||
const gitlabApiUrl = process.env.GITLAB_API_URL || 'https://gitlab.com/api/v4';
|
||||
await axios.get(`${gitlabApiUrl}/user`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
return accessToken;
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status !== 401) return accessToken;
|
||||
$logger.debug({ integrationId: integration.id }, '[integrations] GitLab token expired (401), refreshing');
|
||||
$logger.debug({ integrationId: integration.id, provider: integration.provider }, '[integrations] token expired (401), refreshing');
|
||||
}
|
||||
|
||||
// Token expired, refresh it
|
||||
try {
|
||||
const refreshToken = decrypt(integration.refreshTokenEncrypted);
|
||||
const tokens = await refreshGitLabToken(refreshToken);
|
||||
const tokens = integration.provider === 'gitea'
|
||||
? await refreshGiteaToken(refreshToken)
|
||||
: await refreshGitLabToken(refreshToken);
|
||||
await this.repository.updateTokens(
|
||||
integration.id,
|
||||
encrypt(tokens.accessToken),
|
||||
encrypt(tokens.refreshToken),
|
||||
);
|
||||
$logger.debug({ integrationId: integration.id }, '[integrations] GitLab token refreshed successfully');
|
||||
$logger.debug({ integrationId: integration.id, provider: integration.provider }, '[integrations] token refreshed successfully');
|
||||
return tokens.accessToken;
|
||||
} catch (err) {
|
||||
$logger.error({ integrationId: integration.id, err }, '[integrations] GitLab token refresh failed');
|
||||
$logger.error({ integrationId: integration.id, provider: integration.provider, err }, '[integrations] token refresh failed');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { and, eq, ne, isNull, sql } from 'drizzle-orm';
|
||||
import { IntegrationsSchema, IntegrationTaskMapSchema, TasksSchema, UsersSchema, type IntegrationsSchemaTypeForSelect, type IntegrationTaskMapSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { GoalsSchema, IntegrationsSchema, IntegrationTaskMapSchema, OrganizationsSchema, TasksSchema, UsersSchema, type IntegrationsSchemaTypeForSelect, type IntegrationTaskMapSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { Database } from '../../modules/db';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import type { IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgSelectRepo, IntegrationsArgToggle } from './types';
|
||||
import type { IntegrationProvider, IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgSelectRepo, IntegrationsArgToggle } from './types';
|
||||
import { TasksRepository } from '../tasks/TasksRepository';
|
||||
|
||||
export class IntegrationsRepository {
|
||||
@@ -62,7 +62,7 @@ export class IntegrationsRepository {
|
||||
}
|
||||
|
||||
async createWithToken(
|
||||
provider: 'github' | 'gitlab',
|
||||
provider: IntegrationProvider,
|
||||
projectId: number,
|
||||
accessTokenEncrypted: string,
|
||||
refreshTokenEncrypted?: string | null,
|
||||
@@ -322,6 +322,17 @@ export class IntegrationsRepository {
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async fetchProjectOrgSlug(projectId: number): Promise<string | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ slug: OrganizationsSchema.slug })
|
||||
.from(GoalsSchema)
|
||||
.innerJoin(OrganizationsSchema, eq(GoalsSchema.organizationId, OrganizationsSchema.id))
|
||||
.where(eq(GoalsSchema.id, projectId))
|
||||
);
|
||||
if (!result || result.length === 0) return null;
|
||||
return result[0].slug;
|
||||
}
|
||||
|
||||
async fetchUserLogin(userId: number): Promise<string | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ login: UsersSchema.login }).from(UsersSchema)
|
||||
|
||||
@@ -31,5 +31,6 @@ export default class IntegrationsRoutes implements Routable {
|
||||
this.router.get('/oauth/:provider/callback', this.controller.handleOAuthCallback);
|
||||
this.router.post('/webhook/github', this.controller.handleGitHubWebhook);
|
||||
this.router.post('/webhook/gitlab', this.controller.handleGitLabWebhook);
|
||||
this.router.post('/webhook/gitea', this.controller.handleGiteaWebhook);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { appendFileSync } from 'fs';
|
||||
import type { IntegrationsDebugLogEntry } from './types';
|
||||
|
||||
// TEMPORARY debug instrumentation for the integrations OAuth flow.
|
||||
// Remove this file and all integrationsDebugLog() calls once the Gitea
|
||||
// connect issue is resolved.
|
||||
const LOG_PATH = '/private/tmp/claude-501/-Users-nikolaygiman-Programming-HandScreamInc-taskview/1d568266-6fbf-457c-83c2-5c5ca619edf1/scratchpad/integrations-debug.log';
|
||||
|
||||
export function integrationsDebugLog(entry: IntegrationsDebugLogEntry): void {
|
||||
try {
|
||||
appendFileSync(LOG_PATH, `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`);
|
||||
} catch {
|
||||
// debug logging must never break the flow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import axios from 'axios';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import type { GiteaCreateWebhookArgs, GiteaFetchIssuesArgs, GiteaUpdateIssueStateArgs, GiteaVerifyWebhookSignatureArgs } from '../types';
|
||||
|
||||
export const GITEA_BASE_URL = (process.env.GITEA_BASE_URL || 'https://gitea.com').replace(/\/+$/, '');
|
||||
const GITEA_API_URL = process.env.GITEA_API_URL || `${GITEA_BASE_URL}/api/v1`;
|
||||
|
||||
export type GiteaRepo = {
|
||||
id: number;
|
||||
full_name: string;
|
||||
name: string;
|
||||
private: boolean;
|
||||
description: string | null;
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
export type GiteaIssue = {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: 'open' | 'closed';
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
export function getGiteaOAuthUrl(state: string): string {
|
||||
const clientId = process.env.GITEA_INTEGRATION_CLIENT_ID;
|
||||
const redirectUri = process.env.GITEA_INTEGRATION_CALLBACK_URL;
|
||||
if (!clientId || !redirectUri) {
|
||||
throw new Error('Gitea integration OAuth is not configured');
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
state,
|
||||
});
|
||||
return `${GITEA_BASE_URL}/login/oauth/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function exchangeGiteaCode(code: string): Promise<{ accessToken: string; refreshToken: string | null }> {
|
||||
const res = await axios.post<{ access_token: string; refresh_token?: string; token_type: string }>(
|
||||
`${GITEA_BASE_URL}/login/oauth/access_token`,
|
||||
{
|
||||
client_id: process.env.GITEA_INTEGRATION_CLIENT_ID,
|
||||
client_secret: process.env.GITEA_INTEGRATION_CLIENT_SECRET,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: process.env.GITEA_INTEGRATION_CALLBACK_URL,
|
||||
},
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
},
|
||||
);
|
||||
if (!res.data.access_token) {
|
||||
throw new Error('Failed to exchange Gitea code for token');
|
||||
}
|
||||
return {
|
||||
accessToken: res.data.access_token,
|
||||
refreshToken: res.data.refresh_token ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshGiteaToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
const res = await axios.post<{ access_token: string; refresh_token: string; token_type: string }>(
|
||||
`${GITEA_BASE_URL}/login/oauth/access_token`,
|
||||
{
|
||||
client_id: process.env.GITEA_INTEGRATION_CLIENT_ID,
|
||||
client_secret: process.env.GITEA_INTEGRATION_CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
},
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
},
|
||||
);
|
||||
if (!res.data.access_token) {
|
||||
throw new Error('Failed to refresh Gitea token');
|
||||
}
|
||||
return {
|
||||
accessToken: res.data.access_token,
|
||||
refreshToken: res.data.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyGiteaToken(accessToken: string): Promise<void> {
|
||||
await axios.get(`${GITEA_API_URL}/user`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchGiteaRepos(accessToken: string): Promise<GiteaRepo[]> {
|
||||
const repos: GiteaRepo[] = [];
|
||||
let page = 1;
|
||||
const perPage = 50;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GiteaRepo[]>(`${GITEA_API_URL}/user/repos`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
params: {
|
||||
limit: perPage,
|
||||
page,
|
||||
},
|
||||
});
|
||||
repos.push(...res.data);
|
||||
if (res.data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return repos;
|
||||
}
|
||||
|
||||
export async function fetchGiteaIssues(args: GiteaFetchIssuesArgs): Promise<GiteaIssue[]> {
|
||||
const issues: GiteaIssue[] = [];
|
||||
let page = 1;
|
||||
const perPage = 50;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GiteaIssue[]>(`${GITEA_API_URL}/repos/${args.repoFullName}/issues`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${args.accessToken}`,
|
||||
},
|
||||
params: {
|
||||
state: 'all',
|
||||
// Gitea returns pull requests from the issues endpoint too — this excludes them
|
||||
type: 'issues',
|
||||
limit: perPage,
|
||||
page,
|
||||
...(args.since ? { since: args.since } : {}),
|
||||
},
|
||||
});
|
||||
issues.push(...res.data);
|
||||
if (res.data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export async function createGiteaWebhook(args: GiteaCreateWebhookArgs): Promise<{ id: number }> {
|
||||
const res = await axios.post<{ id: number }>(
|
||||
`${GITEA_API_URL}/repos/${args.repoFullName}/hooks`,
|
||||
{
|
||||
type: 'gitea',
|
||||
active: true,
|
||||
events: ['issues'],
|
||||
config: {
|
||||
url: args.webhookUrl,
|
||||
content_type: 'json',
|
||||
secret: args.secret,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${args.accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return { id: res.data.id };
|
||||
}
|
||||
|
||||
export function verifyGiteaWebhookSignature(args: GiteaVerifyWebhookSignatureArgs): boolean {
|
||||
const expected = createHmac('sha256', args.secret).update(args.rawBody).digest('hex');
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(args.signature), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGiteaIssueState(args: GiteaUpdateIssueStateArgs): Promise<void> {
|
||||
await axios.patch(
|
||||
`${GITEA_API_URL}/repos/${args.repoFullName}/issues/${args.issueNumber}`,
|
||||
{ state: args.state },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${args.accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type } from 'arktype';
|
||||
|
||||
export const IntegrationsArkTypeAdd = type({
|
||||
provider: "'github' | 'gitlab'",
|
||||
provider: "'github' | 'gitlab' | 'gitea'",
|
||||
repoFullName: 'string',
|
||||
projectId: 'number',
|
||||
});
|
||||
@@ -30,10 +30,43 @@ export const IntegrationsArkTypeSelectRepo = type({
|
||||
});
|
||||
export type IntegrationsArgSelectRepo = typeof IntegrationsArkTypeSelectRepo.infer;
|
||||
|
||||
export type IntegrationProvider = 'github' | 'gitlab' | 'gitea';
|
||||
|
||||
export type OAuthStatePayload = {
|
||||
userId: number;
|
||||
projectId: number;
|
||||
provider: 'github' | 'gitlab';
|
||||
provider: IntegrationProvider;
|
||||
};
|
||||
|
||||
export type GiteaFetchIssuesArgs = {
|
||||
accessToken: string;
|
||||
repoFullName: string;
|
||||
since?: string;
|
||||
};
|
||||
|
||||
export type GiteaCreateWebhookArgs = {
|
||||
accessToken: string;
|
||||
repoFullName: string;
|
||||
webhookUrl: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
export type GiteaVerifyWebhookSignatureArgs = {
|
||||
rawBody: Buffer;
|
||||
signature: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
export type GiteaUpdateIssueStateArgs = {
|
||||
accessToken: string;
|
||||
repoFullName: string;
|
||||
issueNumber: number;
|
||||
state: 'open' | 'closed';
|
||||
};
|
||||
|
||||
export type IntegrationsDebugLogEntry = {
|
||||
step: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
export type RepoItemForClient = {
|
||||
|
||||
@@ -21,16 +21,7 @@ export function isSafeUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Escapes HTML text and attribute contexts (the quotes matter inside href="...")
|
||||
// so a user-controlled value can't break out of a Telegram HTML message.
|
||||
export function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
export { escapeHtml } from '../../utils/helpers';
|
||||
|
||||
// Slack mrkdwn requires escaping these three in text (incl. link labels).
|
||||
export function escapeSlackText(text: string): string {
|
||||
|
||||
@@ -84,12 +84,20 @@ export class RecurrenceGenerator {
|
||||
// Completed late → next from today, not a pile of overdue copies (Todoist behavior).
|
||||
const today = RecurrenceParser.todayInTimezone(rule.timezone);
|
||||
const afterDate = rule.lastInstanceDate > today ? rule.lastInstanceDate : today;
|
||||
const nextDate = RecurrenceParser.nextOccurrenceDate({
|
||||
rrule: rule.rrule,
|
||||
dtstart: rule.dtstart,
|
||||
afterDate,
|
||||
skipDates,
|
||||
});
|
||||
// Fixed series follow the calendar schedule; after-completion series
|
||||
// take one interval step from the completion day. Stepping from
|
||||
// max(lastInstanceDate, today) keeps instance dates strictly
|
||||
// increasing, so the (rule_id, instance_date) unique index can
|
||||
// never collide with an earlier instance of the series.
|
||||
const nextDate =
|
||||
rule.scheduleMode === 'after-completion'
|
||||
? RecurrenceParser.nextDateAfterCompletion({ rrule: rule.rrule, afterDate })
|
||||
: RecurrenceParser.nextOccurrenceDate({
|
||||
rrule: rule.rrule,
|
||||
dtstart: rule.dtstart,
|
||||
afterDate,
|
||||
skipDates,
|
||||
});
|
||||
if (!nextDate) {
|
||||
await tx
|
||||
.update(RecurrenceRulesSchema)
|
||||
|
||||
@@ -61,10 +61,12 @@ export class RecurrenceManager {
|
||||
return fail('invalid_rule', 'timezone must be a valid IANA name');
|
||||
}
|
||||
|
||||
const scheduleMode = args.scheduleMode ?? 'fixed';
|
||||
let dtstart: Date;
|
||||
let hasTime: boolean;
|
||||
try {
|
||||
RecurrenceParser.validateRuleString(args.rrule);
|
||||
if (scheduleMode === 'after-completion') RecurrenceParser.validateForAfterCompletion(args.rrule);
|
||||
({ date: dtstart, hasTime } = RecurrenceParser.parseDtstart(args.dtstart));
|
||||
} catch (err) {
|
||||
return fail('invalid_rule', (err as Error).message);
|
||||
@@ -99,6 +101,7 @@ export class RecurrenceManager {
|
||||
dtstart,
|
||||
hasTime,
|
||||
timezone: args.timezone,
|
||||
scheduleMode,
|
||||
lastInstanceDate: originInstanceDate,
|
||||
notifyOnOccurrence: args.notifyOnOccurrence ?? false,
|
||||
creatorId: this.initiatorId,
|
||||
@@ -196,13 +199,26 @@ export class RecurrenceManager {
|
||||
}
|
||||
patch.timezone = args.timezone;
|
||||
}
|
||||
if (patch.rrule !== undefined || patch.dtstart !== undefined) {
|
||||
const nextDate = RecurrenceParser.nextOccurrenceDate({
|
||||
rrule: patch.rrule ?? rule.rrule,
|
||||
dtstart: patch.dtstart ?? rule.dtstart,
|
||||
afterDate: RecurrenceParser.todayInTimezone(patch.timezone ?? rule.timezone),
|
||||
skipDates: new Set<string>(),
|
||||
});
|
||||
if (args.scheduleMode !== undefined) patch.scheduleMode = args.scheduleMode;
|
||||
const effectiveMode = patch.scheduleMode ?? rule.scheduleMode;
|
||||
if (effectiveMode === 'after-completion') {
|
||||
try {
|
||||
RecurrenceParser.validateForAfterCompletion(patch.rrule ?? rule.rrule);
|
||||
} catch (err) {
|
||||
return fail('invalid_rule', (err as Error).message);
|
||||
}
|
||||
}
|
||||
if (patch.rrule !== undefined || patch.dtstart !== undefined || patch.scheduleMode !== undefined) {
|
||||
const afterDate = RecurrenceParser.todayInTimezone(patch.timezone ?? rule.timezone);
|
||||
const nextDate =
|
||||
effectiveMode === 'after-completion'
|
||||
? RecurrenceParser.nextDateAfterCompletion({ rrule: patch.rrule ?? rule.rrule, afterDate })
|
||||
: RecurrenceParser.nextOccurrenceDate({
|
||||
rrule: patch.rrule ?? rule.rrule,
|
||||
dtstart: patch.dtstart ?? rule.dtstart,
|
||||
afterDate,
|
||||
skipDates: new Set<string>(),
|
||||
});
|
||||
if (!nextDate) return fail('invalid_rule', 'rule produces no occurrences');
|
||||
}
|
||||
if (args.notifyOnOccurrence !== undefined) patch.notifyOnOccurrence = args.notifyOnOccurrence;
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { RRule } from 'rrule';
|
||||
import type { InstanceWindow, InstanceWindowArgs, NextOccurrenceArgs, ParseRuleArgs } from './types';
|
||||
import type { InstanceWindow, InstanceWindowArgs, NextDateAfterCompletionArgs, NextOccurrenceArgs, ParseRuleArgs } from './types';
|
||||
|
||||
const ALLOWED_FREQUENCIES = new Set<number>([RRule.YEARLY, RRule.MONTHLY, RRule.WEEKLY, RRule.DAILY]);
|
||||
const MAX_COUNT = 10000;
|
||||
|
||||
const FREQ_TO_STEP_UNIT: Record<number, 'years' | 'months' | 'weeks' | 'days'> = {
|
||||
[RRule.YEARLY]: 'years',
|
||||
[RRule.MONTHLY]: 'months',
|
||||
[RRule.WEEKLY]: 'weeks',
|
||||
[RRule.DAILY]: 'days',
|
||||
};
|
||||
|
||||
/**
|
||||
* All recurrence math happens in a single floating wall-clock frame:
|
||||
* `dtstart` is a Date whose UTC components equal the wall-clock components of
|
||||
@@ -41,6 +48,40 @@ export class RecurrenceParser {
|
||||
return RRule.parseString(rruleString).count ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* After-completion series step from the completion day, so calendar anchors
|
||||
* (BYDAY, BYMONTHDAY) have no defined meaning for them — reject instead of
|
||||
* silently ignoring what the client asked for.
|
||||
*/
|
||||
static validateForAfterCompletion(rruleString: string): void {
|
||||
const options = RRule.parseString(rruleString);
|
||||
if (options.byweekday !== undefined && options.byweekday !== null) {
|
||||
throw new Error('BYDAY is not supported for after-completion series');
|
||||
}
|
||||
if (options.bymonthday !== undefined && options.bymonthday !== null) {
|
||||
throw new Error('BYMONTHDAY is not supported for after-completion series');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Next date of an after-completion series: one FREQ/INTERVAL step after
|
||||
* `afterDate` (the completion day), no calendar anchor. Month/year steps
|
||||
* clamp to the last valid day (Jan 31 + 1 month → Feb 28). COUNT is
|
||||
* enforced by the caller via instances_created (same as fixed series);
|
||||
* returns null when the step lands past UNTIL — the series is over.
|
||||
*/
|
||||
static nextDateAfterCompletion(args: NextDateAfterCompletionArgs): string | null {
|
||||
const options = RRule.parseString(args.rrule);
|
||||
const unit = options.freq !== undefined ? FREQ_TO_STEP_UNIT[options.freq] : undefined;
|
||||
if (!unit) return null;
|
||||
const nextDate = DateTime.fromISO(args.afterDate, { zone: 'utc' })
|
||||
.plus({ [unit]: options.interval ?? 1 })
|
||||
.toISODate();
|
||||
if (!nextDate) return null;
|
||||
if (options.until && nextDate > RecurrenceParser.toIsoDate(options.until)) return null;
|
||||
return nextDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* First occurrence date strictly after `afterDate`, skipping explicit skip
|
||||
* dates. COUNT is intentionally stripped: the cap is "N materialized
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type } from 'arktype';
|
||||
import type {
|
||||
RecurrenceRulesSchemaTypeForInsert,
|
||||
RecurrenceRulesSchemaTypeForSelect,
|
||||
RecurrenceScheduleMode,
|
||||
TasksSchemaTypeForSelect,
|
||||
} from 'taskview-db-schemas';
|
||||
|
||||
@@ -12,6 +13,7 @@ export const RecurrenceArkTypeCreate = type({
|
||||
rrule: 'string > 0',
|
||||
dtstart: 'string', // 'YYYY-MM-DDTHH:mm:ss' floating wall-clock, no TZ suffix
|
||||
timezone: 'string > 0', // IANA name, e.g. 'Europe/Moscow'
|
||||
'scheduleMode?': '"fixed" | "after-completion"',
|
||||
'notifyOnOccurrence?': 'boolean',
|
||||
});
|
||||
|
||||
@@ -20,6 +22,7 @@ export const RecurrenceArkTypeUpdate = type({
|
||||
'rrule?': 'string > 0',
|
||||
'dtstart?': 'string',
|
||||
'timezone?': 'string > 0',
|
||||
'scheduleMode?': '"fixed" | "after-completion"',
|
||||
'notifyOnOccurrence?': 'boolean',
|
||||
'templateOverrides?': type({
|
||||
'description?': 'string',
|
||||
@@ -63,6 +66,11 @@ export type NextOccurrenceArgs = {
|
||||
afterDate: string;
|
||||
skipDates: Set<string>;
|
||||
};
|
||||
export type NextDateAfterCompletionArgs = {
|
||||
rrule: string;
|
||||
/** 'YYYY-MM-DD' — the completion day; the next date is one FREQ/INTERVAL step after it. */
|
||||
afterDate: string;
|
||||
};
|
||||
export type InstanceWindowArgs = {
|
||||
/** 'YYYY-MM-DD' wall-clock occurrence date in the rule's timezone. */
|
||||
occurrenceDate: string;
|
||||
@@ -93,6 +101,7 @@ export type RecurrenceRulePatchArgs = {
|
||||
dtstart: Date;
|
||||
hasTime: boolean;
|
||||
timezone: string;
|
||||
scheduleMode: RecurrenceScheduleMode;
|
||||
state: 'active' | 'paused' | 'ended';
|
||||
lastInstanceDate: string;
|
||||
instancesCreated: number;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type } from 'arktype'
|
||||
import { hashSync } from 'bcryptjs'
|
||||
import type { Request, Response } from 'express'
|
||||
import { $logger } from '../../modules/logget'
|
||||
import { PublicApiUrl } from '../../modules/public-url'
|
||||
import { logError } from '../../utils/api'
|
||||
import { generateString, isEmail } from '../../utils/helpers'
|
||||
import AuthModel from '../auth/AuthModel'
|
||||
@@ -140,6 +141,16 @@ export class SsoController {
|
||||
})
|
||||
}
|
||||
|
||||
getPublicUrls = async (req: Request, res: Response) => {
|
||||
const base = PublicApiUrl.base(req)
|
||||
return res.tvJson({
|
||||
apiBaseUrl: base,
|
||||
callbackUrlTemplate: `${base}/module/sso/callback/{id}`,
|
||||
scimEndpointUrl: `${base}/scim/v2`,
|
||||
apiPublicUrlConfigured: PublicApiUrl.configured() !== null,
|
||||
})
|
||||
}
|
||||
|
||||
listConfigs = async (req: Request, res: Response) => {
|
||||
const orgId = Number(req.query.organizationId)
|
||||
if (!orgId) return res.status(400).tvJson({ message: 'organizationId is required' })
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Routable } from '../../types/routable.type'
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
|
||||
import { IsOrgAdmin } from '../organizations/middlewares/IsOrgAdmin'
|
||||
import { IsSsoConfigAdmin } from './middlewares/IsSsoConfigAdmin'
|
||||
import { RequireLoginMethod } from '../auth/middlewares/require-login-method'
|
||||
import { SsoController } from './SsoController'
|
||||
|
||||
export default class SsoRoutes implements Routable {
|
||||
@@ -20,11 +21,12 @@ export default class SsoRoutes implements Routable {
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('/providers', this.controller.listPublicProviders)
|
||||
this.router.get('/login/:configId', this.controller.initiateLogin)
|
||||
this.router.get('/callback/:configId', this.controller.handleCallback)
|
||||
this.router.post('/callback/:configId', this.controller.handleCallback)
|
||||
this.router.get('/providers', [RequireLoginMethod('sso')], this.controller.listPublicProviders)
|
||||
this.router.get('/login/:configId', [RequireLoginMethod('sso')], this.controller.initiateLogin)
|
||||
this.router.get('/callback/:configId', [RequireLoginMethod('sso')], this.controller.handleCallback)
|
||||
this.router.post('/callback/:configId', [RequireLoginMethod('sso')], this.controller.handleCallback)
|
||||
|
||||
this.router.get('/admin/public-urls', [IsLoggedIn], this.controller.getPublicUrls)
|
||||
this.router.get('/admin/metadata', [IsLoggedIn, IsOrgAdmin], this.controller.parseMetadata)
|
||||
this.router.get('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.listConfigs)
|
||||
this.router.post('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.createConfig)
|
||||
|
||||
@@ -110,7 +110,8 @@ export class OidcProvider implements SsoProvider {
|
||||
throw new Error('CSRF state mismatch — possible CSRF attack')
|
||||
}
|
||||
|
||||
const currentUrl = new URL(req.originalUrl, `${req.protocol}://${req.get('host')}`)
|
||||
const callbackOrigin = new URL(this.config.oidcCallbackUrl!).origin
|
||||
const currentUrl = new URL(req.originalUrl, callbackOrigin)
|
||||
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
|
||||
pkceCodeVerifier: codeVerifier,
|
||||
expectedState: returnedState,
|
||||
|
||||
@@ -89,7 +89,7 @@ export class StartManager {
|
||||
await this.fetchSharedGoals(organizationId);
|
||||
const goalIds = await this.getAllGoalsIds(organizationId);
|
||||
|
||||
const tasks = await this.repository.searchTask(description.trim(), goalIds);
|
||||
const tasks = await this.repository.searchTask({ description, goalsIds: goalIds });
|
||||
return tasks;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, eq, inArray, isNotNull, or } from 'drizzle-orm';
|
||||
import { GoalsSchema, GoalsListSchema } from 'taskview-db-schemas';
|
||||
import { and, eq, ilike, inArray, isNotNull, isNull, or } from 'drizzle-orm';
|
||||
import { GoalsSchema, GoalsListSchema, TasksSchema } from 'taskview-db-schemas';
|
||||
import type { AppUser } from '../../core/AppUser';
|
||||
import { Database } from '../../modules/db';
|
||||
import { $logger } from '../../modules/logget';
|
||||
@@ -8,7 +8,7 @@ import { logError } from '../../utils/api';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import type { TagToTaskInDb } from '../tags/tags.types';
|
||||
import { TaskItemForClient } from '../tasks/TaskItemForClient';
|
||||
import type { AssigneesForTaskFromDb, FetchAllListsResult, UsersByProjectsFromDb } from './start.types';
|
||||
import type { AssigneesForTaskFromDb, FetchAllListsResult, SearchTaskArgs, SearchTaskResult, UsersByProjectsFromDb } from './start.types';
|
||||
|
||||
//TODO: refactor
|
||||
export class StartRepository {
|
||||
@@ -371,31 +371,38 @@ export class StartRepository {
|
||||
return [...taskIdToTaskMap.values()];
|
||||
}
|
||||
|
||||
async searchTask(description: string, goalsIds: number[]): Promise<TaskItemForClient[]> {
|
||||
if (goalsIds.length === 0 || !description.trim()) {
|
||||
async searchTask(args: SearchTaskArgs): Promise<SearchTaskResult[]> {
|
||||
const description = args.description.trim();
|
||||
if (args.goalsIds.length === 0 || !description) {
|
||||
return [];
|
||||
}
|
||||
const placeholders = goalsIds.map((_id, index) => {
|
||||
return `$${index + 1}`;
|
||||
});
|
||||
|
||||
const result = await this.db.query<TaskItemInDb>(
|
||||
`select * from tasks.tasks where goal_id in (${placeholders.join(',')}) and complete = FALSE and parent_id is null and description ILIKE $${goalsIds.length + 1}`,
|
||||
[...goalsIds, `%${description}%`]
|
||||
const idMatch = description.match(/^#(\d+)$/);
|
||||
const searchCondition = idMatch
|
||||
? eq(TasksSchema.id, Number(idMatch[1]))
|
||||
: and(
|
||||
eq(TasksSchema.complete, false),
|
||||
isNull(TasksSchema.parentId),
|
||||
ilike(TasksSchema.description, `%${description}%`),
|
||||
);
|
||||
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(TasksSchema)
|
||||
.where(and(inArray(TasksSchema.goalId, args.goalsIds), searchCondition))
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const map: Map<number, TaskItemForClient> = new Map();
|
||||
|
||||
result.rows.forEach((t) => {
|
||||
if (!map.get(t.id)) {
|
||||
map.set(t.id, new TaskItemForClient(t));
|
||||
}
|
||||
});
|
||||
|
||||
return [...map.values()];
|
||||
return result.map((task) => ({
|
||||
...task,
|
||||
tags: [],
|
||||
assignedUsers: [],
|
||||
historyId: null,
|
||||
subtasks: [],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
import type { TasksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
|
||||
export type SearchTaskArgs = {
|
||||
description: string;
|
||||
goalsIds: number[];
|
||||
};
|
||||
|
||||
export type SearchTaskResult = TasksSchemaTypeForSelect & {
|
||||
tags: number[];
|
||||
assignedUsers: number[];
|
||||
historyId: number | null;
|
||||
subtasks: SearchTaskResult[];
|
||||
};
|
||||
|
||||
export type FetchAllListsResult = {
|
||||
goalName: string | null;
|
||||
listName: string | null;
|
||||
|
||||
@@ -44,6 +44,8 @@ const firstDayOfWeekArkType = type('number.integer').narrow((v, ctx) =>
|
||||
|
||||
export const UiSettingsArkType = type({
|
||||
'firstDayOfWeek?': firstDayOfWeekArkType,
|
||||
'defaultProjectId?': 'number.integer >= 1',
|
||||
'defaultView?': "'tasks' | 'kanban' | 'graph' | 'sprints'",
|
||||
})
|
||||
|
||||
export type UiSettings = typeof UiSettingsArkType.infer
|
||||
|
||||
@@ -23,6 +23,17 @@ export const AppEnvSchema = z.object({
|
||||
SMTP_FROM_EMAIL: z.string().optional(),
|
||||
APP_URL: z.string(),
|
||||
|
||||
// Send an email to a person when they are invited to a project (requires SMTP); default off
|
||||
INVITE_EMAIL_ENABLED: z.string().optional(),
|
||||
// Max invite emails one user may trigger per hour (default 30)
|
||||
INVITE_EMAIL_HOURLY_LIMIT: z.string().optional(),
|
||||
|
||||
// How account password changes are confirmed: code sent by email (default) or current password
|
||||
PASSWORD_CHANGE_CONFIRMATION: z.enum(['email', 'password']).optional(),
|
||||
|
||||
// Comma-separated list of enabled login methods (magic-link, password, sso, social); unset = all enabled
|
||||
AUTH_LOGIN_METHODS: z.string().optional(),
|
||||
|
||||
TELEGRAM_BOT_TOKEN: z.string().optional(),
|
||||
TELEGRAM_BOT_USERNAME: z.string().optional(),
|
||||
TELEGRAM_WEBHOOK_SECRET: z.string().optional(),
|
||||
|
||||
@@ -62,6 +62,61 @@ export const ChangePasswordDataScheme = z
|
||||
|
||||
export type ChangePasswordData = z.infer<typeof ChangePasswordDataScheme>;
|
||||
|
||||
export const ChangeOwnPasswordSchema = z
|
||||
.object({
|
||||
code: z.string().min(1).max(64),
|
||||
password: z.string().min(6).max(128),
|
||||
passwordRepeat: z.string().max(128),
|
||||
})
|
||||
.refine((data) => data.password === data.passwordRepeat, {
|
||||
message: "Passwords don't match",
|
||||
path: ['passwordRepeat'],
|
||||
});
|
||||
|
||||
export type ChangeOwnPassword = z.infer<typeof ChangeOwnPasswordSchema>;
|
||||
|
||||
export const ChangeOwnPasswordByPasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1).max(128),
|
||||
password: z.string().min(6).max(128),
|
||||
passwordRepeat: z.string().max(128),
|
||||
})
|
||||
.refine((data) => data.password === data.passwordRepeat, {
|
||||
message: "Passwords don't match",
|
||||
path: ['passwordRepeat'],
|
||||
});
|
||||
|
||||
export type ChangeOwnPasswordByPassword = z.infer<typeof ChangeOwnPasswordByPasswordSchema>;
|
||||
|
||||
export type PasswordChangeConfirmationMode = 'email' | 'password';
|
||||
|
||||
export type LoginMethod = 'magic-link' | 'password' | 'sso' | 'social';
|
||||
|
||||
export const ChangeDefaultUserCredentialsSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1).max(128),
|
||||
login: z.string().min(3).max(64).regex(/^[a-zA-Z0-9._-]+$/).toLowerCase(),
|
||||
email: z.string().email().max(255).toLowerCase(),
|
||||
password: z.string().min(6).max(128),
|
||||
passwordRepeat: z.string().max(128),
|
||||
})
|
||||
.refine((data) => data.password === data.passwordRepeat, {
|
||||
message: "Passwords don't match",
|
||||
path: ['passwordRepeat'],
|
||||
});
|
||||
|
||||
export type ChangeDefaultUserCredentials = z.infer<typeof ChangeDefaultUserCredentialsSchema>;
|
||||
|
||||
export type UpdateUserCredentialsArgs = {
|
||||
userId: number;
|
||||
oldEmail: string;
|
||||
login: string;
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
};
|
||||
|
||||
export type UpdateUserCredentialsResult = 'ok' | 'conflict' | 'error';
|
||||
|
||||
export const RefreshTokenSchema = z.object({
|
||||
refreshToken: z.string(),
|
||||
});
|
||||
|
||||
@@ -2,6 +2,17 @@ import { randomInt } from 'crypto';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { $logger } from '../modules/logget';
|
||||
|
||||
// Escapes HTML text and attribute contexts (the quotes matter inside href="...")
|
||||
// so a user-controlled value can't break out of the surrounding markup.
|
||||
export function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function isEmail(email: string): boolean {
|
||||
const re =
|
||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
|
||||
@@ -37,9 +37,18 @@ cd web
|
||||
bash build-docker-web.sh $VERSION
|
||||
cd ..
|
||||
|
||||
# Build CE MCP
|
||||
echo "========================================="
|
||||
echo "Building CE MCP Server..."
|
||||
echo "========================================="
|
||||
cd taskview-packages/taskview-mcp
|
||||
bash build-docker-mcp.sh $VERSION gimanhead/taskview-ce-mcp
|
||||
cd ../..
|
||||
|
||||
echo "========================================="
|
||||
echo "Build complete!"
|
||||
echo "Images built:"
|
||||
echo " - gimanhead/taskview-ce-api-server:$VERSION"
|
||||
echo " - gimanhead/taskview-ce-webapp:$VERSION"
|
||||
echo " - gimanhead/taskview-ce-mcp:$VERSION"
|
||||
echo "========================================="
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: What is TaskView
|
||||
description: TaskView is an open-source, self-hosted project and task management platform. Features Kanban boards, dependency graphs, team collaboration, RBAC, GitHub/GitLab sync, and full data ownership. Free alternative to other PM for teams who need privacy and control.
|
||||
description: TaskView is a source-available, self-hosted project and task management platform. Features Kanban boards, dependency graphs, team collaboration, RBAC, GitHub/GitLab sync, and full data ownership. Free alternative to other PM for teams who need privacy and control.
|
||||
navigation:
|
||||
icon: i-lucide-house
|
||||
---
|
||||
|
||||
@@ -40,7 +40,12 @@ DB_USER="taskview_db_user"
|
||||
DB_PASSWORD="your_secure_password"
|
||||
DB_NAME="taskviewdb"
|
||||
DB_PORT=5432
|
||||
# Postgres connections per API worker (this is the default)
|
||||
DB_POOL_MAX=20
|
||||
APP_PORT=1401
|
||||
# API worker processes (this is the default). Accepts a number or "max" (one worker per CPU core).
|
||||
# Keep PM2_INSTANCES x DB_POOL_MAX below the Postgres max_connections limit (default 100).
|
||||
PM2_INSTANCES=2
|
||||
JWT_ALG="HS256"
|
||||
JWT_SIGN="secret"
|
||||
ACCESS_LIFE_TIME="3d"
|
||||
@@ -144,8 +149,24 @@ services:
|
||||
taskview-webapp:
|
||||
image: gimanhead/taskview-ce-webapp:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# The web app will always use this API server and hide the server selector on the login page.
|
||||
# Remove this variable if you want to pick the API server manually on the login page.
|
||||
TASKVIEW_API_URL: "http://localhost:1725"
|
||||
ports:
|
||||
- "8888:80"
|
||||
# Enable to let AI assistants (Claude Code, Cursor, ...) work with your instance
|
||||
# over MCP, read https://taskview.tech/docs/integrations/mcp
|
||||
# taskview-mcp:
|
||||
# image: gimanhead/taskview-ce-mcp:latest
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# TASKVIEW_URL: "http://taskview-api-server:1401"
|
||||
# ports:
|
||||
# - "3100:3100"
|
||||
# depends_on:
|
||||
# - taskview-api-server
|
||||
# networks: [backend]
|
||||
# Enable for realtime notification read https://taskview.tech/docs/configuration/environment-variables#centrifugo-configuration-file
|
||||
# centrifugo:
|
||||
# image: centrifugo/centrifugo:v6
|
||||
@@ -175,14 +196,16 @@ Go to [http://localhost:8888](http://localhost:8888) in your browser. You'll see
|
||||
|
||||
### Configure the API server
|
||||
|
||||
Before logging in, you need to tell the web app where the API server is running. Click the **server settings** icon on the login page and add the API server URL:
|
||||
The web app (port 8888) serves the frontend, while the API server (port 1725) handles authentication, projects, tasks, and all backend operations.
|
||||
|
||||
If you set `TASKVIEW_API_URL` on the `taskview-webapp` service (as in the compose file above), there is nothing to configure — the web app already knows where the API is, and the server selector is hidden from the login page.
|
||||
|
||||
Without `TASKVIEW_API_URL`, click the **server settings** section on the login page and add the API server URL manually:
|
||||
|
||||
```
|
||||
http://localhost:1725
|
||||
```
|
||||
|
||||
This is the API server that handles authentication, projects, tasks, and all backend operations. The web app (port 8888) serves the frontend, while the API server (port 1725) handles the data.
|
||||
|
||||
### Log in with the default user
|
||||
|
||||
The database migration creates a default user so you can log in right away:
|
||||
@@ -193,31 +216,25 @@ The database migration creates a default user so you can log in right away:
|
||||
Use these credentials to verify that everything is working - check that the UI loads, you can create a project, add tasks, etc.
|
||||
|
||||
::callout{icon="i-lucide-alert-triangle" color="error"}
|
||||
**Important:** The default user is for initial setup only. Once you've confirmed the system works, delete the default user and create your own account with a secure password.
|
||||
**Important:** The default credentials are publicly known — anyone who has read this page can sign in to a fresh installation. Claim the account right after the first login.
|
||||
::
|
||||
|
||||
### Replacing the default user
|
||||
### Claim the default account
|
||||
|
||||
Make the default account your own — no SMTP or database access needed:
|
||||
|
||||
1. Log in with the default credentials
|
||||
2. Register a new account with your real email and a strong password
|
||||
3. Delete the default `admin` account
|
||||
2. Open **Account settings** — the highlighted **Login and email** card is shown at the top (it is visible only to the default user)
|
||||
3. Set your own login, email and a strong password, confirm with the current password (`user1!#Q`), and click **Save and sign out**
|
||||
4. Sign in again with your new login and password
|
||||
|
||||
If you prefer to create the first user directly in the database, generate a password hash:
|
||||

|
||||
|
||||
```ts
|
||||
import { hashSync } from 'bcryptjs'
|
||||
Your organizations, projects and permissions are preserved. Once the email is changed, the card disappears and the claim endpoint is disabled.
|
||||
|
||||
const passwordHash = hashSync('your-secure-password', 12)
|
||||
console.log(passwordHash)
|
||||
```
|
||||
|
||||
Or as a one-liner:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('bcryptjs').hashSync('your-secure-password', 12))"
|
||||
```
|
||||
|
||||
Then insert the user into the database with the generated hash.
|
||||
::callout{icon="i-lucide-mail" color="info"}
|
||||
Changing the password later requires a confirmation code sent by email. If your installation has no SMTP, set `PASSWORD_CHANGE_CONFIRMATION="password"` in `.env.taskview` so password changes are confirmed with the current password instead. See [Environment Variables](/docs/configuration/environment-variables#authentication).
|
||||
::
|
||||
|
||||
## Updating
|
||||
|
||||
@@ -233,7 +250,10 @@ The migration container will automatically apply any new database changes on sta
|
||||
## Production tips
|
||||
|
||||
- **Use a reverse proxy** (Nginx, Caddy, Traefik) to terminate SSL and serve everything over HTTPS
|
||||
- **Update `APP_URL` and `API_URL`** in `.env.taskview` to match your production domain
|
||||
- **Update `APP_URL`** in `.env.taskview` and `TASKVIEW_API_URL` on the webapp service to match your production domains
|
||||
- **Trim the login page** — set `AUTH_LOGIN_METHODS` in `.env.taskview` to offer only the sign-in methods you actually use (e.g. `AUTH_LOGIN_METHODS="password"`). Google/GitHub/Apple buttons are shown only when the provider is configured.
|
||||
- **Close the instance** — set `ALLOW_PUBLIC_REGISTRATION="false"` so strangers can't create accounts: only emails invited to an organization or project (and users coming through your SSO provider) can sign in and get an account on first login. See [how it works](/docs/configuration/environment-variables#closing-an-instance-how-allow_public_registrationfalse-works).
|
||||
- **Scale API workers deliberately** — the API runs `PM2_INSTANCES` worker processes (default `2`), and each worker opens its own pool of up to `DB_POOL_MAX` Postgres connections (default `20`). Before raising either value (or using `PM2_INSTANCES=max`), make sure `workers × DB_POOL_MAX` stays below your Postgres `max_connections` (default `100`) — otherwise the API fails with *"sorry, too many clients already"*. The API logs a warning on startup when the budget looks too high.
|
||||
- **Back up the database** - the `pgdata` volume contains all your data
|
||||
- **Set `restart: unless-stopped`** on all services so they survive server reboots
|
||||
- **SMTP setup** - add SMTP variables to `.env.taskview` if you want email features (password recovery, invitations). See [Configuration](/docs/configuration/environment-variables) for details.
|
||||
|
||||
@@ -50,7 +50,7 @@ Only **owners** and **admins** can manage members. The **Members** tab is not vi
|
||||
2. Go to the **Members** tab
|
||||
3. Enter an email address and click **Add Member**
|
||||
|
||||
New members are added with the **member** role by default. You can change their role to **admin** using the role dropdown next to their name. Members can only be invited by email. The person needs to have a TaskView account with that email.
|
||||
New members are added with the **member** role by default. You can change their role to **admin** using the role dropdown next to their name. Members are invited by email address. The person doesn't need a TaskView account yet - membership is stored against the email, so you can add someone in advance and they join the organization as soon as they sign up with that address.
|
||||
|
||||
::callout{icon="i-lucide-alert-triangle" color="warning"}
|
||||
When a project member with the **Manage users** permission invites someone into a project, that person is automatically added to the organization as a **member** - even though only admins and owners can add members directly. This is by design: a person can't be in a project without being in its organization. The auto-added member gets the minimum role and can't manage the organization.
|
||||
@@ -82,3 +82,21 @@ Organizations and projects have separate permission systems:
|
||||
Being an organization admin doesn't automatically give you permissions inside projects. You still need to be added to each project and assigned a project role. See [Roles and Permissions](/docs/collaboration/roles-and-permissions) for project-level access control.
|
||||
|
||||
The member list API endpoint is restricted to owners and admins. Regular members cannot fetch the list of organization members.
|
||||
|
||||
## Who can see which projects
|
||||
|
||||
| Who | Sees |
|
||||
|-----|------|
|
||||
| **Organization owner** | **Every project of the organization**, including projects created by other members |
|
||||
| **Organization admin** | Only the projects they were added to |
|
||||
| **Organization member** | Only the projects they were added to |
|
||||
|
||||
Projects created inside an organization belong to the organization, not to the person who created them: the organization owner is recorded as their owner. That is what keeps a project reachable when the person who created it leaves the company - nothing is lost with them. The flip side is that the owner sees every project of their organization, whoever created it.
|
||||
|
||||
Everyone else - admins included - gets access to a project only by being added to it and given a project role. Being an organization admin means administering the organization (members, settings, SSO), not its content.
|
||||
|
||||
::callout{icon="i-lucide-shield-alert" color="warning"}
|
||||
**Access is granted explicitly, never inherited from a title.** There is deliberately no "admins can see all projects" switch: if a project should be visible to someone, they get invited to it. This keeps sensitive projects - finance, HR, salaries - private by default instead of silently opening them the moment someone is promoted to admin.
|
||||
|
||||
The one exception is the organization owner, who sees everything by design (see above). If a project must stay private from the owner too, keep it in your **personal workspace** rather than in the organization.
|
||||
::
|
||||
|
||||
@@ -35,6 +35,10 @@ Go to your organization's settings → **SSO** tab. You need the **admin** or **
|
||||
| IdP Certificate | Your IdP's public signing certificate (base64, without BEGIN/END headers) |
|
||||
| ACS URL (Callback) | The URL where your IdP sends SAML responses. Shown after creating the config - copy it to your IdP |
|
||||
|
||||
::callout{icon="i-lucide-network" color="warning"}
|
||||
**Running behind a reverse proxy?** Set [`API_PUBLIC_URL`](/docs/configuration/environment-variables#application) to the public address of your API server. The ACS/Callback URL and the SCIM endpoint shown on this screen are built from it — without the variable they fall back to the address your browser used, which behind a proxy can be an internal host that your IdP cannot reach.
|
||||
::
|
||||
|
||||
**Using Metadata URL (recommended):**
|
||||
|
||||
Instead of filling fields manually, paste your IdP's metadata URL and click **Sync**. This auto-fills the IdP SSO URL, Certificate, and Logout URL from the metadata XML.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: UI Customization
|
||||
description: Personalize TaskView per user - reorder and hide task fields and analytics blocks, set the first day of week, and pick a default project and view to open right after signing in.
|
||||
navigation:
|
||||
icon: i-lucide-sliders-horizontal
|
||||
---
|
||||
|
||||
TaskView lets every user tune the interface to how they actually work. All settings on this page are personal — they are stored per user on the server and follow you across devices and browsers.
|
||||
|
||||
Open **Settings → UI customization** from the user menu.
|
||||
|
||||
## Reorder and hide items
|
||||
|
||||
Three sections are driven by drag-and-drop lists:
|
||||
|
||||
- **Tasks** — the fields shown in the task detail view (note, status, priority, assignees, tags, deadline, sprint, estimate, time tracking, history, …)
|
||||
- **Analytics — Indicators** — the KPI tiles on the analytics page
|
||||
- **Analytics — Charts** — the charts on the analytics page
|
||||
|
||||
For every item you can:
|
||||
|
||||
- **Reorder** — drag by the handle on the left
|
||||
- **Show / hide** — toggle visibility
|
||||
- **Narrow / wide** — for task fields, choose whether the field takes half or the full width of the detail view
|
||||
|
||||
All available items are listed here; permission checks still apply at render time, so an enabled item may stay hidden if you lack the permission to see it.
|
||||
|
||||
## Others
|
||||
|
||||
### First day of week
|
||||
|
||||
Sets which day calendars start on. Applies to all date pickers across the app. "Default" follows your locale.
|
||||
|
||||
### Default project and view
|
||||
|
||||
Normally, after signing in you land on the home screen and navigate to your project and board manually. If you always start in the same place, set it as the default:
|
||||
|
||||
- **Default project** — the project TaskView opens right after you sign in (or reopen the app with an active session)
|
||||
- **Default view** — which view of that project to open: **Tasks**, **Kanban**, **Graph** or **Sprints**
|
||||
|
||||
When a default project is set, a quick-jump button with the project name also appears in the sidebar next to **Inbox** — click it from anywhere to return to your project in the chosen view.
|
||||
|
||||
If the default project is deleted or you lose access to it, TaskView falls back to the home screen. Choose "Home screen (default)" to turn the feature off.
|
||||
|
||||
## How it is stored
|
||||
|
||||
Preferences are saved automatically (no Save button) through the `ui-preferences` API and kept per user account. Resetting the browser or switching devices does not lose them.
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: GitHub & GitLab Setup
|
||||
description: Connect GitHub and GitLab repositories to TaskView. Import and sync issues as tasks with OAuth authorization, webhook-based real-time updates, and AES-256 encrypted token storage. Supports GitHub Enterprise and self-hosted GitLab.
|
||||
title: GitHub, GitLab & Gitea Setup
|
||||
description: Connect GitHub, GitLab and Gitea repositories to TaskView. Import and sync issues as tasks with OAuth authorization, webhook-based real-time updates, and AES-256 encrypted token storage. Supports GitHub Enterprise, self-hosted GitLab and self-hosted Gitea.
|
||||
navigation:
|
||||
icon: i-lucide-git-pull-request
|
||||
---
|
||||
|
||||
TaskView integrations allow you to connect GitHub or GitLab repositories to your projects. After connecting, issues from the repository are synced as tasks in TaskView and kept up to date via webhooks.
|
||||
TaskView integrations allow you to connect GitHub, GitLab or Gitea repositories to your projects. After connecting, issues from the repository are synced as tasks in TaskView and kept up to date via webhooks.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -81,7 +81,29 @@ GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
|
||||
|
||||
---
|
||||
|
||||
## 5. Full `.env.taskview` Example
|
||||
## 5. Create Gitea OAuth App (optional)
|
||||
|
||||
1. On [gitea.com](https://gitea.com) (or your own instance) go to **Settings → Applications → Manage OAuth2 Applications**
|
||||
2. Click **"Create Application"**
|
||||
3. Fill in:
|
||||
- **Application Name**: `TaskView Integrations`
|
||||
- **Redirect URIs**: `http://localhost:1401/module/integrations/oauth/gitea/callback`
|
||||
4. Click **"Create Application"**
|
||||
5. Copy **Client ID** and **Client Secret**
|
||||
|
||||
Add to `.env.taskview`:
|
||||
|
||||
```
|
||||
GITEA_INTEGRATION_CLIENT_ID=<your-client-id>
|
||||
GITEA_INTEGRATION_CLIENT_SECRET=<your-client-secret>
|
||||
GITEA_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitea/callback
|
||||
```
|
||||
|
||||
> **Note**: For self-hosted Gitea, also set `GITEA_BASE_URL=https://gitea.yourcompany.com` (defaults to `https://gitea.com`). The API URL is derived as `{GITEA_BASE_URL}/api/v1`; override with `GITEA_API_URL` only if it's served from a different address.
|
||||
|
||||
---
|
||||
|
||||
## 6. Full `.env.taskview` Example
|
||||
|
||||
```env
|
||||
# ... existing vars ...
|
||||
@@ -98,16 +120,21 @@ GITHUB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
|
||||
GITLAB_INTEGRATION_CLIENT_ID=app_id_123
|
||||
GITLAB_INTEGRATION_CLIENT_SECRET=secret_123
|
||||
GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitlab/callback
|
||||
|
||||
# Gitea Integration OAuth (optional)
|
||||
GITEA_INTEGRATION_CLIENT_ID=client_id_123
|
||||
GITEA_INTEGRATION_CLIENT_SECRET=secret_123
|
||||
GITEA_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitea/callback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Usage
|
||||
## 7. Usage
|
||||
|
||||
1. Open a project in TaskView
|
||||
2. Right-click the project in the sidebar → **"Integrations"**
|
||||
3. Click **"Add Integration"**
|
||||
4. Choose **GitHub** or **GitLab** - you'll be redirected to authorize
|
||||
4. Choose **GitHub**, **GitLab** or **Gitea** - you'll be redirected to authorize
|
||||
5. After authorization, select a repository from the list
|
||||
6. Done - the integration is active
|
||||
|
||||
@@ -119,7 +146,7 @@ You can toggle integrations on/off or delete them from the integrations page.
|
||||
|
||||
- **Callback URLs**: Update to your production domain (e.g., `https://api.yourdomain.com/module/integrations/oauth/github/callback`)
|
||||
- **ENCRYPTION_KEY**: Store securely, never commit to git. If changed, existing encrypted tokens become unreadable
|
||||
- **Separate OAuth Apps**: Create new GitHub/GitLab OAuth Apps for production with production callback URLs
|
||||
- **Separate OAuth Apps**: Create new GitHub/GitLab/Gitea OAuth Apps for production with production callback URLs
|
||||
- **CORS**: Ensure your production frontend domain is in `CORS_ALLOWED_ORIGINS`
|
||||
|
||||
---
|
||||
|
||||
@@ -7,10 +7,16 @@ navigation:
|
||||
|
||||
TaskView ships an MCP (Model Context Protocol) server that lets AI assistants such as Claude Code and Claude Desktop work with your projects and tasks through the TaskView API.
|
||||
|
||||
It runs in two modes:
|
||||
|
||||
```
|
||||
AI client ──stdio──▶ taskview-mcp ──HTTPS──▶ TaskView API
|
||||
Local (stdio): AI client ──stdio──▶ taskview-mcp (npx) ──HTTPS──▶ TaskView API
|
||||
Shared (HTTP): AI client ──HTTPS──▶ taskview-mcp container ──HTTP───▶ TaskView API
|
||||
```
|
||||
|
||||
- **Local (stdio)** — each user runs the server on their machine via `npx`; the token lives in the client config.
|
||||
- **Shared (HTTP)** — one server container runs next to your TaskView instance; every user connects to its URL and authenticates with their own API token per request.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js >= 24
|
||||
@@ -40,6 +46,52 @@ No installation is required — the server runs via `npx`. Add it to your MCP cl
|
||||
|
||||
Set `TASKVIEW_URL` to your own instance when self-hosting.
|
||||
|
||||
## Shared HTTP server (self-hosted)
|
||||
|
||||
The `gimanhead/taskview-ce-mcp` image serves MCP over HTTP (Streamable HTTP transport), so users don't need Node.js or `npx` — they just point their client at a URL. Add it to your `docker-compose.yml` next to the API (see the commented block in the [installation guide](/docs/getting-started/installation)):
|
||||
|
||||
```yaml
|
||||
taskview-mcp:
|
||||
image: gimanhead/taskview-ce-mcp:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Where this MCP server forwards requests; the docker-network address
|
||||
# of your API service works best
|
||||
TASKVIEW_URL: "http://taskview-api-server:1401"
|
||||
ports:
|
||||
- "3100:3100"
|
||||
networks: [backend]
|
||||
```
|
||||
|
||||
The MCP endpoint is `/mcp` (port `3100`, configurable via `MCP_HTTP_PORT`), health check at `/health`. Every request must carry the caller's own token in the `Authorization` header — requests without it get 401. The server is stateless and keeps no data: each request is forwarded with exactly the token it came with.
|
||||
|
||||
Connect from Claude Code:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http taskview https://mcp.your-domain.com/mcp \
|
||||
--header "Authorization: Bearer tvk_your_token_here"
|
||||
```
|
||||
|
||||
or in `.mcp.json` (Claude Code, Cursor, VS Code):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"taskview": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.your-domain.com/mcp",
|
||||
"headers": { "Authorization": "Bearer tvk_your_token_here" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Put the port behind your reverse proxy with HTTPS for anything beyond local use.
|
||||
|
||||
::callout{icon="i-lucide-info" color="neutral"}
|
||||
The claude.ai and Claude Desktop **custom connectors** UI supports only OAuth-based servers and has no field for a token header — it cannot connect to this endpoint yet. Claude Desktop users should use the local stdio configuration above instead.
|
||||
::
|
||||
|
||||
## Permissions
|
||||
|
||||
The assistant can only do what the API token allows. Token permissions are scoped to selected projects and intersected with your RBAC role, so an AI client never exceeds your own access. Grant the minimum scope needed.
|
||||
|
||||
@@ -18,13 +18,27 @@ These must match your PostgreSQL setup.
|
||||
| `DB_PASSWORD` | Yes | - | Database password |
|
||||
| `DB_NAME` | Yes | - | Database name |
|
||||
| `DB_PORT` | No | `5432` | Database port |
|
||||
| `DB_POOL_MAX` | No | `20` | Maximum Postgres connections **per API worker** (each worker opens its own pool) |
|
||||
|
||||
**Connection budget:** the total number of Postgres connections is roughly `PM2_INSTANCES × DB_POOL_MAX`. Keep it below the `max_connections` of your PostgreSQL (default `100`), leaving ~10 connections of headroom for migrations and maintenance. The API logs a warning at startup when the estimate exceeds 80.
|
||||
|
||||
## Application
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `APP_PORT` | No | `1401` | Port the API server listens on |
|
||||
| `PM2_INSTANCES` | No | `2` | Number of API worker processes (PM2 cluster mode). Accepts a number or `max` (one worker per CPU core). When using `max`, set `DB_POOL_MAX` yourself so the connection budget above still fits. |
|
||||
| `APP_URL` | Yes | https://app.taskview.tech | Full URL of the web app (e.g. `https://tasks.company.com`). Used for OAuth redirects and email links. |
|
||||
| `API_PUBLIC_URL` | No | - | Public URL of the **API server** as external systems see it (e.g. `https://api.company.com`). Used to build the SSO callback/ACS URL and the SCIM endpoint shown in organization settings. Set it when the API runs behind a reverse proxy — otherwise those URLs are derived from the browser's address and may show an internal host that your IdP cannot reach. The server refuses to start if the value is not a valid http(s) URL. |
|
||||
| `TRUST_PROXY` | No | `false` | Set when running behind a reverse proxy so `X-Forwarded-Proto`/`X-Forwarded-For` are honoured (correct `https` URLs, real client IP). Use the number of proxies in front of the app (`1` for a single Caddy/nginx), or an IP/subnet list (`10.0.0.0/8`, `uniquelocal`). Leave unset for direct access. Avoid `true` (trusts any hop, allows header spoofing). |
|
||||
|
||||
## Web app
|
||||
|
||||
Unlike everything else on this page, this variable is set on the **web app container** (`taskview-webapp`), not in `.env.taskview`.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `TASKVIEW_API_URL` | No | - | Pins the API server URL for the web app (e.g. `https://api.company.com`). When set, the "Select server" section disappears from the login page and the app always talks to this API. When unset, users pick the API server on the login page themselves. |
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -34,11 +48,33 @@ These must match your PostgreSQL setup.
|
||||
| `ACCESS_LIFE_TIME` | No | `1d` | How long access tokens are valid. Examples: `1h`, `1d`, `7d` |
|
||||
| `REFRESH_LIFE_TIME` | No | `2d` | How long refresh tokens are valid |
|
||||
| `JWT_ALG` | No | `HS256` | JWT signing algorithm |
|
||||
| `AUTH_LOGIN_METHODS` | No | all enabled | Comma-separated list of login methods to offer: `magic-link`, `password`, `sso`, `social`. Disabled methods disappear from the login page and their API endpoints return 403. The API refuses to start if the list contains a typo or disables every method. |
|
||||
| `PASSWORD_CHANGE_CONFIRMATION` | No | `email` | How account password changes are confirmed: `email` — a confirmation code is sent to the user's email (requires SMTP); `password` — the user confirms with their current password (works without SMTP, recommended for installs without a mail server). |
|
||||
| `ALLOW_PUBLIC_REGISTRATION` | No | `true` | Set to `false` to close the instance: strangers can no longer create accounts — the registration endpoint returns 403, and magic-link / social sign-in stop auto-creating users. Emails invited to an organization or project can still sign in and get their account created on first login. |
|
||||
|
||||
::callout{icon="i-lucide-shield" color="warning"}
|
||||
Generate a strong JWT secret: `node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"`
|
||||
::
|
||||
|
||||
### Closing an instance: how `ALLOW_PUBLIC_REGISTRATION=false` works
|
||||
|
||||
By default anyone who can reach your instance can create an account — through the registration endpoint, or simply by entering an email on the login page (magic-link and social sign-in create the account on first login). Set `ALLOW_PUBLIC_REGISTRATION=false` to close the instance: from that moment accounts are created **by invitation only**.
|
||||
|
||||
**Who can still get an account on a closed instance.** TaskView invitations are stored by email, before any account exists. An email is considered invited — and its owner can sign in and get an account created on first login — if it appears in any of these places:
|
||||
|
||||
- **Organization members** — added via organization settings (or provisioned through SCIM)
|
||||
- **Project collaborators** — invited to a project by an existing user
|
||||
|
||||
Everyone else is rejected: the registration endpoint returns `403`, magic-link refuses to send a code to an unknown email, and social sign-in redirects back to the login page with an error. Existing accounts are not affected in any way — the flag only controls the *creation* of new ones.
|
||||
|
||||
**SSO is not blocked by this flag.** Signing in through a SAML/OIDC provider still provisions accounts, because an identity provider is configured by the administrator and is itself a controlled channel — your IdP decides who gets in.
|
||||
|
||||
**The value must be `true` or `false`.** Any other value (a typo like `Flase`, `0`, `no`) stops the server at startup with a clear error instead of silently leaving the instance open.
|
||||
|
||||
::callout{icon="i-lucide-users" color="info"}
|
||||
Note that *any* existing user can invite a collaborator to their project, and an invited email becomes eligible for an account. If your policy is stricter — "only administrators approve new accounts" — restrict who you give accounts to, since every user holds an invitation key to the instance.
|
||||
::
|
||||
|
||||
## SMTP (Email)
|
||||
|
||||
Required for password recovery, email confirmation, and invitation notifications. Without SMTP, these features won't work, but everything else functions normally.
|
||||
@@ -52,6 +88,8 @@ Required for password recovery, email confirmation, and invitation notifications
|
||||
| `SMTP_ENCRYPTION` | No | `ssl` | `ssl` or `tls` |
|
||||
| `SMTP_FROM_NAME` | No | `TaskView` | Sender name in emails |
|
||||
| `SMTP_FROM_EMAIL` | No | - | Sender email address |
|
||||
| `INVITE_EMAIL_ENABLED` | No | `false` | Set to `true` to email a person when they are invited to a project. The email is localized (English/Russian) by the inviter's browser language and links to the project. Requires SMTP; the value must be `true` or `false` — anything else stops the server at startup. |
|
||||
| `INVITE_EMAIL_HOURLY_LIMIT` | No | `30` | Maximum invite emails one user may trigger per hour. On top of this cap, the same address is never emailed about the same project more than once per 24 hours. Must be a positive integer. |
|
||||
|
||||
## Encryption
|
||||
|
||||
@@ -76,7 +114,7 @@ If you change or lose the encryption key, all stored SSO configurations and inte
|
||||
|
||||
## GitHub Integration
|
||||
|
||||
For connecting GitHub repositories. See [GitHub & GitLab Setup](/docs/integrations/setup) for a step-by-step guide.
|
||||
For connecting GitHub repositories. See [GitHub, GitLab & Gitea Setup](/docs/integrations/setup) for a step-by-step guide.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
@@ -98,6 +136,18 @@ For connecting GitLab repositories.
|
||||
| `GITLAB_BASE_URL` | No | `https://gitlab.com` | Override for self-hosted GitLab |
|
||||
| `GITLAB_API_URL` | No | `https://gitlab.com/api/v4` | Override for self-hosted GitLab API |
|
||||
|
||||
## Gitea Integration
|
||||
|
||||
For connecting Gitea repositories.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `GITEA_INTEGRATION_CLIENT_ID` | No | - | OAuth2 application client ID |
|
||||
| `GITEA_INTEGRATION_CLIENT_SECRET` | No | - | OAuth2 application client secret |
|
||||
| `GITEA_INTEGRATION_CALLBACK_URL` | No | - | OAuth callback URL |
|
||||
| `GITEA_BASE_URL` | No | `https://gitea.com` | Override for self-hosted Gitea |
|
||||
| `GITEA_API_URL` | No | `{GITEA_BASE_URL}/api/v1` | Override for self-hosted Gitea API |
|
||||
|
||||
## Messaging Integrations (Telegram / Slack)
|
||||
|
||||
For delivering task notifications to messengers. See [Telegram & Slack Setup](/docs/integrations/messaging) for a step-by-step guide.
|
||||
@@ -202,12 +252,22 @@ DB_USER="taskview_db_user"
|
||||
DB_PASSWORD="password"
|
||||
DB_NAME="taskview"
|
||||
DB_PORT=5432
|
||||
# Postgres connections per API worker (this is the default)
|
||||
DB_POOL_MAX=20
|
||||
APP_PORT=1401
|
||||
# API worker processes (this is the default). Accepts a number or "max" (one worker per CPU core).
|
||||
# Keep PM2_INSTANCES x DB_POOL_MAX below the Postgres max_connections limit (default 100).
|
||||
PM2_INSTANCES=2
|
||||
JWT_ALG="HS256"
|
||||
JWT_SIGN="secret"
|
||||
ACCESS_LIFE_TIME="3d"
|
||||
REFRESH_LIFE_TIME="9d"
|
||||
|
||||
# Login methods offered on the login page (unset = all enabled)
|
||||
#AUTH_LOGIN_METHODS="magic-link,password,sso,social"
|
||||
# Password change confirmation: "email" (code by email, needs SMTP) or "password" (no SMTP needed)
|
||||
#PASSWORD_CHANGE_CONFIRMATION="email"
|
||||
|
||||
SMTP_HOST=smtp
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
|
||||
@@ -7,6 +7,21 @@ navigation:
|
||||
|
||||
TaskView supports multiple ways to sign in - email/password, email/code, GitHub, Google, and Apple. You can enable whichever methods make sense for your team.
|
||||
|
||||
## Choosing login methods
|
||||
|
||||
By default the login page offers every method. Use the `AUTH_LOGIN_METHODS` environment variable to offer only the ones you need:
|
||||
|
||||
```env
|
||||
# Comma-separated list: magic-link, password, sso, social
|
||||
AUTH_LOGIN_METHODS="password,sso"
|
||||
```
|
||||
|
||||
Disabled methods disappear from the login page and their API endpoints return 403 — the setting is enforced server-side, not just hidden in the UI. Google/GitHub/Apple buttons are additionally shown only when the provider is actually configured, so unconfigured providers never render dead buttons.
|
||||
|
||||
::callout{icon="i-lucide-shield" color="warning"}
|
||||
The API refuses to start if `AUTH_LOGIN_METHODS` contains an unknown value or disables every method — a broken config can't silently lock everyone out.
|
||||
::
|
||||
|
||||
## Email and password
|
||||
|
||||
This is the default method and works out of the box. Users register with an email and password, and log in the same way (email conformation is required).
|
||||
@@ -14,10 +29,23 @@ This is the default method and works out of the box. Users register with an emai
|
||||
If you have SMTP configured, users will receive a confirmation email after registration.
|
||||
Without SMTP, email confirmation is skipped and accounts should be activated manually.
|
||||
|
||||
### Changing your password
|
||||
|
||||
Users can set or change their password from **Account settings → Password**. How the change is confirmed depends on the `PASSWORD_CHANGE_CONFIRMATION` environment variable:
|
||||
|
||||
- `email` (default) — a confirmation code is sent to the user's email. Requires SMTP.
|
||||
- `password` — the user confirms with their current password. No SMTP needed; recommended for installations without a mail server (password login is the only way in there, so every user knows their password).
|
||||
|
||||
After a successful change all other sessions are signed out; the current one stays active.
|
||||
|
||||
### Password recovery
|
||||
|
||||
Requires SMTP. Users click "Forgot password" on the login screen, enter their email, and receive a reset link. Without SMTP configured, password recovery is not available - you'll need to reset passwords manually in the database.
|
||||
|
||||
### The default user (self-hosted)
|
||||
|
||||
Fresh installations ship a preinstalled user (`user` / `user1!#Q`). That account gets a dedicated **Login and email** card in Account settings to claim it in one step — set your own login, email and password, confirmed by the current password, no SMTP required. See [Installation → Claim the default account](/docs/getting-started/installation#claim-the-default-account).
|
||||
|
||||
## OAuth providers
|
||||
|
||||
TaskView can use external providers for login. This is separate from the integration OAuth (which is for connecting GitHub/GitLab repositories).
|
||||
|
||||
@@ -13,9 +13,9 @@ TaskView is built for teams. You can invite people to your projects, assign them
|
||||
2. Enter the person's email address in the input field
|
||||
3. Click **Add**
|
||||
|
||||
The person needs to have a TaskView account with that email. If they don't have one yet, they'll need to register first (using the same email you invited them with).
|
||||
The person doesn't need a TaskView account yet - the invite is stored against the email address. If they already have an account, the project appears in their sidebar right away. If they don't, they simply register with that same email and find the project waiting for them.
|
||||
|
||||
Once added, they'll see the project in their sidebar and can start working immediately.
|
||||
Inviting someone into a project also adds them to the project's organization as a **member**, since a person can't be in a project without being in its organization.
|
||||
|
||||
## Removing members
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Frequently Asked Questions
|
||||
description: Common questions about TaskView - self-hosted open-source task and project management. Installation, features, security, Docker deployment, team collaboration, and more.
|
||||
description: Common questions about TaskView - self-hosted source-available task and project management. Installation, features, security, Docker deployment, team collaboration, and more.
|
||||
navigation:
|
||||
icon: i-lucide-circle-help
|
||||
---
|
||||
@@ -77,7 +77,7 @@ Yes. You can attach a monetary amount to any task and mark it as income or expen
|
||||
|
||||
### How do I invite team members?
|
||||
|
||||
Open a project, go to the Collaboration tab, and enter the person's email address. They need to have a TaskView account with that email. See [Team Members](/docs/collaboration/members).
|
||||
Open a project, go to the Collaboration tab, and enter the person's email address. They don't need a TaskView account yet - if they register later with that same email, the project is already there. See [Team Members](/docs/collaboration/members).
|
||||
|
||||
### Does TaskView have role-based access control?
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-monorepo",
|
||||
"version": "1.49.0",
|
||||
"version": "1.51.0",
|
||||
"private": true,
|
||||
"description": "TaskView CE monorepo containing web, API, and packages",
|
||||
"workspaces": [
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
android/build/
|
||||
android/.gradle/
|
||||
android/local.properties
|
||||
.swiftpm/
|
||||
ios/.build/
|
||||
DerivedData/
|
||||
@@ -0,0 +1,24 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "CapacitorWidgetBridge",
|
||||
platforms: [.iOS(.v15)],
|
||||
products: [
|
||||
.library(
|
||||
name: "CapacitorWidgetBridge",
|
||||
targets: ["WidgetBridgePlugin"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "WidgetBridgePlugin",
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm")
|
||||
],
|
||||
path: "ios/Sources/WidgetBridgePlugin")
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
ext {
|
||||
junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
|
||||
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
|
||||
}
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
android {
|
||||
namespace = "tech.taskview.plugins.widgetbridge"
|
||||
compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
|
||||
defaultConfig {
|
||||
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
|
||||
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError = false
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation project(':capacitor-android')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package tech.taskview.plugins.widgetbridge;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
@CapacitorPlugin(name = "WidgetBridge")
|
||||
public class WidgetBridgePlugin extends Plugin {
|
||||
|
||||
public static final String PREFS_NAME = "taskview_widget";
|
||||
public static final String SNAPSHOT_KEY = "widgetSnapshot";
|
||||
public static final String ACTION_WIDGET_UPDATE = "tech.taskview.widget.UPDATE";
|
||||
|
||||
@PluginMethod
|
||||
public void setSnapshot(PluginCall call) {
|
||||
String snapshot = call.getString("snapshot");
|
||||
if (snapshot == null) {
|
||||
call.reject("snapshot is required");
|
||||
return;
|
||||
}
|
||||
prefs().edit().putString(SNAPSHOT_KEY, snapshot).apply();
|
||||
notifyWidgets();
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void clearSnapshot(PluginCall call) {
|
||||
prefs().edit().remove(SNAPSHOT_KEY).apply();
|
||||
notifyWidgets();
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
private SharedPreferences prefs() {
|
||||
return getContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
private void notifyWidgets() {
|
||||
Context context = getContext();
|
||||
Intent intent = new Intent(ACTION_WIDGET_UPDATE);
|
||||
intent.setPackage(context.getPackageName());
|
||||
context.sendBroadcast(intent);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import WidgetKit
|
||||
|
||||
@objc(WidgetBridgePlugin)
|
||||
public class WidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "WidgetBridgePlugin"
|
||||
public let jsName = "WidgetBridge"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "setSnapshot", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "clearSnapshot", returnType: CAPPluginReturnPromise)
|
||||
]
|
||||
|
||||
static let snapshotKey = "widgetSnapshot"
|
||||
|
||||
private var sharedDefaults: UserDefaults? {
|
||||
guard let appGroup = getConfig().getString("appGroup") else { return nil }
|
||||
return UserDefaults(suiteName: appGroup)
|
||||
}
|
||||
|
||||
@objc func setSnapshot(_ call: CAPPluginCall) {
|
||||
guard let snapshot = call.getString("snapshot") else {
|
||||
call.reject("snapshot is required")
|
||||
return
|
||||
}
|
||||
guard let defaults = sharedDefaults else {
|
||||
call.reject("WidgetBridge appGroup is not configured in capacitor.config")
|
||||
return
|
||||
}
|
||||
defaults.set(snapshot, forKey: Self.snapshotKey)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func clearSnapshot(_ call: CAPPluginCall) {
|
||||
guard let defaults = sharedDefaults else {
|
||||
call.reject("WidgetBridge appGroup is not configured in capacitor.config")
|
||||
return
|
||||
}
|
||||
defaults.removeObject(forKey: Self.snapshotKey)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "capacitor-widget-bridge",
|
||||
"private": false,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Capacitor bridge that shares a data snapshot with native home-screen widgets (iOS WidgetKit / Android App Widgets)",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"ios",
|
||||
"android",
|
||||
"Package.swift"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"capacitor": {
|
||||
"ios": {
|
||||
"src": "ios"
|
||||
},
|
||||
"android": {
|
||||
"src": "android"
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"typescript": "~5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type WidgetSnapshotTask = {
|
||||
id: number
|
||||
title: string
|
||||
priority: 1 | 2 | 3
|
||||
overdue: boolean
|
||||
endTime: string | null
|
||||
endDate: string | null
|
||||
path: string
|
||||
}
|
||||
|
||||
export type WidgetSnapshotMode = 'today' | 'upcoming'
|
||||
|
||||
export type WidgetSnapshot = {
|
||||
v: 3
|
||||
generatedAt: string
|
||||
locale: string
|
||||
orgSlug: string | null
|
||||
mode: WidgetSnapshotMode
|
||||
todayCount: number
|
||||
overdueCount: number
|
||||
upcomingCount: number
|
||||
tasks: WidgetSnapshotTask[]
|
||||
}
|
||||
|
||||
export type SetSnapshotOptions = {
|
||||
snapshot: string
|
||||
}
|
||||
|
||||
export type WidgetBridgePlugin = {
|
||||
setSnapshot(options: SetSnapshotOptions): Promise<void>
|
||||
clearSnapshot(): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { registerPlugin } from '@capacitor/core'
|
||||
import type { WidgetBridgePlugin } from './definitions'
|
||||
|
||||
export const WidgetBridge = registerPlugin<WidgetBridgePlugin>('WidgetBridge')
|
||||
|
||||
export type {
|
||||
WidgetBridgePlugin,
|
||||
WidgetSnapshot,
|
||||
WidgetSnapshotMode,
|
||||
WidgetSnapshotTask,
|
||||
SetSnapshotOptions,
|
||||
} from './definitions'
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2020"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"noEmit": false,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { TvApi } from '@/tv'
|
||||
import { TvPermissions } from '@/api/permissions'
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { initApi } from './init-api'
|
||||
|
||||
describe('Collaboration roles access control', () => {
|
||||
let user1Api: TvApi
|
||||
let user2Api: TvApi
|
||||
let user2Email: string
|
||||
let deleteAllGoals: () => Promise<void>
|
||||
let manageUsersPermissionId: number
|
||||
|
||||
beforeAll(async () => {
|
||||
const init = await initApi()
|
||||
user1Api = init.$tvApi
|
||||
user2Api = init.$tvApiForSecondUser
|
||||
user2Email = init.user2Email
|
||||
deleteAllGoals = init.deleteAllGoals
|
||||
|
||||
const allPermissions = await user1Api.collaboration.fetchAllPermissions()
|
||||
const found = allPermissions.find(p => p.name === TvPermissions.GOAL_CAN_MANAGE_USERS)
|
||||
if (!found) throw new Error('Permission "goal_can_manage_users" is not in DB')
|
||||
manageUsersPermissionId = found.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteAllGoals()
|
||||
})
|
||||
|
||||
async function expectHttpStatus<T>(promise: Promise<T>, status: number): Promise<void> {
|
||||
try {
|
||||
await promise
|
||||
throw new Error(`Expected HTTP ${status} but request succeeded`)
|
||||
} catch (e: any) {
|
||||
if (typeof e.message === 'string' && e.message.startsWith('Expected HTTP')) throw e
|
||||
expect(e.response?.status, `Expected ${status}, got ${e.response?.status}`).toBe(status)
|
||||
}
|
||||
}
|
||||
|
||||
async function createGoalWithUser2(grantManageUsers: boolean) {
|
||||
const goal = await user1Api.goals.createGoal({ name: `Roles access ${Date.now()}` })
|
||||
if (!goal) throw new Error('Failed to create goal')
|
||||
|
||||
const collab = await user1Api.collaboration.inviteUserToGoal({ email: user2Email, goalId: goal.id })
|
||||
if (!collab) throw new Error('Failed to invite user2')
|
||||
|
||||
const role = await user1Api.collaboration.createRoleForGoal({
|
||||
goalId: goal.id,
|
||||
roleName: `Manager ${Date.now()}`,
|
||||
})
|
||||
if (!role) throw new Error('Failed to create role')
|
||||
|
||||
if (grantManageUsers) {
|
||||
const toggled = await user1Api.collaboration.toggleRolePermission({
|
||||
roleId: role.id,
|
||||
permissionId: manageUsersPermissionId,
|
||||
})
|
||||
if (!toggled || toggled.add !== true) {
|
||||
throw new Error(`Expected goal_can_manage_users to be added, got ${JSON.stringify(toggled)}`)
|
||||
}
|
||||
}
|
||||
|
||||
await user1Api.collaboration.toggleUserRoles({
|
||||
goalId: goal.id,
|
||||
userId: collab.id,
|
||||
roles: [role.id],
|
||||
})
|
||||
|
||||
return { goal, role }
|
||||
}
|
||||
|
||||
it('collaborator with goal_can_manage_users can read the role-to-permission matrix', async () => {
|
||||
const { goal, role } = await createGoalWithUser2(true)
|
||||
|
||||
const matrix = await user2Api.collaboration.fetchRoleToPermissionsForGoal(goal.id)
|
||||
expect(matrix).toBeDefined()
|
||||
// the granted permission is visible in the matrix of the role user2 holds
|
||||
expect(matrix?.some(row => row.roleId === role.id && row.permissionId === manageUsersPermissionId)).toBe(true)
|
||||
})
|
||||
|
||||
it('collaborator without goal_can_manage_users cannot read the matrix', async () => {
|
||||
const { goal } = await createGoalWithUser2(false)
|
||||
|
||||
await expectHttpStatus(user2Api.collaboration.fetchRoleToPermissionsForGoal(goal.id), 403)
|
||||
})
|
||||
|
||||
it('changing role permissions stays owner-only', async () => {
|
||||
const { goal, role } = await createGoalWithUser2(true)
|
||||
|
||||
// reading is allowed...
|
||||
const matrix = await user2Api.collaboration.fetchRoleToPermissionsForGoal(goal.id)
|
||||
expect(matrix).toBeDefined()
|
||||
|
||||
// ...but editing the matrix is not
|
||||
await expectHttpStatus(
|
||||
user2Api.collaboration.toggleRolePermission({ roleId: role.id, permissionId: manageUsersPermissionId }),
|
||||
403,
|
||||
)
|
||||
|
||||
// owner still can edit
|
||||
const ownerToggle = await user1Api.collaboration.toggleRolePermission({
|
||||
roleId: role.id,
|
||||
permissionId: manageUsersPermissionId,
|
||||
})
|
||||
expect(ownerToggle?.add).toBe(false)
|
||||
|
||||
await expect(user1Api.goals.deleteGoal(goal.id)).resolves.toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -308,6 +308,127 @@ describe('Collaboration', () => {
|
||||
|
||||
});
|
||||
|
||||
// Regression for #98, the reporter's exact path: an org admin who is a collaborator
|
||||
// in several projects created a new project in that org and lost every role he had.
|
||||
// The trigger is creating a goal in an organization owned by SOMEONE ELSE - only then
|
||||
// does the API add the creator as a collaborator, which used to wipe his other roles.
|
||||
it('an org member creating a project in that org keeps his roles in other projects', async () => {
|
||||
const { $tvApiForSecondUser, user2Email } = await initApi();
|
||||
|
||||
const org = await $api.organizations.create({ name: `Roles org ${Date.now()}` });
|
||||
expect(org?.id).toBeDefined();
|
||||
|
||||
// the reporter's case: the second user is an ADMIN of the organization
|
||||
await $api.organizations.addMember({
|
||||
organizationId: org.id,
|
||||
email: user2Email,
|
||||
role: 'admin',
|
||||
});
|
||||
|
||||
// owner creates two projects in the org and gives the admin roles in both
|
||||
const projectA = await $api.goals.createGoal({ name: 'Org project A', organizationId: org.id });
|
||||
const projectB = await $api.goals.createGoal({ name: 'Org project B', organizationId: org.id });
|
||||
expect(projectA?.id).toBeDefined();
|
||||
expect(projectB?.id).toBeDefined();
|
||||
|
||||
const collabA = await $api.collaboration.inviteUserToGoal({ goalId: projectA!.id, email: user2Email });
|
||||
const collabB = await $api.collaboration.inviteUserToGoal({ goalId: projectB!.id, email: user2Email });
|
||||
expect(collabA?.id).toBeDefined();
|
||||
expect(collabB?.id).toEqual(collabA?.id);
|
||||
|
||||
const editorA = (await $api.collaboration.fetchRolesForGoal(projectA!.id))?.find((r) => r.name === 'editor');
|
||||
const editorB = (await $api.collaboration.fetchRolesForGoal(projectB!.id))?.find((r) => r.name === 'editor');
|
||||
expect(editorA?.id).toBeDefined();
|
||||
expect(editorB?.id).toBeDefined();
|
||||
|
||||
await $api.collaboration.toggleUserRoles({
|
||||
goalId: projectA!.id,
|
||||
userId: collabA!.id,
|
||||
roles: [editorA!.id],
|
||||
});
|
||||
await $api.collaboration.toggleUserRoles({
|
||||
goalId: projectB!.id,
|
||||
userId: collabA!.id,
|
||||
roles: [editorB!.id],
|
||||
});
|
||||
|
||||
// ...the admin now creates his own project INSIDE the owner's organization
|
||||
const ownProject = await $tvApiForSecondUser.goals.createGoal({
|
||||
name: 'Project created by the org admin',
|
||||
organizationId: org.id,
|
||||
});
|
||||
expect(ownProject?.id).toBeDefined();
|
||||
|
||||
// roles in the pre-existing projects must survive
|
||||
const usersA = await $api.collaboration.fetchUsersForGoal(projectA!.id);
|
||||
const usersB = await $api.collaboration.fetchUsersForGoal(projectB!.id);
|
||||
expect(usersA?.find((u) => u.email === user2Email)?.roles).toEqual([editorA!.id]);
|
||||
expect(usersB?.find((u) => u.email === user2Email)?.roles).toEqual([editorB!.id]);
|
||||
|
||||
// and the new project is still usable by its creator
|
||||
const ownGoals = await $tvApiForSecondUser.goals.fetchGoals(org.id);
|
||||
expect(ownGoals?.some((g) => g.id === ownProject!.id)).toBe(true);
|
||||
|
||||
await $api.organizations.delete(org.id).catch(() => { });
|
||||
});
|
||||
|
||||
// Regression for #98: toggling roles in one goal wiped the user's roles in every other goal
|
||||
it('toggling roles in one goal must not touch the same user roles in another goal', async () => {
|
||||
const email = `multi-goal-${Date.now()}@fff.com`;
|
||||
|
||||
const goalA = await $api.goals.createGoal({ name: 'Roles isolation goal A' });
|
||||
const goalB = await $api.goals.createGoal({ name: 'Roles isolation goal B' });
|
||||
expect(goalA).toBeTruthy();
|
||||
expect(goalB).toBeTruthy();
|
||||
|
||||
const userInA = await $api.collaboration.inviteUserToGoal({ goalId: goalA!.id, email });
|
||||
const userInB = await $api.collaboration.inviteUserToGoal({ goalId: goalB!.id, email });
|
||||
expect(userInA?.id).toBeDefined();
|
||||
// the same collaboration user is shared between goals
|
||||
expect(userInB?.id).toEqual(userInA?.id);
|
||||
|
||||
const rolesA = await $api.collaboration.fetchRolesForGoal(goalA!.id);
|
||||
const rolesB = await $api.collaboration.fetchRolesForGoal(goalB!.id);
|
||||
const editorA = rolesA?.find((r) => r.name === 'editor');
|
||||
const editorB = rolesB?.find((r) => r.name === 'editor');
|
||||
expect(editorA?.id).toBeDefined();
|
||||
expect(editorB?.id).toBeDefined();
|
||||
|
||||
// assign a role in goal B first
|
||||
const toggledB = await $api.collaboration.toggleUserRoles({
|
||||
goalId: goalB!.id,
|
||||
userId: userInA?.id!,
|
||||
roles: [editorB?.id!],
|
||||
});
|
||||
expect(toggledB).toEqual([editorB?.id!]);
|
||||
|
||||
// toggling roles in goal A must not clear the role in goal B
|
||||
const toggledA = await $api.collaboration.toggleUserRoles({
|
||||
goalId: goalA!.id,
|
||||
userId: userInA?.id!,
|
||||
roles: [editorA?.id!],
|
||||
});
|
||||
expect(toggledA).toEqual([editorA?.id!]);
|
||||
|
||||
const usersInB = await $api.collaboration.fetchUsersForGoal(goalB!.id);
|
||||
expect(usersInB?.find((u) => u.email === email)?.roles).toEqual([editorB?.id!]);
|
||||
|
||||
// each goal's collaborator list shows only that goal's roles
|
||||
const usersInA = await $api.collaboration.fetchUsersForGoal(goalA!.id);
|
||||
expect(usersInA?.find((u) => u.email === email)?.roles).toEqual([editorA?.id!]);
|
||||
|
||||
// a role id belonging to another goal must not be assignable through this goal
|
||||
const toggledForeign = await $api.collaboration.toggleUserRoles({
|
||||
goalId: goalA!.id,
|
||||
userId: userInA?.id!,
|
||||
roles: [editorB?.id!],
|
||||
}).catch(() => null);
|
||||
expect(toggledForeign ?? []).toEqual([]);
|
||||
|
||||
const usersInB2 = await $api.collaboration.fetchUsersForGoal(goalB!.id);
|
||||
expect(usersInB2?.find((u) => u.email === email)?.roles).toEqual([editorB?.id!]);
|
||||
});
|
||||
|
||||
it('should handle inviting already existing collaborator', async () => {
|
||||
const addResult1 = await $api.collaboration.inviteUserToGoal({
|
||||
goalId: collaborationGoal?.id!,
|
||||
|
||||
@@ -32,6 +32,14 @@ services:
|
||||
condition: service_completed_successfully
|
||||
env_file:
|
||||
- .env.taskview
|
||||
environment:
|
||||
# The test suite runs against a closed instance (see registration-flag.test.ts);
|
||||
# no other test creates accounts through public registration paths.
|
||||
ALLOW_PUBLIC_REGISTRATION: "false"
|
||||
# IdP-facing URLs are built from this base (see sso-public-urls.test.ts)
|
||||
API_PUBLIC_URL: "https://api.public.example"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -so /dev/null http://localhost:1401/ || exit 1"]
|
||||
interval: 3s
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { TvApi } from '@/tv'
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
} from 'vitest'
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
import { initApi, API_URL, DEFAULT_USER, DEFAULT_PASSWORD } from './init-api'
|
||||
import { ymd } from './test-helpers'
|
||||
import type { RecurrenceRuleDetails } from '@/api/recurrence.types'
|
||||
|
||||
/**
|
||||
* Integration tests for the 'after-completion' schedule mode.
|
||||
*
|
||||
* Unlike the fixed mode (calendar grid anchored to dtstart), an
|
||||
* after-completion series has no calendar anchor: the next instance date is
|
||||
* exactly one FREQ/INTERVAL step after max(lastInstanceDate, today) — dates
|
||||
* stay strictly increasing even when the card is completed early, and BYDAY /
|
||||
* BYMONTHDAY have no defined meaning and are rejected.
|
||||
*
|
||||
* Same deterministic frame as recurrence.test.ts: Europe/Moscow (fixed UTC+3),
|
||||
* 10:45 wall-clock → 07:45:00 UTC stored.
|
||||
*/
|
||||
describe('Recurrence (after-completion)', () => {
|
||||
let $api: TvApi
|
||||
let raw: AxiosInstance
|
||||
let goalId: number
|
||||
|
||||
const MSK_TIME = 'T10:45:00'
|
||||
const UTC_TIME = '07:45:00'
|
||||
|
||||
beforeAll(async () => {
|
||||
const { $tvApi } = await initApi()
|
||||
$api = $tvApi
|
||||
|
||||
const auth = await axios.post(`${API_URL}/module/auth/login`, {
|
||||
login: DEFAULT_USER,
|
||||
password: DEFAULT_PASSWORD,
|
||||
})
|
||||
raw = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${auth.data.access}` },
|
||||
validateStatus: () => true,
|
||||
})
|
||||
|
||||
const goal = await $api.goals.createGoal({ name: `After-completion test project-${Date.now()}` })
|
||||
if (!goal) throw new Error('Failed to create goal')
|
||||
goalId = goal.id!
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await $api.goals.deleteGoal(goalId).catch(() => {})
|
||||
})
|
||||
|
||||
async function createTask(description: string, startDate = ymd(3)) {
|
||||
const task = await $api.tasks.createTask({
|
||||
goalId,
|
||||
description,
|
||||
startDate,
|
||||
startTime: UTC_TIME,
|
||||
endDate: startDate,
|
||||
endTime: '08:45:00',
|
||||
})
|
||||
if (!task) throw new Error('Failed to create task')
|
||||
return task
|
||||
}
|
||||
|
||||
async function createAcRule(taskId: number, rrule: string, startDate = ymd(3)) {
|
||||
return await $api.recurrence.create({
|
||||
taskId,
|
||||
rrule,
|
||||
dtstart: `${startDate}${MSK_TIME}`,
|
||||
timezone: 'Europe/Moscow',
|
||||
scheduleMode: 'after-completion',
|
||||
})
|
||||
}
|
||||
|
||||
/** Last day of the month `monthsAhead` months from now ('YYYY-MM-DD', UTC). */
|
||||
function lastDayOfMonth(monthsAhead: number): string {
|
||||
const now = new Date()
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + monthsAhead + 1, 0)).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** `iso` plus `months` calendar months, day clamped to the target month's length (luxon semantics). */
|
||||
function addMonthsClamped(iso: string, months: number): string {
|
||||
const [y, m, d] = iso.split('-').map(Number)
|
||||
const lastDay = new Date(Date.UTC(y, m - 1 + months + 1, 0)).getUTCDate()
|
||||
return new Date(Date.UTC(y, m - 1 + months, Math.min(d, lastDay))).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** Polls the rule details until the predicate holds (materialization is async). */
|
||||
async function waitFor(
|
||||
ruleId: number,
|
||||
predicate: (details: RecurrenceRuleDetails) => boolean,
|
||||
timeoutMs = 8000,
|
||||
): Promise<RecurrenceRuleDetails> {
|
||||
const startedAt = Date.now()
|
||||
for (;;) {
|
||||
const details = await $api.recurrence.getById(ruleId).catch(() => null)
|
||||
if (details && predicate(details)) return details
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
throw new Error(`waitFor timed out for rule ${ruleId}: ${JSON.stringify(details)?.slice(0, 300)}`)
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
}
|
||||
}
|
||||
|
||||
describe('validation', () => {
|
||||
const base = { dtstart: `${ymd(3)}${MSK_TIME}`, timezone: 'Europe/Moscow', scheduleMode: 'after-completion' }
|
||||
|
||||
it('rejects an unknown scheduleMode value (request shape, 400)', async () => {
|
||||
const task = await createTask('Bad mode target')
|
||||
const res = await raw.post('/module/recurrence', { taskId: task.id, rrule: 'FREQ=DAILY', ...base, scheduleMode: 'whenever' })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects an update that leaves no next step (UNTIL in the past)', async () => {
|
||||
const task = await createTask('Dead-end update')
|
||||
const rule = await createAcRule(task.id, 'FREQ=DAILY')
|
||||
const res = await raw.patch(`/module/recurrence/${rule.id}`, { rrule: 'FREQ=DAILY;UNTIL=20000101T000000Z' })
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('rejects BYDAY on create (no calendar anchor in this mode)', async () => {
|
||||
const task = await createTask('BYDAY target')
|
||||
const res = await raw.post('/module/recurrence', { taskId: task.id, rrule: 'FREQ=WEEKLY;BYDAY=MO', ...base })
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('rejects BYMONTHDAY on create', async () => {
|
||||
const task = await createTask('BYMONTHDAY target')
|
||||
const res = await raw.post('/module/recurrence', { taskId: task.id, rrule: 'FREQ=MONTHLY;BYMONTHDAY=-1', ...base })
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('rejects switching a BYDAY fixed rule to after-completion', async () => {
|
||||
const task = await createTask('Anchored fixed')
|
||||
const rule = await $api.recurrence.create({
|
||||
taskId: task.id,
|
||||
rrule: 'FREQ=WEEKLY;BYDAY=MO,TH',
|
||||
dtstart: `${ymd(3)}${MSK_TIME}`,
|
||||
timezone: 'Europe/Moscow',
|
||||
})
|
||||
const res = await raw.patch(`/module/recurrence/${rule.id}`, { scheduleMode: 'after-completion' })
|
||||
expect(res.status).toBe(422)
|
||||
|
||||
const intact = await $api.recurrence.getById(rule.id)
|
||||
expect(intact?.rule.scheduleMode).toBe('fixed')
|
||||
})
|
||||
|
||||
it('rejects updating the rrule to BYDAY while the mode stays after-completion', async () => {
|
||||
const task = await createTask('Stays unanchored')
|
||||
const rule = await createAcRule(task.id, 'FREQ=DAILY')
|
||||
const res = await raw.patch(`/module/recurrence/${rule.id}`, { rrule: 'FREQ=WEEKLY;BYDAY=FR' })
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('allows BYDAY when the same PATCH switches the rule back to fixed', async () => {
|
||||
const task = await createTask('Mode and rrule together')
|
||||
const rule = await createAcRule(task.id, 'FREQ=DAILY')
|
||||
// validation must run against the NEW mode, not the stored one
|
||||
const res = await raw.patch(`/module/recurrence/${rule.id}`, {
|
||||
scheduleMode: 'fixed',
|
||||
rrule: 'FREQ=WEEKLY;BYDAY=MO',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const updated = await $api.recurrence.getById(rule.id)
|
||||
expect(updated?.rule.scheduleMode).toBe('fixed')
|
||||
expect(updated?.rule.rrule).toContain('BYDAY=MO')
|
||||
})
|
||||
})
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('a rule created without scheduleMode defaults to fixed', async () => {
|
||||
const task = await createTask('Default mode')
|
||||
const rule = await $api.recurrence.create({
|
||||
taskId: task.id,
|
||||
rrule: 'FREQ=DAILY',
|
||||
dtstart: `${ymd(3)}${MSK_TIME}`,
|
||||
timezone: 'Europe/Moscow',
|
||||
})
|
||||
expect(rule.scheduleMode).toBe('fixed')
|
||||
})
|
||||
|
||||
it('origin task becomes the open instance, mode carried in rule and details', async () => {
|
||||
const task = await createTask('AC standup')
|
||||
const rule = await createAcRule(task.id, 'FREQ=DAILY')
|
||||
|
||||
expect(rule.scheduleMode).toBe('after-completion')
|
||||
expect(rule.state).toBe('active')
|
||||
expect(rule.instancesCreated).toBe(1)
|
||||
|
||||
const details = await $api.recurrence.getForTask(task.id)
|
||||
expect(details?.rule.scheduleMode).toBe('after-completion')
|
||||
expect(details?.openInstance?.id).toBe(task.id)
|
||||
expect(details?.openInstance?.recurrenceInstanceDate).toBe(ymd(3))
|
||||
expect(details?.openInstance?.startTime).toBe(UTC_TIME)
|
||||
})
|
||||
|
||||
it('daily: completing steps one day past the scheduled date, even when completed early', async () => {
|
||||
const task = await createTask('AC daily')
|
||||
const rule = await createAcRule(task.id, 'FREQ=DAILY')
|
||||
|
||||
// completed today, 3 days ahead of schedule — the next date steps from
|
||||
// the scheduled day (max(lastInstanceDate, today)), NOT from today,
|
||||
// so instance dates stay strictly increasing
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
|
||||
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
|
||||
expect(details.openInstance?.recurrenceInstanceDate).toBe(ymd(4))
|
||||
expect(details.openInstance?.startTime).toBe(UTC_TIME)
|
||||
expect(details.openInstance?.complete).toBe(false)
|
||||
expect(details.openInstance?.description).toBe('AC daily')
|
||||
expect(details.rule.instancesCreated).toBe(2)
|
||||
})
|
||||
|
||||
it('INTERVAL is respected: every 3 days lands 3 days after the scheduled date', async () => {
|
||||
const task = await createTask('AC every 3 days')
|
||||
const rule = await createAcRule(task.id, 'FREQ=DAILY;INTERVAL=3')
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
|
||||
expect(details.openInstance?.recurrenceInstanceDate).toBe(ymd(6))
|
||||
})
|
||||
|
||||
it('weekly: exactly +7 days, no weekday grid (contrast with fixed BYDAY)', async () => {
|
||||
const task = await createTask('AC weekly')
|
||||
const rule = await createAcRule(task.id, 'FREQ=WEEKLY')
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
|
||||
expect(details.openInstance?.recurrenceInstanceDate).toBe(ymd(10))
|
||||
})
|
||||
|
||||
it('monthly: the step clamps to the last valid day and does not re-anchor to month end', async () => {
|
||||
const start = lastDayOfMonth(1)
|
||||
const task = await createTask('AC monthly close', start)
|
||||
const rule = await createAcRule(task.id, 'FREQ=MONTHLY', start)
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const first = addMonthsClamped(start, 1)
|
||||
const second = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
|
||||
// clamped calendar step, NOT a month-end anchor: from a 31-day month end
|
||||
// it lands on the 30th of a 30-day month
|
||||
expect(second.openInstance?.recurrenceInstanceDate).toBe(first)
|
||||
|
||||
// the clamped day is what steps forward: Aug 31 → Sep 30 → Oct 30
|
||||
// (fixed BYMONTHDAY=-1 would re-anchor to Oct 31)
|
||||
await $api.tasks.updateTask({ id: second.openInstance!.id, complete: true })
|
||||
const third = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== second.openInstance!.id)
|
||||
expect(third.openInstance?.recurrenceInstanceDate).toBe(addMonthsClamped(first, 1))
|
||||
})
|
||||
|
||||
it('yearly: exactly +1 year from the scheduled date', async () => {
|
||||
const task = await createTask('AC yearly review')
|
||||
const rule = await createAcRule(task.id, 'FREQ=YEARLY')
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
|
||||
expect(details.openInstance?.recurrenceInstanceDate).toBe(addMonthsClamped(ymd(3), 12))
|
||||
})
|
||||
|
||||
it('COUNT=1: completing the origin ends the series without a successor', async () => {
|
||||
const task = await createTask('AC one-shot')
|
||||
const rule = await createAcRule(task.id, 'FREQ=DAILY;COUNT=1')
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const ended = await waitFor(rule.id, (d) => d.rule.state === 'ended')
|
||||
expect(ended.openInstance).toBeNull()
|
||||
expect(ended.rule.instancesCreated).toBe(1)
|
||||
})
|
||||
|
||||
it('pause blocks the completion step; resume materializes it', async () => {
|
||||
const task = await createTask('AC pausable')
|
||||
const rule = await createAcRule(task.id, 'FREQ=DAILY')
|
||||
|
||||
await $api.recurrence.pause(rule.id)
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
const whilePaused = await $api.recurrence.getById(rule.id)
|
||||
expect(whilePaused?.openInstance).toBeNull()
|
||||
expect(whilePaused?.rule.instancesCreated).toBe(1)
|
||||
|
||||
await $api.recurrence.resume(rule.id)
|
||||
const restored = await waitFor(rule.id, (d) => !!d.openInstance)
|
||||
expect(restored.openInstance?.recurrenceInstanceDate).toBe(ymd(4))
|
||||
})
|
||||
|
||||
it('a COUNT-limited series ends after the last instance is completed', async () => {
|
||||
const task = await createTask('AC twice and done')
|
||||
const rule = await createAcRule(task.id, 'FREQ=DAILY;COUNT=2')
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const second = await waitFor(rule.id, (d) => d.rule.instancesCreated === 2)
|
||||
expect(second.openInstance?.recurrenceInstanceDate).toBe(ymd(4))
|
||||
|
||||
await $api.tasks.updateTask({ id: second.openInstance!.id, complete: true })
|
||||
const ended = await waitFor(rule.id, (d) => d.rule.state === 'ended')
|
||||
expect(ended.openInstance).toBeNull()
|
||||
expect(ended.rule.instancesCreated).toBe(2)
|
||||
})
|
||||
|
||||
it('an UNTIL-bounded series ends once the next step lands past the boundary', async () => {
|
||||
const task = await createTask('AC until')
|
||||
const until = `${ymd(4).replace(/-/g, '')}T235959Z`
|
||||
const rule = await createAcRule(task.id, `FREQ=DAILY;UNTIL=${until}`)
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const second = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
|
||||
expect(second.openInstance?.recurrenceInstanceDate).toBe(ymd(4))
|
||||
|
||||
// next step would be ymd(5) > UNTIL — the series is over
|
||||
await $api.tasks.updateTask({ id: second.openInstance!.id, complete: true })
|
||||
const ended = await waitFor(rule.id, (d) => d.rule.state === 'ended')
|
||||
expect(ended.openInstance).toBeNull()
|
||||
})
|
||||
|
||||
it('skip jumps the card one interval step and records the skipped date', async () => {
|
||||
const task = await createTask('AC skippable')
|
||||
const rule = await createAcRule(task.id, 'FREQ=DAILY')
|
||||
|
||||
const details = await $api.recurrence.skip(rule.id)
|
||||
expect(details.skipDates).toContain(ymd(3))
|
||||
expect(details.openInstance?.recurrenceInstanceDate).toBe(ymd(4))
|
||||
expect(details.rule.instancesCreated).toBe(2)
|
||||
})
|
||||
|
||||
it('switching a live fixed series to after-completion takes effect on the next completion', async () => {
|
||||
const task = await createTask('Mode switch mid-series')
|
||||
const rule = await $api.recurrence.create({
|
||||
taskId: task.id,
|
||||
rrule: 'FREQ=DAILY;INTERVAL=3',
|
||||
dtstart: `${ymd(3)}${MSK_TIME}`,
|
||||
timezone: 'Europe/Moscow',
|
||||
})
|
||||
|
||||
const switched = await $api.recurrence.update({ ruleId: rule.id, scheduleMode: 'after-completion' })
|
||||
expect(switched.scheduleMode).toBe('after-completion')
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
|
||||
expect(details.openInstance?.recurrenceInstanceDate).toBe(ymd(6))
|
||||
expect(details.rule.scheduleMode).toBe('after-completion')
|
||||
})
|
||||
|
||||
it('a date-only after-completion series stays date-only on the next instance', async () => {
|
||||
const task = await $api.tasks.createTask({ goalId, description: 'AC date-only', startDate: ymd(3) })
|
||||
const rule = await $api.recurrence.create({
|
||||
taskId: task!.id,
|
||||
rrule: 'FREQ=DAILY',
|
||||
dtstart: ymd(3),
|
||||
timezone: 'Europe/Moscow',
|
||||
scheduleMode: 'after-completion',
|
||||
})
|
||||
expect(rule.hasTime).toBe(false)
|
||||
|
||||
await $api.tasks.updateTask({ id: task!.id, complete: true })
|
||||
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task!.id)
|
||||
expect(details.openInstance?.startDate).toBe(ymd(4))
|
||||
expect(details.openInstance?.endDate).toBe(ymd(4))
|
||||
expect(details.openInstance?.startTime).toBeNull()
|
||||
expect(details.openInstance?.endTime).toBeNull()
|
||||
})
|
||||
|
||||
it('template overrides apply to the next materialized instance', async () => {
|
||||
const task = await createTask('AC old name')
|
||||
const rule = await createAcRule(task.id, 'FREQ=DAILY')
|
||||
|
||||
await $api.recurrence.update({
|
||||
ruleId: rule.id,
|
||||
templateOverrides: { description: 'AC new name', priorityId: 3 },
|
||||
})
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
|
||||
expect(details.openInstance?.description).toBe('AC new name')
|
||||
expect(details.openInstance?.priorityId).toBe(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import axios from 'axios'
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { TvApi } from '@/tv'
|
||||
import { API_URL, DEFAULT_USER, DEFAULT_PASSWORD, initApi } from './init-api'
|
||||
|
||||
// The test stack runs the API with ALLOW_PUBLIC_REGISTRATION=false
|
||||
// (set in docker/docker-compose.yml), so this suite asserts the
|
||||
// closed-instance contract: strangers cannot create accounts through
|
||||
// any public path, invited emails and existing users keep working.
|
||||
|
||||
const strangerEmail = `stranger-${Date.now()}@closed.example`
|
||||
const invitedEmail = `invited-${Date.now()}@closed.example`
|
||||
|
||||
let user1Api: TvApi
|
||||
let testOrgId: number
|
||||
|
||||
beforeAll(async () => {
|
||||
const init = await initApi()
|
||||
user1Api = init.$tvApi
|
||||
|
||||
const org = await user1Api.organizations.create({ name: 'Closed Instance Org' })
|
||||
testOrgId = org.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await user1Api.organizations.delete(testOrgId).catch(() => {})
|
||||
})
|
||||
|
||||
describe('ALLOW_PUBLIC_REGISTRATION=false: closed instance', () => {
|
||||
it('exposes publicRegistration=false in login options', async () => {
|
||||
const res = await axios.get(`${API_URL}/module/auth/login-options`)
|
||||
expect(res.data.publicRegistration).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects the registration endpoint for a stranger email', async () => {
|
||||
const res = await axios.post(
|
||||
`${API_URL}/module/auth/registration`,
|
||||
{ email: strangerEmail, password: DEFAULT_PASSWORD, passwordRepeat: DEFAULT_PASSWORD },
|
||||
{ validateStatus: () => true }
|
||||
)
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
expect(res.data.registrationDisabled).toBe(true)
|
||||
})
|
||||
|
||||
it('magic-link refuses to create an account for a stranger email', async () => {
|
||||
const res = await axios.post(
|
||||
`${API_URL}/module/auth/send-login-code`,
|
||||
{ email: strangerEmail },
|
||||
{ validateStatus: () => true }
|
||||
)
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
expect(res.data.registrationDisabled).toBe(true)
|
||||
})
|
||||
|
||||
it('a stranger email did not get an account (login by password fails)', async () => {
|
||||
const res = await axios.post(
|
||||
`${API_URL}/module/auth/login`,
|
||||
{ login: strangerEmail, password: DEFAULT_PASSWORD },
|
||||
{ validateStatus: () => true }
|
||||
)
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(400)
|
||||
})
|
||||
|
||||
it('an email invited to an organization can request a login code', async () => {
|
||||
const member = await user1Api.organizations.addMember({
|
||||
organizationId: testOrgId,
|
||||
email: invitedEmail,
|
||||
role: 'member',
|
||||
})
|
||||
expect(member).toBeTruthy()
|
||||
|
||||
const res = await axios.post(`${API_URL}/module/auth/send-login-code`, {
|
||||
email: invitedEmail,
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('invited email got a real account visible to the org owner', async () => {
|
||||
const members = await user1Api.organizations.fetchMembers(testOrgId)
|
||||
const invited = members.find((m: { email: string }) => m.email === invitedEmail)
|
||||
expect(invited).toBeTruthy()
|
||||
})
|
||||
|
||||
it('existing users still sign in normally', async () => {
|
||||
const res = await axios.post(`${API_URL}/module/auth/login`, {
|
||||
login: DEFAULT_USER,
|
||||
password: DEFAULT_PASSWORD,
|
||||
})
|
||||
|
||||
expect(res.data.access).toBeTruthy()
|
||||
expect(res.data.refresh).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import { TvApi } from '@/tv'
|
||||
import { initApi } from './init-api'
|
||||
|
||||
// The test stack runs the API with API_PUBLIC_URL=https://api.public.example
|
||||
// (set in docker/docker-compose.yml). The URLs an admin copies into an IdP
|
||||
// must be built from that base — not from whatever host the request came in on.
|
||||
|
||||
let user1Api: TvApi
|
||||
|
||||
beforeAll(async () => {
|
||||
const init = await initApi()
|
||||
user1Api = init.$tvApi
|
||||
})
|
||||
|
||||
describe('SSO public URLs (API_PUBLIC_URL)', () => {
|
||||
it('builds IdP-facing URLs from API_PUBLIC_URL, not from the request host', async () => {
|
||||
const urls = await user1Api.sso.getPublicUrls()
|
||||
|
||||
expect(urls.apiPublicUrlConfigured).toBe(true)
|
||||
expect(urls.apiBaseUrl).toBe('https://api.public.example')
|
||||
expect(urls.callbackUrlTemplate).toBe('https://api.public.example/module/sso/callback/{id}')
|
||||
expect(urls.scimEndpointUrl).toBe('https://api.public.example/scim/v2')
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,6 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'http'
|
||||
import { networkInterfaces } from 'os'
|
||||
|
||||
function getLocalIp(): string {
|
||||
const nets = networkInterfaces()
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name]!) {
|
||||
if (net.family === 'IPv4' && !net.internal) {
|
||||
return net.address
|
||||
}
|
||||
}
|
||||
}
|
||||
return '127.0.0.1'
|
||||
}
|
||||
const RECEIVER_HOST = process.env.TASKVIEW_TEST_WEBHOOK_HOST || 'host.docker.internal'
|
||||
|
||||
export type ReceivedWebhook = {
|
||||
body: any
|
||||
@@ -42,12 +31,10 @@ export function createWebhookReceiver() {
|
||||
})
|
||||
})
|
||||
|
||||
const ip = getLocalIp()
|
||||
|
||||
return {
|
||||
server,
|
||||
received,
|
||||
getUrl: () => `http://${ip}:${(server.address() as any).port}/webhook`,
|
||||
getUrl: () => `http://${RECEIVER_HOST}:${(server.address() as any).port}/webhook`,
|
||||
start: () => new Promise<void>((resolve) => {
|
||||
server.listen(0, '0.0.0.0', () => resolve())
|
||||
}),
|
||||
|
||||
@@ -23,6 +23,8 @@ export type AnalyticsRange = {
|
||||
export type LocalizedText = {
|
||||
ru: string
|
||||
en: string
|
||||
de?: string
|
||||
es?: string
|
||||
}
|
||||
|
||||
export type AnalyticsUnit =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type IntegrationProvider = 'github' | 'gitlab';
|
||||
export type IntegrationProvider = 'github' | 'gitlab' | 'gitea';
|
||||
|
||||
export type IntegrationItem = {
|
||||
id: number;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Task } from './tasks.api.types';
|
||||
|
||||
export type RecurrenceState = 'active' | 'paused' | 'ended';
|
||||
/** 'fixed' — calendar schedule; 'after-completion' — next occurrence is one interval step after the completion day. */
|
||||
export type RecurrenceScheduleMode = 'fixed' | 'after-completion';
|
||||
|
||||
export type RecurrenceRule = {
|
||||
id: number;
|
||||
@@ -20,6 +22,7 @@ export type RecurrenceRule = {
|
||||
hasTime: boolean;
|
||||
/** IANA timezone name, e.g. 'Europe/Moscow'. */
|
||||
timezone: string;
|
||||
scheduleMode: RecurrenceScheduleMode;
|
||||
state: RecurrenceState;
|
||||
lastInstanceDate: string;
|
||||
instancesCreated: number;
|
||||
@@ -41,6 +44,7 @@ export type RecurrenceCreateArgs = {
|
||||
/** 'YYYY-MM-DD' for a date-only series, 'YYYY-MM-DDTHH:mm:ss' for a timed one (incl. 00:00). */
|
||||
dtstart: string;
|
||||
timezone: string;
|
||||
scheduleMode?: RecurrenceScheduleMode;
|
||||
notifyOnOccurrence?: boolean;
|
||||
};
|
||||
|
||||
@@ -58,6 +62,7 @@ export type RecurrenceUpdateArgs = {
|
||||
rrule?: string;
|
||||
dtstart?: string;
|
||||
timezone?: string;
|
||||
scheduleMode?: RecurrenceScheduleMode;
|
||||
notifyOnOccurrence?: boolean;
|
||||
templateOverrides?: RecurrenceTemplateOverrides;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SsoConfigArgCreate,
|
||||
SsoConfigArgUpdate,
|
||||
SsoProviderPublic,
|
||||
SsoPublicUrls,
|
||||
} from './sso.types'
|
||||
|
||||
export default class TvSsoApi extends TvApiBase {
|
||||
@@ -36,6 +37,12 @@ export default class TvSsoApi extends TvApiBase {
|
||||
)
|
||||
}
|
||||
|
||||
public async getPublicUrls() {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<SsoPublicUrls>>(`${this.moduleUrl}/admin/public-urls`)
|
||||
)
|
||||
}
|
||||
|
||||
public async parseMetadata(url: string) {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<{ samlEntryPoint: string, samlCert: string, samlLogoutUrl: string }>>(`${this.moduleUrl}/admin/metadata`, {
|
||||
|
||||
@@ -81,3 +81,10 @@ export type SsoProviderPublic = {
|
||||
displayName: string
|
||||
protocol: string
|
||||
}
|
||||
|
||||
export type SsoPublicUrls = {
|
||||
apiBaseUrl: string
|
||||
callbackUrlTemplate: string
|
||||
scimEndpointUrl: string
|
||||
apiPublicUrlConfigured: boolean
|
||||
}
|
||||
|
||||
@@ -7,8 +7,12 @@ export type UiPreferencesItem = {
|
||||
|
||||
export type FirstDayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6
|
||||
|
||||
export type DefaultView = 'tasks' | 'kanban' | 'graph' | 'sprints'
|
||||
|
||||
export type UiSettings = {
|
||||
firstDayOfWeek?: FirstDayOfWeek
|
||||
defaultProjectId?: number
|
||||
defaultView?: DefaultView
|
||||
}
|
||||
|
||||
export const UI_SETTINGS_KEY = '__settings__'
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './schemas/tasks-to-tags.schema';
|
||||
export * from './schemas/tags.schema';
|
||||
export * from './schemas/users.schema';
|
||||
export * from './schemas/collaboration-users.schema';
|
||||
export * from './schemas/invite-emails.schema';
|
||||
export * from './schemas/tasks-assignee.schema';
|
||||
export * from './schemas/goals.schema';
|
||||
export * from './schemas/goals-list.schema';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { integer, pgSchema, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||
import { GoalsSchema } from "./goals.schema";
|
||||
import { UsersSchema } from "./users.schema";
|
||||
|
||||
export const InviteEmailsSchema = pgSchema('collaboration').table('invite_emails', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
initiatorId: integer('initiator_id').notNull().references(() => UsersSchema.id, { onDelete: 'cascade' }),
|
||||
email: varchar({ length: 255 }).notNull(),
|
||||
goalId: integer('goal_id').notNull().references(() => GoalsSchema.id, { onDelete: 'cascade' }),
|
||||
sentAt: timestamp('sent_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type InviteEmailsSchemaTypeForSelect = typeof InviteEmailsSchema.$inferSelect;
|
||||
export type InviteEmailsSchemaTypeForInsert = typeof InviteEmailsSchema.$inferInsert;
|
||||
@@ -4,6 +4,7 @@ import { TasksSchema } from "./tasks.schema";
|
||||
import { UsersSchema } from "./users.schema";
|
||||
|
||||
export type RecurrenceState = 'active' | 'paused' | 'ended';
|
||||
export type RecurrenceScheduleMode = 'fixed' | 'after-completion';
|
||||
|
||||
export const RecurrenceRulesSchema = pgSchema('tasks').table('recurrence_rules', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
@@ -20,6 +21,7 @@ export const RecurrenceRulesSchema = pgSchema('tasks').table('recurrence_rules',
|
||||
hasTime: boolean('has_time').notNull().default(false),
|
||||
timezone: varchar({ length: 50 }).notNull(),
|
||||
state: varchar({ length: 20 }).$type<RecurrenceState>().notNull().default('active'),
|
||||
scheduleMode: varchar('schedule_mode', { length: 20 }).$type<RecurrenceScheduleMode>().notNull().default('fixed'),
|
||||
lastInstanceDate: date('last_instance_date').notNull(),
|
||||
instancesCreated: integer('instances_created').notNull().default(1),
|
||||
notifyOnOccurrence: boolean('notify_on_occurrence').notNull().default(false),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
FROM node:24-alpine AS deps
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package.json ./
|
||||
# npm cannot parse pnpm's workspace: protocol in devDependencies; only the
|
||||
# runtime deps matter here, so drop the dev section before installing
|
||||
RUN node -e "const p = require('./package.json'); delete p.devDependencies; require('fs').writeFileSync('package.json', JSON.stringify(p, null, 2))" \
|
||||
&& npm install --omit=dev
|
||||
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# npm/corepack are build-time tools; their vendored deps (tar, undici, ...)
|
||||
# carry CVEs, so the runtime image ships without them
|
||||
RUN rm -rf /usr/local/lib/node_modules /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /opt/yarn* /usr/local/bin/yarn /usr/local/bin/yarnpkg
|
||||
|
||||
COPY --from=deps /usr/src/app/node_modules ./node_modules
|
||||
COPY package.json ./
|
||||
COPY dist ./dist
|
||||
|
||||
ENV MCP_HTTP_PORT=3100
|
||||
EXPOSE 3100
|
||||
|
||||
USER node
|
||||
|
||||
CMD ["node", "dist/http.js"]
|
||||
@@ -71,28 +71,71 @@ Then use `"command": "taskview-mcp"` (no `args` needed).
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `TASKVIEW_URL` | yes | TaskView API server URL (e.g. `https://api.taskview.tech`) |
|
||||
| `TASKVIEW_TOKEN` | yes | API token with `tvk_` prefix |
|
||||
| `TASKVIEW_TOKEN` | yes (stdio mode) | API token with `tvk_` prefix. Not used in HTTP mode — each caller sends their own token |
|
||||
| `MCP_HTTP_PORT` | no (HTTP mode) | Port for the HTTP server, default `3100` |
|
||||
|
||||
## HTTP server (remote / self-hosted)
|
||||
|
||||
Besides the local stdio mode above, the package ships an HTTP entrypoint
|
||||
(Streamable HTTP transport) so one shared server can serve many users — each
|
||||
request authenticates with the caller's own API token:
|
||||
|
||||
```bash
|
||||
TASKVIEW_URL=https://api.taskview.tech taskview-mcp-http
|
||||
# MCP endpoint: http://localhost:3100/mcp, health check: /health
|
||||
```
|
||||
|
||||
Connect from Claude Code:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http taskview https://mcp.example.com/mcp \
|
||||
--header "Authorization: Bearer tvk_..."
|
||||
```
|
||||
|
||||
or in `.mcp.json` (Claude Code, Cursor, VS Code):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"taskview": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer tvk_..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The server is stateless: every request gets its own isolated API client
|
||||
carrying only that caller's token. Requests without a `Bearer` token get 401.
|
||||
Self-hosted instances run their own copy next to their API (see `Dockerfile`
|
||||
in this package) — point `TASKVIEW_URL` at your API server and put the MCP
|
||||
port behind your reverse proxy with HTTPS.
|
||||
|
||||
## Available tools
|
||||
|
||||
37 tools covering the full TaskView surface.
|
||||
61 tools covering the full TaskView surface.
|
||||
|
||||
**Projects (Goals)** — `list_goals`, `create_goal`, `update_goal`, `delete_goal`
|
||||
|
||||
**Lists** — `list_lists`, `create_list`, `update_list`, `delete_list`
|
||||
|
||||
**Tasks** — `list_tasks`, `get_task`, `create_task`, `update_task`, `delete_task`, `toggle_task_assignees`, `get_task_history`
|
||||
**Tasks** — `list_tasks`, `get_task`, `create_task`, `update_task`, `delete_task`, `toggle_task_assignees`, `get_task_history`, `restore_task_from_history`
|
||||
|
||||
**Tags** — `list_tags`, `create_tag`, `update_tag`, `delete_tag`, `toggle_task_tag`
|
||||
|
||||
**Kanban** — `list_kanban_columns`, `create_kanban_column`, `update_kanban_column`, `delete_kanban_column`
|
||||
|
||||
**Collaboration** — `list_collaborators`, `list_collaborators_for_goal`, `invite_collaborator`, `remove_collaborator`, `toggle_collaborator_roles`, `list_roles`, `create_role`, `delete_role`, `list_permissions`, `toggle_role_permission`
|
||||
**Collaboration** — `list_collaborators`, `list_collaborators_for_goal`, `invite_collaborator`, `remove_collaborator`, `toggle_collaborator_roles`, `list_roles`, `create_role`, `delete_role`, `list_permissions`, `list_role_permissions_for_goal`, `toggle_role_permission`
|
||||
|
||||
**Task dependencies (graph)** — `list_task_dependencies`, `add_task_dependency`, `delete_task_dependency`
|
||||
|
||||
**Notifications** — `list_notifications`, `mark_notification_read`, `mark_all_notifications_read`
|
||||
|
||||
**Organizations** — `list_organizations`, `get_organization`, `create_organization`, `update_organization`, `delete_organization`, `list_organization_members`, `add_organization_member`, `update_organization_member_role`, `remove_organization_member`
|
||||
|
||||
**Time tracking** — `start_timer`, `stop_timer`, `get_active_timer`, `log_time`, `list_time_entries`, `update_time_entry`, `delete_time_entry`, `get_time_summary`, `get_time_report`, `get_time_contributors`
|
||||
|
||||
## How it works
|
||||
|
||||
The MCP server uses the `taskview-api` client under the hood. Every tool call goes through the full API stack — authentication, permission checks, and validation. The MCP process itself is stateless; your token never leaves your machine except in `Authorization: Bearer ...` headers to your TaskView API server.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "taskview-mcp",
|
||||
"version": "1.48.3",
|
||||
"version": "1.51.0",
|
||||
"description": "MCP (Model Context Protocol) server for TaskView — lets AI assistants (Claude Code, Claude Desktop, etc.) manage projects and tasks via the TaskView API",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"taskview-mcp": "./dist/index.js"
|
||||
"taskview-mcp": "./dist/index.js",
|
||||
"taskview-mcp-http": "./dist/http.js"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"files": [
|
||||
@@ -61,4 +62,4 @@
|
||||
"vitest": "^4.1.2",
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
import { createServer, type IncomingMessage } from 'node:http'
|
||||
import axios from 'axios'
|
||||
import { TvApi } from 'taskview-api'
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
||||
import { createMcpServer } from './server.js'
|
||||
import type { HandleMcpRequestArgs } from './http.types.js'
|
||||
|
||||
const TASKVIEW_URL = process.env.TASKVIEW_URL
|
||||
const PORT = Number(process.env.MCP_HTTP_PORT || 3100)
|
||||
|
||||
if (!TASKVIEW_URL) {
|
||||
console.error('Required environment variable: TASKVIEW_URL')
|
||||
console.error('Example: TASKVIEW_URL=https://api.taskview.tech MCP_HTTP_PORT=3100 taskview-mcp-http')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const CORS_HEADERS = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version',
|
||||
'Access-Control-Expose-Headers': 'Mcp-Session-Id',
|
||||
}
|
||||
|
||||
function extractBearerToken(req: IncomingMessage): string | null {
|
||||
const header = req.headers.authorization
|
||||
if (!header?.startsWith('Bearer ')) return null
|
||||
const token = header.slice('Bearer '.length).trim()
|
||||
return token.length > 0 ? token : null
|
||||
}
|
||||
|
||||
// Stateless mode: every request gets its own server + transport pair wired to
|
||||
// an axios instance carrying ONLY this caller's token, so tokens can never
|
||||
// leak between users through shared state.
|
||||
async function handleMcpRequest({ req, res, token }: HandleMcpRequestArgs) {
|
||||
const $axios = axios.create({
|
||||
baseURL: TASKVIEW_URL,
|
||||
timeout: 30000,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const api = new TvApi($axios)
|
||||
const server = createMcpServer(api)
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true,
|
||||
})
|
||||
res.on('close', () => {
|
||||
transport.close()
|
||||
server.close()
|
||||
})
|
||||
await server.connect(transport)
|
||||
await transport.handleRequest(req, res)
|
||||
}
|
||||
|
||||
const httpServer = createServer(async (req, res) => {
|
||||
for (const [name, value] of Object.entries(CORS_HEADERS)) res.setHeader(name, value)
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204).end()
|
||||
return
|
||||
}
|
||||
|
||||
const path = new URL(req.url ?? '/', 'http://localhost').pathname
|
||||
|
||||
if (path === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ status: 'ok' }))
|
||||
return
|
||||
}
|
||||
|
||||
if (path !== '/mcp') {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' }).end(JSON.stringify({ message: 'Not found. MCP endpoint is /mcp' }))
|
||||
return
|
||||
}
|
||||
|
||||
const token = extractBearerToken(req)
|
||||
if (!token) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer' }).end(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32000, message: 'Unauthorized: provide your TaskView API token as "Authorization: Bearer tvk_..."' },
|
||||
id: null,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await handleMcpRequest({ req, res, token })
|
||||
} catch (error) {
|
||||
console.error('[mcp-http] request failed:', error)
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' }).end(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32603, message: 'Internal server error' },
|
||||
id: null,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`TaskView MCP HTTP server listening on :${PORT} (endpoint /mcp), proxying to ${TASKVIEW_URL}`)
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
|
||||
export type HandleMcpRequestArgs = {
|
||||
req: IncomingMessage
|
||||
res: ServerResponse
|
||||
token: string
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
|
||||
@@ -4,14 +4,19 @@ import { resolve } from 'path'
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
entry: {
|
||||
index: resolve(__dirname, 'src/index.ts'),
|
||||
http: resolve(__dirname, 'src/http.ts'),
|
||||
},
|
||||
formats: ['es'],
|
||||
fileName: () => 'index.js',
|
||||
fileName: (_format, name) => `${name}.js`,
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'@modelcontextprotocol/sdk/server/mcp.js',
|
||||
'@modelcontextprotocol/sdk/server/stdio.js',
|
||||
'@modelcontextprotocol/sdk/server/streamableHttp.js',
|
||||
'node:http',
|
||||
],
|
||||
},
|
||||
target: 'node22',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user