mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 171f269ee2 | |||
| f79614ebc5 | |||
| a4c465469d | |||
| f3e2663ebe | |||
| 5ce2fe99f0 | |||
| e11aba4d79 | |||
| 0b6f95ec3d | |||
| a596e023db | |||
| c1685e42b8 | |||
| 9635120e66 | |||
| 19e0212edf | |||
| 5dc5b387de | |||
| 8011da1259 | |||
| a6329e998d | |||
| b372854636 | |||
| a243f9ec56 | |||
| ebd25a94ea | |||
| 4b142619c0 | |||
| 6f576e7297 | |||
| 15050541fa | |||
| 3afdca99d9 | |||
| aaa876412d | |||
| 915e8d9f9f | |||
| 55ded8bac9 |
@@ -428,7 +428,7 @@ For commercial licensing questions, hosted service permissions, or other use cas
|
||||
|
||||
Do not publish security vulnerabilities in public GitHub issues.
|
||||
|
||||
Report security issues privately using the contact information provided in the repository or on the TaskView website.
|
||||
Report security issues privately — see [SECURITY.md](SECURITY.md) for the reporting channels, response times, scope, and safe-harbor terms.
|
||||
|
||||
When running TaskView in production:
|
||||
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
|
||||
|
||||
Report them privately using one of these channels:
|
||||
|
||||
- **GitHub private vulnerability reporting** (preferred): open the **Security** tab of this repository and click **Report a vulnerability**.
|
||||
- **Email**: [support@taskview.tech](mailto:support@taskview.tech) with `[security]` in the subject.
|
||||
|
||||
Please include as much of the following as you can:
|
||||
|
||||
- A description of the issue and its impact
|
||||
- Affected component (API, web app, MCP server, mobile app) and version
|
||||
- Steps to reproduce, or a proof of concept
|
||||
- Any suggested mitigation
|
||||
|
||||
## What to expect
|
||||
|
||||
- We will acknowledge your report within **5 business days**.
|
||||
- We will keep you informed about progress and aim to release a fix for confirmed issues within **90 days** of the report, sooner for critical issues.
|
||||
- Once a fix is released, we publish a GitHub Security Advisory for the affected versions and credit the reporter, unless they prefer to stay anonymous.
|
||||
- We ask that you give us a reasonable time to fix the issue before disclosing it publicly.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Security fixes are released for the latest minor version line only. Self-hosted installations should upgrade to the latest release to receive them.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- The TaskView API server, web app, MCP server, and mobile app in this repository
|
||||
- The hosted service at `app.taskview.tech`
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Vulnerabilities in third-party dependencies that are not exploitable in TaskView (report them upstream)
|
||||
- Findings that require a compromised admin account or physical access to the server
|
||||
- Missing security headers, rate limiting, or best-practice recommendations without a demonstrated impact
|
||||
- Denial-of-service testing against the hosted service
|
||||
|
||||
## Safe harbor
|
||||
|
||||
We will not pursue legal action against researchers who act in good faith: test only against their own self-hosted instance or their own accounts on the hosted service, avoid accessing or modifying other users' data, and report findings privately as described above.
|
||||
@@ -19,6 +19,9 @@ ACCESS_LIFE_TIME=1d
|
||||
REFRESH_LIFE_TIME=2d
|
||||
JWT_ALG=HS256
|
||||
|
||||
# SSO: comma-separated email domains that skip DNS/HTTP ownership proof (air-gapped installs)
|
||||
#SSO_TRUSTED_DOMAINS=company.com,corp.local
|
||||
|
||||
# SMTP Configuration
|
||||
SMTP_HOST=smtp.domain.com
|
||||
SMTP_PORT=465
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.51.0",
|
||||
"version": "1.53.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch ./server.ts",
|
||||
"start": "NODE_ENV=production node ./dist/taskview-server.js",
|
||||
|
||||
@@ -737,5 +737,18 @@
|
||||
"description": [
|
||||
"Log of sent project-invite emails (collaboration.invite_emails) backing the per-recipient cooldown and the hourly per-initiator sending cap"
|
||||
]
|
||||
},
|
||||
"58": {
|
||||
"version": "1.63.0",
|
||||
"name": "SSO domain verification",
|
||||
"releaseDate": "20260813",
|
||||
"scripts": [
|
||||
"/1.63.0/0.sso-domain-verification.sql",
|
||||
"/1.63.0/1.sso-domain-verified-unique.sql"
|
||||
],
|
||||
"description": [
|
||||
"SSO configs require proving ownership of email_domain_restriction before login is allowed: DNS TXT taskview-sso-verify=<token> or https://<domain>/.well-known/taskview-sso-verify.txt. Air-gapped installs can skip this for listed domains via SSO_TRUSTED_DOMAINS.",
|
||||
"Replaces the plain UNIQUE(email_domain_restriction) with a partial unique index over verified configs only, so an unverified config can no longer squat a domain and block its real owner — multiple orgs may hold a pending config for the same domain, but only one can verify it (first-to-verify wins)."
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE tv_auth.sso_configs
|
||||
ADD COLUMN IF NOT EXISTS domain_verify_token VARCHAR,
|
||||
ADD COLUMN IF NOT EXISTS domain_verified_at TIMESTAMP;
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE tv_auth.sso_configs
|
||||
DROP CONSTRAINT IF EXISTS sso_configs_email_domain_restriction_key;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS sso_configs_verified_domain_uniq
|
||||
ON tv_auth.sso_configs (email_domain_restriction)
|
||||
WHERE domain_verified_at IS NOT NULL;
|
||||
@@ -5,18 +5,12 @@ import { OverdueKpi } from './kpi/OverdueKpi'
|
||||
import { ThroughputSection } from './productivity/ThroughputSection'
|
||||
import { PriorityMixOverTimeSection } from './productivity/PriorityMixOverTimeSection'
|
||||
import { WorkloadByAssigneeSection } from './workload/WorkloadByAssigneeSection'
|
||||
import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
|
||||
import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
|
||||
import { StaleTasksSection } from './quality/StaleTasksSection'
|
||||
import { StatusDistributionSection } from './usage/StatusDistributionSection'
|
||||
import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
|
||||
import { IncomeExpenseMonthSection } from './financial/IncomeExpenseMonthSection'
|
||||
import { IncomeExpensePerProjectSection } from './financial/IncomeExpensePerProjectSection'
|
||||
import { IncomePerProjectMonthSection } from './financial/IncomePerProjectMonthSection'
|
||||
import { ExpensePerProjectMonthSection } from './financial/ExpensePerProjectMonthSection'
|
||||
import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection'
|
||||
import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection'
|
||||
import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
|
||||
import { AmountCoverageKpi } from './financial/AmountCoverageKpi'
|
||||
import { TotalIncomeKpi } from './financial/TotalIncomeKpi'
|
||||
import { TotalExpenseKpi } from './financial/TotalExpenseKpi'
|
||||
@@ -59,6 +53,12 @@ import { sectionLocales } from './locales'
|
||||
// - entered_at) for rows in that status. Without a transition log, this
|
||||
// metric cannot be computed correctly.
|
||||
// ---------------------------------------------------------------------------
|
||||
// import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
|
||||
// import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
|
||||
// import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
|
||||
// import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
|
||||
// import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection'
|
||||
// import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection'
|
||||
// import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection'
|
||||
// import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection'
|
||||
// import { CycleTimeKpi } from './kpi/CycleTimeKpi'
|
||||
@@ -82,25 +82,25 @@ const builders: SectionBuilder[] = [
|
||||
new PriorityMixOverTimeSection(),
|
||||
// Workload
|
||||
new WorkloadByAssigneeSection(),
|
||||
new BlockedByDependenciesSection(),
|
||||
// new BlockedByDependenciesSection(), // disabled
|
||||
// new TimeInKanbanStatusSection(), // disabled — see top-of-file comment
|
||||
// new AgingOpenTasksSection(), // disabled — see top-of-file comment
|
||||
// Quality
|
||||
new OverdueByAgeSection(),
|
||||
// new OverdueByAgeSection(), // disabled
|
||||
// new CycleTimeHistogramSection(), // disabled — see top-of-file comment
|
||||
new StaleTasksSection(),
|
||||
// new CycleTimePerProjectSection(), // disabled — see top-of-file comment
|
||||
// Usage
|
||||
new StatusDistributionSection(),
|
||||
new ActiveProjectsSection(),
|
||||
// new ActiveProjectsSection(), // disabled
|
||||
// Financial
|
||||
new IncomeExpenseMonthSection(),
|
||||
new IncomeExpensePerProjectSection(),
|
||||
new IncomePerProjectMonthSection(),
|
||||
new ExpensePerProjectMonthSection(),
|
||||
new IncomePerTagMonthSection(),
|
||||
new ExpensePerTagMonthSection(),
|
||||
new TopProjectsByAmountSection(),
|
||||
// new IncomePerTagMonthSection(), // disabled
|
||||
// new ExpensePerTagMonthSection(), // disabled
|
||||
// new TopProjectsByAmountSection(), // disabled
|
||||
]
|
||||
|
||||
export class SectionRegistry {
|
||||
|
||||
@@ -367,6 +367,16 @@ export default class AuthController {
|
||||
// Invalidate code immediately to prevent replay attacks
|
||||
await req.appUser.authManager.repository.updateLoginCode(null, userData.email);
|
||||
|
||||
if (userData.block) {
|
||||
if (!userData.confirm_email_code) {
|
||||
return res.status(403).send({ message: 'account_blocked' });
|
||||
}
|
||||
const confirmed = await req.appUser.authManager.repository.markEmailConfirmed(userData.email);
|
||||
if (!confirmed) {
|
||||
return res.status(500).end();
|
||||
}
|
||||
}
|
||||
|
||||
const sessionId = await req.appUser.authManager.sessionStorage.createSession(
|
||||
userData.id,
|
||||
req.ip,
|
||||
|
||||
@@ -2,7 +2,7 @@ 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, UpdateUserCredentialsArgs, UpdateUserCredentialsResult, UserDbRecord } from '../../types/auth.types';
|
||||
import type { RegisterUserInDb, UpdateUserCredentialsArgs, UpdateUserEmailArgs, UpdateUserCredentialsResult, UserDbRecord } from '../../types/auth.types';
|
||||
|
||||
export default class AuthModel {
|
||||
private readonly db: Database;
|
||||
@@ -119,6 +119,21 @@ export default class AuthModel {
|
||||
}
|
||||
}
|
||||
|
||||
async markEmailConfirmed(email: string): Promise<boolean> {
|
||||
if (!email) return false;
|
||||
|
||||
try {
|
||||
const result = await this.db.dbDrizzle
|
||||
.update(UsersSchema)
|
||||
.set({ confirmEmailCode: null, block: 0 })
|
||||
.where(eq(UsersSchema.email, email));
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
} catch (error) {
|
||||
$logger.error(error, `Error marking email confirmed for ${email}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async confirmEmail(login: string, code: string, block: number): Promise<boolean> {
|
||||
const query = `UPDATE tv_auth.users
|
||||
SET confirm_email_code = NULL, block = $1
|
||||
@@ -189,6 +204,37 @@ export default class AuthModel {
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserEmail(args: UpdateUserEmailArgs): Promise<UpdateUserCredentialsResult> {
|
||||
try {
|
||||
await this.db.dbDrizzle.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(UsersSchema)
|
||||
.set({ email: args.email })
|
||||
.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) {
|
||||
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 email 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';
|
||||
|
||||
@@ -512,4 +512,65 @@ describe('Login API', () => {
|
||||
|
||||
expect(te).toBe(0);
|
||||
});
|
||||
|
||||
it('loginByCode confirms and admits a blocked-unconfirmed account', async () => {
|
||||
deleteTestUserEmail = `${Date.now()}test@mail.dest`;
|
||||
const email = deleteTestUserEmail;
|
||||
|
||||
await axios.post(`${url}/module/auth/registration`, {
|
||||
email,
|
||||
password: 'user1!#Q',
|
||||
passwordRepeat: 'user1!#Q',
|
||||
});
|
||||
|
||||
const userModel = new AuthModel();
|
||||
const before = await userModel.getUserByLogin(email, true);
|
||||
expect(before).toBeTruthy();
|
||||
expect((before as any).block).toBe(1);
|
||||
expect((before as any).confirm_email_code).toBeTruthy();
|
||||
|
||||
const code = '654321';
|
||||
await userModel.updateLoginCode(`${code}:${Date.now()}`, email);
|
||||
|
||||
const response = await axios.post(`${url}/module/auth/login-by-code`, { email, code });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.access).toBeTruthy();
|
||||
expect(response.data.refresh).toBeTruthy();
|
||||
|
||||
const after = await userModel.getUserByLogin(email, true);
|
||||
expect((after as any).block).toBe(0);
|
||||
expect((after as any).confirm_email_code).toBeNull();
|
||||
});
|
||||
|
||||
it('loginByCode rejects a banned account (blocked, no confirm code)', async () => {
|
||||
deleteTestUserEmail = `${Date.now()}test@mail.dest`;
|
||||
const email = deleteTestUserEmail;
|
||||
|
||||
await axios.post(`${url}/module/auth/registration`, {
|
||||
email,
|
||||
password: 'user1!#Q',
|
||||
passwordRepeat: 'user1!#Q',
|
||||
});
|
||||
|
||||
const db = Database.getInstance();
|
||||
await db.query('update tv_auth.users set block = 1, confirm_email_code = null where email = $1', [email]);
|
||||
|
||||
const userModel = new AuthModel();
|
||||
const code = '112233';
|
||||
await userModel.updateLoginCode(`${code}:${Date.now()}`, email);
|
||||
|
||||
let status = 0;
|
||||
let message = '';
|
||||
await axios.post(`${url}/module/auth/login-by-code`, { email, code }).catch((err) => {
|
||||
status = err.response.status;
|
||||
message = err.response.data.message;
|
||||
});
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(message).toBe('account_blocked');
|
||||
|
||||
const after = await userModel.getUserByLogin(email, true);
|
||||
expect((after as any).block).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@ 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;
|
||||
// the only route using this guard names the goal in the path
|
||||
const goalId = req.params.goalId;
|
||||
|
||||
if (!goalId) {
|
||||
return res.status(400).end();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IsOrgMemberIfProvided } from '../../middlewares/is-org-member';
|
||||
import { CollaborationController } from './CollaborationController';
|
||||
import { CanAddUserCollaboration } from './middlewares/CanAddUserCollaboration';
|
||||
import { CanDeleteUserCollaboration } from './middlewares/CanDeleteUserCollaboration';
|
||||
// import { CanFetchUsersCollaboration } from './middlewares/CanFetchUsersCollaboration';
|
||||
import { CanFetchUsersCollaboration } from './middlewares/CanFetchUsersCollaboration';
|
||||
import { CanToggleRolesCollaboration } from './middlewares/CanToggleRolesCollaboration';
|
||||
|
||||
export default class CollaborationRoutes implements Routable {
|
||||
@@ -56,6 +56,10 @@ export default class CollaborationRoutes implements Routable {
|
||||
/**
|
||||
* Fetch users for goal for collaboration
|
||||
*/
|
||||
this.router.get('/:goalId', [IsLoggedIn], this.collaborationController.fetchUsersForGoalNew);
|
||||
this.router.get(
|
||||
'/:goalId',
|
||||
[IsLoggedIn, CanFetchUsersCollaboration],
|
||||
this.collaborationController.fetchUsersForGoalNew
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ export const CanFetchUsersCollaboration = async (req: Request, res: Response, ne
|
||||
|
||||
if (
|
||||
permissions.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS) ||
|
||||
permissions.hasPermissions(GoalPermissions.GOAL_CAN_MANAGE_USERS)
|
||||
permissions.hasPermissions(GoalPermissions.GOAL_CAN_MANAGE_USERS) ||
|
||||
permissions.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_ASSIGNED_USERS)
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@ export class GraphController {
|
||||
return res.tvJson(edges);
|
||||
};
|
||||
|
||||
fetchTaskEdges = async (req: Request, res: Response) => {
|
||||
const taskId = Number(req.params.taskId);
|
||||
if (!Number.isFinite(taskId)) {
|
||||
return res.status(400).send('Task ID is required');
|
||||
}
|
||||
const edges = await req.appUser.graphManager.fetchEdgesForTask(taskId);
|
||||
return res.tvJson(edges);
|
||||
};
|
||||
|
||||
deleteEdge = async (req: Request, res: Response) => {
|
||||
if (!req.params.id) {
|
||||
return res.status(400).send('Edge ID is required');
|
||||
|
||||
@@ -19,6 +19,10 @@ export class GraphManager {
|
||||
return await this.repository.fetchAllEdges(goalId);
|
||||
}
|
||||
|
||||
async fetchEdgesForTask(taskId: number) {
|
||||
return await this.repository.fetchEdgesForTask(taskId);
|
||||
}
|
||||
|
||||
async deleteEdge(id: number) {
|
||||
return await this.repository.deleteEdge(id);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, or } from 'drizzle-orm';
|
||||
import { GraphRelationsSchema } from 'taskview-db-schemas';
|
||||
import { Database } from '../../modules/db';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
@@ -32,6 +32,16 @@ export class GraphRepository {
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
public async fetchEdgesForTask(taskId: number): Promise<GraphReturnRelationsType[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(GraphRelationsSchema)
|
||||
.where(or(eq(GraphRelationsSchema.fromTaskId, taskId), eq(GraphRelationsSchema.toTaskId, taskId)))
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
public async deleteEdge(id: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(GraphRelationsSchema).where(eq(GraphRelationsSchema.id, id))
|
||||
|
||||
@@ -21,6 +21,7 @@ export default class GraphRoutes implements Routable {
|
||||
|
||||
initRoutes() {
|
||||
this.router.post('', [IsLoggedIn, CanManageGraph], this.graphController.addEdge);
|
||||
this.router.get('/task/:taskId', [IsLoggedIn, CanViewGraph], this.graphController.fetchTaskEdges);
|
||||
this.router.get('/:goalId', [IsLoggedIn, CanViewGraph], this.graphController.fetchAllEdges);
|
||||
this.router.delete('/:id', [IsLoggedIn, CanManageGraph], this.graphController.deleteEdge);
|
||||
}
|
||||
|
||||
@@ -3,28 +3,32 @@ import { GraphRepository } from '../GraphRepository';
|
||||
import { TasksRepository } from '../../tasks/TasksRepository';
|
||||
|
||||
/**
|
||||
* Resolves goalId from graph request.
|
||||
* - GET /:goalId → params.goalId
|
||||
* - POST (addEdge) → resolve via fromTaskId (body.source)
|
||||
* - DELETE /:id → resolve via edge id
|
||||
* Resolves the single goal a graph request belongs to.
|
||||
*
|
||||
* The source is chosen by what the route actually carries, not by probing every
|
||||
* field in turn: a route parameter always wins, and only a request with no
|
||||
* parameters at all (addEdge) is resolved from the body. Reading the body first
|
||||
* would let a caller point the guard at a task they own while the handler acts
|
||||
* on someone else's edge.
|
||||
*
|
||||
* A graph lives inside one project, so an edge whose endpoints sit in different
|
||||
* goals is not a permission question — it is an impossible object. It resolves
|
||||
* to null and the guards reject it before any permission is considered, the same
|
||||
* invariant the tasks.check_task_graph_relation_goal trigger enforces in the DB.
|
||||
*/
|
||||
export async function resolveGoalId(req: Request): Promise<number | null> {
|
||||
// Direct goalId in params (fetchAllEdges)
|
||||
// fetchAllEdges: GET /:goalId
|
||||
if (req.params.goalId) {
|
||||
const id = Number(req.params.goalId);
|
||||
return isNaN(id) ? null : id;
|
||||
const goalId = Number(req.params.goalId);
|
||||
return isNaN(goalId) ? null : goalId;
|
||||
}
|
||||
|
||||
// addEdge: resolve goalId from task
|
||||
if (req.body?.source) {
|
||||
const taskId = Number(req.body.source);
|
||||
if (isNaN(taskId)) return null;
|
||||
const tasksRepo = new TasksRepository();
|
||||
const task = await tasksRepo.fetchTaskByIdNew(taskId);
|
||||
return task?.goalId ?? null;
|
||||
// fetchTaskEdges: GET /task/:taskId
|
||||
if (req.params.taskId) {
|
||||
return goalIdForTask(req.params.taskId);
|
||||
}
|
||||
|
||||
// deleteEdge: resolve goalId from edge
|
||||
// deleteEdge: DELETE /:id
|
||||
if (req.params.id) {
|
||||
const edgeId = Number(req.params.id);
|
||||
if (isNaN(edgeId)) return null;
|
||||
@@ -33,5 +37,25 @@ export async function resolveGoalId(req: Request): Promise<number | null> {
|
||||
return edge?.goalId ?? null;
|
||||
}
|
||||
|
||||
// addEdge: POST with { source, target } — both endpoints must be in one goal
|
||||
if (req.body?.source) {
|
||||
const sourceGoalId = await goalIdForTask(req.body.source);
|
||||
if (sourceGoalId === null) return null;
|
||||
|
||||
const targetGoalId = await goalIdForTask(req.body.target);
|
||||
if (targetGoalId !== sourceGoalId) return null;
|
||||
|
||||
return sourceGoalId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function goalIdForTask(rawTaskId: unknown): Promise<number | null> {
|
||||
const taskId = Number(rawTaskId);
|
||||
if (!taskId || isNaN(taskId)) return null;
|
||||
|
||||
const tasksRepo = new TasksRepository();
|
||||
const task = await tasksRepo.fetchTaskByIdNew(taskId);
|
||||
return task?.goalId ?? null;
|
||||
}
|
||||
|
||||
@@ -2,22 +2,26 @@ import type { Request } from 'express';
|
||||
import { IntegrationsRepository } from '../IntegrationsRepository';
|
||||
|
||||
/**
|
||||
* Resolves projectId from request.
|
||||
* Checks body (projectId, integrationId, id) and query (projectId, integrationId).
|
||||
* Resolves the project to authorize the request against.
|
||||
*
|
||||
* When the request names an integration, the project is derived from that
|
||||
* integration and a projectId supplied by the caller is ignored: every handler
|
||||
* that takes an integration id acts on the integration, so authorizing a
|
||||
* caller-supplied project would guard a different object than the one touched.
|
||||
*
|
||||
* Only create and fetch carry no integration id — there the project itself is
|
||||
* the object being acted on, so it is read from the request.
|
||||
*/
|
||||
export async function resolveProjectId(req: Request): Promise<number | null> {
|
||||
// Direct projectId in body or query
|
||||
const directId = req.body?.projectId ?? req.query?.projectId;
|
||||
if (directId) {
|
||||
const id = Number(directId);
|
||||
return isNaN(id) ? null : id;
|
||||
const integrationId = Number(req.body?.integrationId || req.query?.integrationId || req.body?.id);
|
||||
if (integrationId && !isNaN(integrationId)) {
|
||||
const repo = new IntegrationsRepository();
|
||||
const integration = await repo.fetchById(integrationId);
|
||||
return integration?.projectId ?? null;
|
||||
}
|
||||
|
||||
// integrationId from body or query, or id from body
|
||||
const integrationId = Number(req.body?.integrationId || req.query?.integrationId || req.body?.id);
|
||||
if (!integrationId || isNaN(integrationId)) return null;
|
||||
const projectId = Number(req.body?.projectId || req.query?.projectId);
|
||||
if (!projectId || isNaN(projectId)) return null;
|
||||
|
||||
const repo = new IntegrationsRepository();
|
||||
const integration = await repo.fetchById(integrationId);
|
||||
return integration?.projectId ?? null;
|
||||
return projectId;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import { KanbanController } from './KanbanController';
|
||||
import { CanManageKanban } from './middlewares/CanManageKanban';
|
||||
import { CanViewKanban } from './middlewares/CanViewKanban';
|
||||
import { CanFetchTasks } from './middlewares/CanFetchTasks';
|
||||
import { goalIdFromBody, goalIdFromParam, goalIdFromStatusBody } from './middlewares/goal-id-resolvers';
|
||||
import { requireKanbanPermission } from './middlewares/require-kanban-permission';
|
||||
|
||||
export default class KanbanRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly kanbanController: KanbanController;
|
||||
@@ -20,17 +21,89 @@ export default class KanbanRoutes implements Routable {
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.post('/fetch-statuses', [IsLoggedIn, CanViewKanban], this.kanbanController.fetchAllColumns);
|
||||
this.router.post('/add-status', [IsLoggedIn, CanManageKanban], this.kanbanController.addStatus);
|
||||
this.router.post('/delete-status', [IsLoggedIn, CanManageKanban], this.kanbanController.deleteStatus);
|
||||
this.router.post('/update-status', [IsLoggedIn, CanManageKanban], this.kanbanController.updateStatus);
|
||||
this.router.post(
|
||||
'/fetch-statuses',
|
||||
[
|
||||
IsLoggedIn,
|
||||
requireKanbanPermission({
|
||||
anyOf: [GoalPermissions.KANBAN_CAN_VIEW],
|
||||
resolveGoalId: goalIdFromBody,
|
||||
}),
|
||||
],
|
||||
this.kanbanController.fetchAllColumns
|
||||
);
|
||||
|
||||
// this.router.get('columns/:goalId', [IsLoggedIn], this.kanbanController.fetchAllColumns);
|
||||
this.router.get('/tasks/:goalId/:columnId/:cursor', [IsLoggedIn, CanViewKanban, CanFetchTasks], this.kanbanController.fetchTasksForColumn);
|
||||
this.router.post(
|
||||
'/add-status',
|
||||
[
|
||||
IsLoggedIn, requireKanbanPermission({
|
||||
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
|
||||
resolveGoalId: goalIdFromBody
|
||||
})
|
||||
],
|
||||
this.kanbanController.addStatus
|
||||
);
|
||||
|
||||
this.router.post(
|
||||
'/delete-status',
|
||||
[
|
||||
IsLoggedIn, requireKanbanPermission({
|
||||
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
|
||||
resolveGoalId: goalIdFromStatusBody
|
||||
})
|
||||
],
|
||||
this.kanbanController.deleteStatus
|
||||
);
|
||||
|
||||
this.router.post(
|
||||
'/update-status',
|
||||
[
|
||||
IsLoggedIn, requireKanbanPermission({
|
||||
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
|
||||
resolveGoalId: goalIdFromStatusBody
|
||||
})
|
||||
],
|
||||
this.kanbanController.updateStatus
|
||||
);
|
||||
|
||||
this.router.get(
|
||||
'/tasks/:goalId/:columnId/:cursor',
|
||||
[
|
||||
IsLoggedIn,
|
||||
requireKanbanPermission({
|
||||
anyOf: [GoalPermissions.KANBAN_CAN_VIEW],
|
||||
resolveGoalId: goalIdFromParam,
|
||||
}),
|
||||
requireKanbanPermission({
|
||||
anyOf: [GoalPermissions.COMPONENT_CAN_WATCH_CONTENT],
|
||||
resolveGoalId: goalIdFromParam,
|
||||
}),
|
||||
],
|
||||
this.kanbanController.fetchTasksForColumn
|
||||
);
|
||||
|
||||
//we do not use this route in the client (no logic for this route on the client side)!!!
|
||||
this.router.get('/tasks-order/:goalId/:columnId/:cursor', [IsLoggedIn, CanManageKanban], this.kanbanController.getTasksOrderForColumnAndCursor);
|
||||
this.router.get(
|
||||
'/tasks-order/:goalId/:columnId/:cursor',
|
||||
[
|
||||
IsLoggedIn, requireKanbanPermission({
|
||||
anyOf: [GoalPermissions.KANBAN_CAN_VIEW],
|
||||
resolveGoalId: goalIdFromParam
|
||||
})
|
||||
],
|
||||
this.kanbanController.getTasksOrderForColumnAndCursor
|
||||
);
|
||||
|
||||
this.router.patch('/update-tasks-order-and-column', [IsLoggedIn, CanManageKanban], this.kanbanController.updateTasksOrderAndColumn);
|
||||
this.router.patch(
|
||||
'/update-tasks-order-and-column',
|
||||
[
|
||||
IsLoggedIn,
|
||||
requireKanbanPermission({
|
||||
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
|
||||
resolveGoalId: goalIdFromBody
|
||||
})
|
||||
],
|
||||
this.kanbanController.updateTasksOrderAndColumn
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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';
|
||||
import { KanbanArkTypeCanManageKanban } from '../types';
|
||||
import { ArkErrors } from 'arktype';
|
||||
|
||||
export const CanFetchTasks = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const props = req.body.goalId ? req.body : req.params;
|
||||
|
||||
const data = KanbanArkTypeCanManageKanban(props);
|
||||
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(data.goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
|
||||
.catch(logError);
|
||||
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for CanAddTask middleware');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (permissions.hasPermissions(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
import { GoalPermissions } from '../../../types/auth.types';
|
||||
import { ALL_TASKS_LIST_ID, DEFAULT_ID } from '../../../types/tasks.types';
|
||||
import { logError } from '../../../utils/api';
|
||||
import { KanbanArkTypeCanManageKanban } from '../types';
|
||||
import { ArkErrors } from 'arktype';
|
||||
|
||||
export const CanManageKanban = async (req: Request, res: Response, next: NextFunction) => {
|
||||
let props = req.body.goalId ? req.body : req.params;
|
||||
|
||||
switch (req.url) {
|
||||
case '/update-status':
|
||||
const result = await req.appUser.kanbanManager.repository.fetchStatus(req.body.id);
|
||||
props = {
|
||||
goalId: result?.goal_id,
|
||||
};
|
||||
break;
|
||||
case '/delete-status':
|
||||
const result2 = await req.appUser.kanbanManager.repository.fetchStatus(req.body.id);
|
||||
props = {
|
||||
goalId: result2?.goal_id,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const data = KanbanArkTypeCanManageKanban(props);
|
||||
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(data.goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
|
||||
.catch(logError);
|
||||
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for CanAddTask middleware');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (
|
||||
permissions.hasPermissions(GoalPermissions.COMPONENT_CAN_ADD_TASKS) ||
|
||||
permissions.hasPermissions(GoalPermissions.TASKS_CAN_ADD_SUBTASKS)
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
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';
|
||||
import { KanbanArkTypeCanManageKanban } from '../types';
|
||||
import { ArkErrors } from 'arktype';
|
||||
|
||||
export const CanViewKanban = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const props = req.body.goalId ? req.body : req.params;
|
||||
|
||||
const data = KanbanArkTypeCanManageKanban(props);
|
||||
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(data.goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
|
||||
.catch(logError);
|
||||
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for CanAddTask middleware');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (permissions.hasPermissions(GoalPermissions.KANBAN_CAN_VIEW)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
export function goalIdFromParam(req: Request): number | null {
|
||||
const goalId = Number(req.params.goalId);
|
||||
return goalId && !isNaN(goalId) ? goalId : null;
|
||||
}
|
||||
|
||||
export function goalIdFromBody(req: Request): number | null {
|
||||
const goalId = Number(req.body?.goalId);
|
||||
return goalId && !isNaN(goalId) ? goalId : null;
|
||||
}
|
||||
|
||||
export async function goalIdFromStatusBody(req: Request): Promise<number | null> {
|
||||
const statusId = Number(req.body?.id);
|
||||
if (!statusId || isNaN(statusId)) return null;
|
||||
|
||||
const status = await req.appUser.kanbanManager.repository.fetchStatus(statusId);
|
||||
return status?.goal_id ?? null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
import { logError } from '../../../utils/api';
|
||||
import type { RequireKanbanPermissionArgs } from '../types';
|
||||
|
||||
export function requireKanbanPermission({ anyOf, resolveGoalId }: RequireKanbanPermissionArgs) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const goalId = await resolveGoalId(req);
|
||||
if (!goalId) return res.status(400).end();
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
|
||||
.catch(logError);
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not resolve kanban permissions');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (anyOf.some((permission) => permissions.hasPermissions(permission))) return next();
|
||||
return res.status(403).end();
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type } from 'arktype';
|
||||
import type { Request } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { StringToNumber } from '../../types/app.types';
|
||||
import type { GoalPermissionType } from '../../types/auth.types';
|
||||
|
||||
// ============ Arktype schemas ============
|
||||
|
||||
@@ -102,11 +104,13 @@ export const KanbanArkTypeUpdateTasksOrder = type({
|
||||
|
||||
export type KanbanArgUpdateTasksOrder = typeof KanbanArkTypeUpdateTasksOrder.infer;
|
||||
|
||||
export const KanbanArkTypeCanManageKanban = type({
|
||||
goalId: NumberFromString,
|
||||
});
|
||||
export type KanbanGoalIdResolver = (req: Request) => Promise<number | null> | number | null;
|
||||
|
||||
export type KanbanArgCanManageKanban = typeof KanbanArkTypeCanManageKanban.infer;
|
||||
export type RequireKanbanPermissionArgs = {
|
||||
/** the caller must hold at least ONE of these */
|
||||
anyOf: GoalPermissionType[];
|
||||
resolveGoalId: KanbanGoalIdResolver;
|
||||
};
|
||||
|
||||
// ============ Deprecated Zod schemas ============
|
||||
|
||||
|
||||
@@ -5,15 +5,24 @@ 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 { generateLetters, generateString } from '../../utils/helpers'
|
||||
import AuthModel from '../auth/AuthModel'
|
||||
import { GoalsRepository } from '../goals/GoalsRepository'
|
||||
import { OrganizationRepository } from '../organizations/OrganizationRepository'
|
||||
import { createSsoProvider } from './providers/provider-factory'
|
||||
import { SsoRepository } from './SsoRepository'
|
||||
import { parseSamlMetadata } from './saml-metadata-parser'
|
||||
import { generateLoginCode, stripSecrets, validateMetadataUrl } from './sso.utils'
|
||||
import { SsoConfigArkTypeCreate, SsoConfigArkTypeUpdate } from './types'
|
||||
import { generateLoginCode, isSsoDomainVerified, stripSecrets, validateMetadataUrl } from './sso.utils'
|
||||
import {
|
||||
SsoConfigArkTypeCreate,
|
||||
SsoConfigArkTypeUpdate,
|
||||
SsoDomainNotVerifiedError,
|
||||
type ApplySsoIdpEmailArgs,
|
||||
type ResolveSsoUserArgs,
|
||||
type ResolveSsoUserResult,
|
||||
type SsoCallbackError,
|
||||
} from './types'
|
||||
import type { UserDbRecord } from '../../types/auth.types'
|
||||
|
||||
export class SsoController {
|
||||
private readonly ssoRepo = new SsoRepository()
|
||||
@@ -21,6 +30,103 @@ export class SsoController {
|
||||
private readonly orgRepo = new OrganizationRepository()
|
||||
private readonly goalsRepo = new GoalsRepository()
|
||||
|
||||
private async resolveLogin(preferredUsername?: string): Promise<string> {
|
||||
const base = preferredUsername?.trim().slice(0, 50)
|
||||
if (!base) return generateString(7)
|
||||
|
||||
if (!(await this.authModel.getUserByLogin(base))) return base
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const suffix = `.${generateLetters(3)}`
|
||||
const candidate = `${base.slice(0, 50 - suffix.length)}${suffix}`
|
||||
if (!(await this.authModel.getUserByLogin(candidate))) return candidate
|
||||
}
|
||||
|
||||
return generateString(7)
|
||||
}
|
||||
|
||||
private redirectSsoError(res: Response, error: SsoCallbackError) {
|
||||
return res.redirect(`${process.env.APP_URL}/login?sso_error=${error}`)
|
||||
}
|
||||
|
||||
private async createSsoUser(args: ResolveSsoUserArgs): Promise<UserDbRecord | false> {
|
||||
const password = generateString(16)
|
||||
const login = await this.resolveLogin(args.preferredUsername)
|
||||
const id = await this.authModel.registerUserInDb({
|
||||
login,
|
||||
email: args.email,
|
||||
password: hashSync(password, 10),
|
||||
block: 0,
|
||||
confirmEmailCode: '',
|
||||
})
|
||||
|
||||
if (!id) {
|
||||
$logger.error('Failed to create user during SSO login')
|
||||
return false
|
||||
}
|
||||
|
||||
const personalOrgSlug = `org-${crypto.randomUUID().slice(0, 8)}`
|
||||
const personalOrg = await this.orgRepo.create({ name: `${login}'s workspace`, slug: personalOrgSlug }, id, true)
|
||||
if (personalOrg) {
|
||||
await this.orgRepo.addMember(personalOrg.id, args.email, 'owner')
|
||||
await this.goalsRepo.createInboxGoal({ ownerId: id, organizationId: personalOrg.id })
|
||||
}
|
||||
|
||||
return await this.authModel.fetchUserById(id)
|
||||
}
|
||||
|
||||
private async applyIdpEmail(args: ApplySsoIdpEmailArgs): Promise<'ok' | 'email_in_use' | 'error'> {
|
||||
if (args.user.email.toLowerCase() === args.email) return 'ok'
|
||||
|
||||
const taken = await this.authModel.getUserByLogin(args.email, true)
|
||||
if (taken && taken.id !== args.user.id) return 'email_in_use'
|
||||
|
||||
const result = await this.authModel.updateUserEmail({
|
||||
userId: args.user.id,
|
||||
oldEmail: args.user.email,
|
||||
email: args.email,
|
||||
})
|
||||
if (result === 'conflict') return 'email_in_use'
|
||||
if (result !== 'ok') return 'error'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
private async resolveSsoUser(args: ResolveSsoUserArgs): Promise<ResolveSsoUserResult> {
|
||||
const identity = await this.ssoRepo.findIdentity({
|
||||
ssoConfigId: args.ssoConfigId,
|
||||
externalId: args.externalId,
|
||||
})
|
||||
|
||||
if (identity) {
|
||||
const user = await this.authModel.fetchUserById(identity.userId)
|
||||
if (!user) return { ok: false, error: 'authentication_failed' }
|
||||
|
||||
const emailResult = await this.applyIdpEmail({ user, email: args.email })
|
||||
if (emailResult === 'email_in_use') return { ok: false, error: 'email_in_use' }
|
||||
if (emailResult !== 'ok') return { ok: false, error: 'authentication_failed' }
|
||||
|
||||
const refreshed = await this.authModel.fetchUserById(user.id)
|
||||
if (!refreshed) return { ok: false, error: 'authentication_failed' }
|
||||
return { ok: true, user: refreshed }
|
||||
}
|
||||
|
||||
const existing = await this.authModel.getUserByLogin(args.email, true)
|
||||
if (existing) {
|
||||
const linked = await this.ssoRepo.findIdentityByUser({
|
||||
ssoConfigId: args.ssoConfigId,
|
||||
userId: existing.id,
|
||||
})
|
||||
if (linked && linked.externalId !== args.externalId) {
|
||||
return { ok: false, error: 'email_in_use' }
|
||||
}
|
||||
return { ok: true, user: existing }
|
||||
}
|
||||
|
||||
const created = await this.createSsoUser(args)
|
||||
if (!created) return { ok: false, error: 'authentication_failed' }
|
||||
return { ok: true, user: created }
|
||||
}
|
||||
|
||||
initiateLogin = async (req: Request, res: Response) => {
|
||||
const configId = Number(req.params.configId)
|
||||
if (!configId) return res.status(400).tvJson({ message: 'Invalid config ID' })
|
||||
@@ -28,6 +134,10 @@ export class SsoController {
|
||||
const config = await this.ssoRepo.findEnabledById(configId)
|
||||
if (!config) return res.status(404).tvJson({ message: 'SSO provider not found' })
|
||||
|
||||
if (!isSsoDomainVerified(config)) {
|
||||
return res.redirect(`${process.env.APP_URL}/login?sso_error=domain_unverified`)
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = createSsoProvider(config)
|
||||
const relayState = JSON.stringify({ platform: req.query.platform || '' })
|
||||
@@ -45,50 +155,40 @@ export class SsoController {
|
||||
const config = await this.ssoRepo.findEnabledById(configId)
|
||||
if (!config) return res.status(404).tvJson({ message: 'SSO provider not found' })
|
||||
|
||||
if (!isSsoDomainVerified(config)) {
|
||||
return res.redirect(`${process.env.APP_URL}/login?sso_error=domain_unverified`)
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = createSsoProvider(config)
|
||||
const ssoResult = await provider.handleCallback(req)
|
||||
|
||||
if (config.emailDomainRestriction) {
|
||||
const domain = ssoResult.email.split('@')[1]
|
||||
if (domain !== config.emailDomainRestriction) {
|
||||
return res.status(403).tvJson({ message: 'Email domain not allowed for this SSO provider' })
|
||||
}
|
||||
if (!config.emailDomainRestriction) {
|
||||
return this.redirectSsoError(res, 'authentication_failed')
|
||||
}
|
||||
|
||||
let userData = await this.authModel.getUserByLogin(ssoResult.email, isEmail(ssoResult.email))
|
||||
|
||||
if (!userData) {
|
||||
const password = generateString(16)
|
||||
const login = generateString(7)
|
||||
const id = await this.authModel.registerUserInDb({
|
||||
login,
|
||||
email: ssoResult.email,
|
||||
password: hashSync(password, 10),
|
||||
block: 0,
|
||||
confirmEmailCode: '',
|
||||
})
|
||||
|
||||
if (!id) {
|
||||
$logger.error('Failed to create user during SSO login')
|
||||
return res.status(500).tvJson({ message: 'Failed to create user' })
|
||||
}
|
||||
|
||||
const personalOrgSlug = `org-${crypto.randomUUID().slice(0, 8)}`
|
||||
const personalOrg = await this.orgRepo.create({ name: `${login}'s workspace`, slug: personalOrgSlug }, id, true)
|
||||
if (personalOrg) {
|
||||
await this.orgRepo.addMember(personalOrg.id, ssoResult.email, 'owner')
|
||||
await this.goalsRepo.createInboxGoal({ ownerId: id, organizationId: personalOrg.id })
|
||||
}
|
||||
|
||||
userData = await this.authModel.getUserByLogin(ssoResult.email, isEmail(ssoResult.email))
|
||||
const domain = ssoResult.email.split('@')[1]
|
||||
if (domain !== config.emailDomainRestriction) {
|
||||
return res.status(403).tvJson({ message: 'Email domain not allowed for this SSO provider' })
|
||||
}
|
||||
|
||||
if (!userData) {
|
||||
return res.status(500).tvJson({ message: 'Failed to resolve user after SSO login' })
|
||||
const resolved = await this.resolveSsoUser({
|
||||
ssoConfigId: config.id,
|
||||
email: ssoResult.email,
|
||||
externalId: ssoResult.externalId,
|
||||
preferredUsername: ssoResult.preferredUsername,
|
||||
})
|
||||
if (!resolved.ok) {
|
||||
return this.redirectSsoError(res, resolved.error)
|
||||
}
|
||||
|
||||
await this.orgRepo.addMember(config.organizationId, ssoResult.email, config.defaultOrgRole)
|
||||
const userData = resolved.user
|
||||
|
||||
if (userData.block && !userData.confirm_email_code) {
|
||||
return this.redirectSsoError(res, 'account_blocked')
|
||||
}
|
||||
|
||||
await this.orgRepo.addMember(config.organizationId, userData.email, config.defaultOrgRole)
|
||||
|
||||
await this.ssoRepo.upsertIdentity({
|
||||
userId: userData.id,
|
||||
@@ -132,7 +232,7 @@ export class SsoController {
|
||||
if (!domain) return res.tvJson(null)
|
||||
|
||||
const config = await this.ssoRepo.findEnabledByDomain(domain)
|
||||
if (!config) return res.tvJson(null)
|
||||
if (!config || !isSsoDomainVerified(config)) return res.tvJson(null)
|
||||
|
||||
return res.tvJson({
|
||||
id: config.id,
|
||||
@@ -165,11 +265,18 @@ export class SsoController {
|
||||
return res.status(400).send(out.summary)
|
||||
}
|
||||
|
||||
const existing = await this.ssoRepo.findEnabledByDomain(out.emailDomainRestriction)
|
||||
if (existing) {
|
||||
const domain = out.emailDomainRestriction.toLowerCase()
|
||||
|
||||
const sameOrg = await this.ssoRepo.findByDomainAndOrg({ domain, organizationId: out.organizationId })
|
||||
if (sameOrg) {
|
||||
return res.status(409).tvJson({ message: 'SSO config for this domain already exists' })
|
||||
}
|
||||
|
||||
const verified = await this.ssoRepo.findVerifiedByDomain(domain)
|
||||
if (verified) {
|
||||
return res.status(409).tvJson({ message: 'This domain is already verified by another organization' })
|
||||
}
|
||||
|
||||
const config = await req.appUser.ssoManager.createConfig(out).catch(logError)
|
||||
if (!config) {
|
||||
return res.status(500).tvJson({ message: 'Failed to create SSO config' })
|
||||
@@ -186,8 +293,33 @@ export class SsoController {
|
||||
return res.status(400).send(out.summary)
|
||||
}
|
||||
|
||||
const config = await req.appUser.ssoManager.updateConfig(configId, out).catch(logError)
|
||||
return res.tvJson(config ? stripSecrets(config) : null)
|
||||
if (out.emailDomainRestriction) {
|
||||
const domain = out.emailDomainRestriction.toLowerCase()
|
||||
|
||||
const verified = await this.ssoRepo.findVerifiedByDomain(domain)
|
||||
if (verified && verified.id !== configId) {
|
||||
return res.status(409).tvJson({ message: 'This domain is already verified by another organization' })
|
||||
}
|
||||
|
||||
const current = await this.ssoRepo.findById(configId)
|
||||
if (current) {
|
||||
const sameOrg = await this.ssoRepo.findByDomainAndOrg({ domain, organizationId: current.organizationId })
|
||||
if (sameOrg && sameOrg.id !== configId) {
|
||||
return res.status(409).tvJson({ message: 'SSO config for this domain already exists' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await req.appUser.ssoManager.updateConfig(configId, out)
|
||||
return res.tvJson(config ? stripSecrets(config) : null)
|
||||
} catch (error) {
|
||||
if (error instanceof SsoDomainNotVerifiedError) {
|
||||
return res.status(403).tvJson({ message: 'Domain is not verified' })
|
||||
}
|
||||
logError(error)
|
||||
return res.tvJson(null)
|
||||
}
|
||||
}
|
||||
|
||||
parseMetadata = async (req: Request, res: Response) => {
|
||||
@@ -213,6 +345,28 @@ export class SsoController {
|
||||
}
|
||||
}
|
||||
|
||||
startDomainVerification = async (req: Request, res: Response) => {
|
||||
const configId = Number(req.params.configId)
|
||||
if (!configId) return res.status(400).end()
|
||||
|
||||
const result = await req.appUser.ssoManager.startDomainVerification(configId).catch(logError)
|
||||
if (!result) {
|
||||
return res.status(404).tvJson({ message: 'SSO config not found' })
|
||||
}
|
||||
return res.tvJson(result)
|
||||
}
|
||||
|
||||
checkDomainVerification = async (req: Request, res: Response) => {
|
||||
const configId = Number(req.params.configId)
|
||||
if (!configId) return res.status(400).end()
|
||||
|
||||
const result = await req.appUser.ssoManager.checkDomainVerification(configId).catch(logError)
|
||||
if (!result) {
|
||||
return res.status(404).tvJson({ message: 'SSO config not found' })
|
||||
}
|
||||
return res.tvJson(result)
|
||||
}
|
||||
|
||||
generateScimToken = async (req: Request, res: Response) => {
|
||||
const configId = Number(req.params.configId)
|
||||
if (!configId) return res.status(400).end()
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import type { AppUser } from '../../core/AppUser'
|
||||
import { encrypt, encryptField } from '../../utils/crypto'
|
||||
import { SsoRepository } from './SsoRepository'
|
||||
import { SSO_SECRET_FIELDS } from './sso.utils'
|
||||
import type { SsoConfigArgCreate, SsoConfigArgUpdate } from './types'
|
||||
import {
|
||||
SSO_SECRET_FIELDS,
|
||||
generateDomainVerifyToken,
|
||||
isSsoDomainVerified,
|
||||
isTrustedSsoDomain,
|
||||
proveSsoDomainOwnership,
|
||||
ssoDomainVerifyDnsRecord,
|
||||
ssoDomainVerifyHttpUrl,
|
||||
} from './sso.utils'
|
||||
import {
|
||||
SsoDomainNotVerifiedError,
|
||||
type CheckDomainVerificationResult,
|
||||
type SsoConfigArgCreate,
|
||||
type SsoConfigArgUpdate,
|
||||
type StartDomainVerificationResult,
|
||||
} from './types'
|
||||
|
||||
export class SsoManager {
|
||||
public readonly repository: SsoRepository
|
||||
@@ -18,11 +32,14 @@ export class SsoManager {
|
||||
}
|
||||
|
||||
async createConfig(data: SsoConfigArgCreate) {
|
||||
const domain = data.emailDomainRestriction.toLowerCase()
|
||||
const trusted = isTrustedSsoDomain(domain)
|
||||
|
||||
return await this.repository.create({
|
||||
organizationId: data.organizationId,
|
||||
protocol: data.protocol,
|
||||
displayName: data.displayName,
|
||||
enabled: data.enabled ?? 1,
|
||||
enabled: trusted ? (data.enabled ?? 1) : 0,
|
||||
samlEntryPoint: data.samlEntryPoint ?? null,
|
||||
samlIssuer: data.samlIssuer ?? null,
|
||||
samlCert: encryptField(data.samlCert),
|
||||
@@ -36,12 +53,22 @@ export class SsoManager {
|
||||
oidcCallbackUrl: data.oidcCallbackUrl ?? null,
|
||||
oidcScope: data.oidcScope ?? null,
|
||||
defaultOrgRole: data.defaultOrgRole ?? 'member',
|
||||
emailDomainRestriction: data.emailDomainRestriction.toLowerCase(),
|
||||
emailDomainRestriction: domain,
|
||||
domainVerifyToken: generateDomainVerifyToken(),
|
||||
domainVerifiedAt: trusted ? new Date() : null,
|
||||
})
|
||||
}
|
||||
|
||||
async updateConfig(configId: number, data: SsoConfigArgUpdate) {
|
||||
const encrypted: Partial<SsoConfigArgUpdate> = { ...data }
|
||||
const current = await this.repository.findById(configId)
|
||||
if (!current) return null
|
||||
|
||||
const encrypted: Partial<SsoConfigArgUpdate> & {
|
||||
domainVerifyToken?: string
|
||||
domainVerifiedAt?: Date | null
|
||||
enabled?: number
|
||||
} = { ...data }
|
||||
|
||||
for (const field of SSO_SECRET_FIELDS) {
|
||||
if (field in encrypted) {
|
||||
if (encrypted[field]) {
|
||||
@@ -51,9 +78,87 @@ export class SsoManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.emailDomainRestriction) {
|
||||
const domain = data.emailDomainRestriction.toLowerCase()
|
||||
encrypted.emailDomainRestriction = domain
|
||||
if (domain !== current.emailDomainRestriction) {
|
||||
const trusted = isTrustedSsoDomain(domain)
|
||||
encrypted.domainVerifyToken = generateDomainVerifyToken()
|
||||
encrypted.domainVerifiedAt = trusted ? new Date() : null
|
||||
if (!trusted) encrypted.enabled = 0
|
||||
await this.repository.deleteIdentitiesByConfig(configId)
|
||||
}
|
||||
}
|
||||
|
||||
const nextDomain = encrypted.emailDomainRestriction ?? current.emailDomainRestriction
|
||||
const nextVerifiedAt = 'domainVerifiedAt' in encrypted
|
||||
? encrypted.domainVerifiedAt
|
||||
: current.domainVerifiedAt
|
||||
const wouldBeVerified = isTrustedSsoDomain(nextDomain) || !!nextVerifiedAt
|
||||
|
||||
if (data.enabled === 1 && !wouldBeVerified) {
|
||||
throw new SsoDomainNotVerifiedError()
|
||||
}
|
||||
|
||||
return await this.repository.update(configId, encrypted)
|
||||
}
|
||||
|
||||
async startDomainVerification(configId: number): Promise<StartDomainVerificationResult | null> {
|
||||
const config = await this.repository.findById(configId)
|
||||
if (!config) return null
|
||||
|
||||
let token = config.domainVerifyToken
|
||||
if (!token) {
|
||||
token = generateDomainVerifyToken()
|
||||
const updated = await this.repository.update(configId, { domainVerifyToken: token })
|
||||
if (!updated) return null
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
dnsRecord: ssoDomainVerifyDnsRecord(token),
|
||||
httpUrl: ssoDomainVerifyHttpUrl(config.emailDomainRestriction),
|
||||
isDomainVerified: isSsoDomainVerified({ ...config, domainVerifyToken: token }),
|
||||
isDomainTrusted: isTrustedSsoDomain(config.emailDomainRestriction),
|
||||
}
|
||||
}
|
||||
|
||||
async checkDomainVerification(configId: number): Promise<CheckDomainVerificationResult | null> {
|
||||
const config = await this.repository.findById(configId)
|
||||
if (!config) return null
|
||||
|
||||
if (!config.domainVerifyToken) {
|
||||
return {
|
||||
verified: isSsoDomainVerified(config),
|
||||
method: isTrustedSsoDomain(config.emailDomainRestriction) ? 'trusted' : null
|
||||
}
|
||||
}
|
||||
|
||||
const method = await proveSsoDomainOwnership({
|
||||
domain: config.emailDomainRestriction,
|
||||
token: config.domainVerifyToken,
|
||||
})
|
||||
|
||||
if (!method) {
|
||||
return { verified: isSsoDomainVerified(config), method: null }
|
||||
}
|
||||
|
||||
if (!config.domainVerifiedAt || method === 'trusted') {
|
||||
const updated = await this.repository.update(configId, {
|
||||
domainVerifiedAt: new Date(),
|
||||
enabled: 1,
|
||||
})
|
||||
// The partial unique index rejects a second verified config for the same
|
||||
// domain another organization proved ownership first.
|
||||
if (!updated) {
|
||||
return { verified: false, method: null }
|
||||
}
|
||||
}
|
||||
|
||||
return { verified: true, method }
|
||||
}
|
||||
|
||||
async deleteConfig(configId: number) {
|
||||
return await this.repository.delete(configId)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, isNotNull } from 'drizzle-orm'
|
||||
import {
|
||||
SsoConfigsSchema,
|
||||
SsoIdentitiesSchema,
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
} from 'taskview-db-schemas'
|
||||
import { Database } from '../../modules/db'
|
||||
import { callWithCatch } from '../../utils/helpers'
|
||||
import type {
|
||||
FindSsoConfigByDomainAndOrgArgs,
|
||||
FindSsoIdentityArgs,
|
||||
FindSsoIdentityByUserArgs,
|
||||
UpsertSsoIdentityArgs,
|
||||
} from './types'
|
||||
|
||||
export class SsoRepository {
|
||||
private readonly db: Database
|
||||
@@ -16,6 +22,38 @@ export class SsoRepository {
|
||||
this.db = Database.getInstance()
|
||||
}
|
||||
|
||||
async findVerifiedByDomain(domain: string): Promise<SsoConfigsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SsoConfigsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(SsoConfigsSchema.emailDomainRestriction, domain.toLowerCase()),
|
||||
isNotNull(SsoConfigsSchema.domainVerifiedAt),
|
||||
)
|
||||
)
|
||||
)
|
||||
if (!result || result.length === 0) return null
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async findByDomainAndOrg(args: FindSsoConfigByDomainAndOrgArgs): Promise<SsoConfigsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SsoConfigsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(SsoConfigsSchema.emailDomainRestriction, args.domain.toLowerCase()),
|
||||
eq(SsoConfigsSchema.organizationId, args.organizationId),
|
||||
)
|
||||
)
|
||||
)
|
||||
if (!result || result.length === 0) return null
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async findEnabledByDomain(domain: string): Promise<SsoConfigsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
@@ -146,12 +184,41 @@ export class SsoRepository {
|
||||
return !!(result?.rowCount && result.rowCount > 0)
|
||||
}
|
||||
|
||||
async upsertIdentity(data: {
|
||||
userId: number
|
||||
ssoConfigId: number
|
||||
externalId: string
|
||||
email: string
|
||||
}): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
|
||||
async findIdentity(args: FindSsoIdentityArgs): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
|
||||
const result = await this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SsoIdentitiesSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(SsoIdentitiesSchema.ssoConfigId, args.ssoConfigId),
|
||||
eq(SsoIdentitiesSchema.externalId, args.externalId),
|
||||
)
|
||||
)
|
||||
if (result.length === 0) return null
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async findIdentityByUser(args: FindSsoIdentityByUserArgs): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
|
||||
const result = await this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SsoIdentitiesSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(SsoIdentitiesSchema.ssoConfigId, args.ssoConfigId),
|
||||
eq(SsoIdentitiesSchema.userId, args.userId),
|
||||
)
|
||||
)
|
||||
if (result.length === 0) return null
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async deleteIdentitiesByConfig(ssoConfigId: number): Promise<void> {
|
||||
await this.db.dbDrizzle
|
||||
.delete(SsoIdentitiesSchema)
|
||||
.where(eq(SsoIdentitiesSchema.ssoConfigId, ssoConfigId))
|
||||
}
|
||||
|
||||
async upsertIdentity(data: UpsertSsoIdentityArgs): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
|
||||
const existing = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
|
||||
@@ -32,6 +32,8 @@ export default class SsoRoutes implements Routable {
|
||||
this.router.post('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.createConfig)
|
||||
this.router.patch('/admin/configs/:configId', [IsLoggedIn, IsSsoConfigAdmin], this.controller.updateConfig)
|
||||
this.router.delete('/admin/configs/:configId', [IsLoggedIn, IsSsoConfigAdmin], this.controller.deleteConfig)
|
||||
this.router.post('/admin/configs/:configId/verify-domain', [IsLoggedIn, IsSsoConfigAdmin], this.controller.startDomainVerification)
|
||||
this.router.post('/admin/configs/:configId/verify-domain/check', [IsLoggedIn, IsSsoConfigAdmin], this.controller.checkDomainVerification)
|
||||
this.router.post('/admin/configs/:configId/scim-token', [IsLoggedIn, IsSsoConfigAdmin], this.controller.generateScimToken)
|
||||
this.router.patch('/admin/configs/:configId/scim', [IsLoggedIn, IsSsoConfigAdmin], this.controller.toggleScim)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { deriveSamlEmail } from '../sso.utils'
|
||||
|
||||
const EMAIL_NAMEID_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
|
||||
const PERSISTENT_NAMEID_FORMAT = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
|
||||
const EMAIL_CLAIM = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
|
||||
|
||||
describe('deriveSamlEmail', () => {
|
||||
it('takes the email attribute and lowercases it', () => {
|
||||
expect(deriveSamlEmail({ email: 'User@Company.com', nameID: 'abc' })).toBe('user@company.com')
|
||||
})
|
||||
|
||||
it('falls back to the xmlsoap emailaddress claim', () => {
|
||||
expect(deriveSamlEmail({ [EMAIL_CLAIM]: 'a@b.com', nameID: 'abc' })).toBe('a@b.com')
|
||||
})
|
||||
|
||||
it('uses nameID only when the NameID Format is emailAddress', () => {
|
||||
expect(deriveSamlEmail({
|
||||
nameID: 'user@company.com',
|
||||
nameIDFormat: EMAIL_NAMEID_FORMAT,
|
||||
})).toBe('user@company.com')
|
||||
})
|
||||
|
||||
it('does not use nameID for a non-email NameID Format', () => {
|
||||
expect(deriveSamlEmail({
|
||||
nameID: 'user@company.com',
|
||||
nameIDFormat: PERSISTENT_NAMEID_FORMAT,
|
||||
})).toBeNull()
|
||||
})
|
||||
|
||||
it('does not use nameID when no format is provided', () => {
|
||||
expect(deriveSamlEmail({ nameID: 'user@company.com' })).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers the email attribute over an emailAddress-format nameID', () => {
|
||||
expect(deriveSamlEmail({
|
||||
email: 'attr@company.com',
|
||||
nameID: 'name@company.com',
|
||||
nameIDFormat: EMAIL_NAMEID_FORMAT,
|
||||
})).toBe('attr@company.com')
|
||||
})
|
||||
|
||||
it('returns null for a blank or non-string email attribute', () => {
|
||||
expect(deriveSamlEmail({ email: ' ', nameID: 'abc' })).toBeNull()
|
||||
expect(deriveSamlEmail({ email: 123, nameID: 'abc' })).toBeNull()
|
||||
expect(deriveSamlEmail({ nameID: 'abc' })).toBeNull()
|
||||
expect(deriveSamlEmail({})).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { randomBytes } from 'crypto'
|
||||
import * as client from 'openid-client'
|
||||
import type { Request, Response } from 'express'
|
||||
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import { PublicApiUrl } from '../../../modules/public-url'
|
||||
import type { SsoProvider, SsoAuthResult } from './sso-provider.interface'
|
||||
|
||||
export class OidcProvider implements SsoProvider {
|
||||
@@ -28,7 +29,12 @@ export class OidcProvider implements SsoProvider {
|
||||
return this.oidcConfig
|
||||
}
|
||||
|
||||
async initiateLogin(_req: Request, res: Response, relayState?: string): Promise<void> {
|
||||
private resolveCallbackUrl(req: Request): string {
|
||||
return this.config.oidcCallbackUrl?.trim()
|
||||
|| `${PublicApiUrl.base(req)}/module/sso/callback/${this.config.id}`
|
||||
}
|
||||
|
||||
async initiateLogin(req: Request, res: Response, relayState?: string): Promise<void> {
|
||||
const config = await this.getOidcConfig()
|
||||
const scope = this.config.oidcScope ?? 'openid email profile'
|
||||
const codeVerifier = client.randomPKCECodeVerifier()
|
||||
@@ -63,7 +69,7 @@ export class OidcProvider implements SsoProvider {
|
||||
})
|
||||
|
||||
const params = new URLSearchParams({
|
||||
redirect_uri: this.config.oidcCallbackUrl!,
|
||||
redirect_uri: this.resolveCallbackUrl(req),
|
||||
scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
@@ -110,7 +116,7 @@ export class OidcProvider implements SsoProvider {
|
||||
throw new Error('CSRF state mismatch — possible CSRF attack')
|
||||
}
|
||||
|
||||
const callbackOrigin = new URL(this.config.oidcCallbackUrl!).origin
|
||||
const callbackOrigin = new URL(this.resolveCallbackUrl(req)).origin
|
||||
const currentUrl = new URL(req.originalUrl, callbackOrigin)
|
||||
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
|
||||
pkceCodeVerifier: codeVerifier,
|
||||
@@ -128,6 +134,7 @@ export class OidcProvider implements SsoProvider {
|
||||
email: (claims.email as string).toLowerCase(),
|
||||
externalId: claims.sub,
|
||||
displayName: claims.name as string | undefined,
|
||||
preferredUsername: claims.preferred_username as string | undefined,
|
||||
provider: `oidc-${this.config.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { SAML, ValidateInResponseTo } from '@node-saml/node-saml'
|
||||
import type { Request, Response } from 'express'
|
||||
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import { PublicApiUrl } from '../../../modules/public-url'
|
||||
import { deriveSamlEmail } from '../sso.utils'
|
||||
import type { SamlOptionsArgs } from '../types'
|
||||
import type { SsoProvider, SsoAuthResult } from './sso-provider.interface'
|
||||
import { SamlDbCacheProvider } from './saml-cache-provider'
|
||||
|
||||
@@ -11,12 +14,12 @@ function normalizeCert(cert: string): string {
|
||||
.replace(/[\s\r\n]/g, '')
|
||||
}
|
||||
|
||||
function buildSamlOptions(config: SsoConfigsSchemaTypeForSelect, mode: 'assertion' | 'response') {
|
||||
function buildSamlOptions({ config, mode, callbackUrl }: SamlOptionsArgs) {
|
||||
return {
|
||||
entryPoint: config.samlEntryPoint!,
|
||||
issuer: config.samlIssuer!,
|
||||
idpCert: normalizeCert(config.samlCert!),
|
||||
callbackUrl: config.samlCallbackUrl!,
|
||||
callbackUrl,
|
||||
wantAssertionsSigned: mode === 'assertion',
|
||||
wantAuthnResponseSigned: mode === 'response',
|
||||
validateInResponseTo: ValidateInResponseTo.always,
|
||||
@@ -31,29 +34,38 @@ function buildSamlOptions(config: SsoConfigsSchemaTypeForSelect, mode: 'assertio
|
||||
}
|
||||
|
||||
export class SamlProvider implements SsoProvider {
|
||||
private readonly samlAssertion: SAML
|
||||
private readonly samlResponse: SAML
|
||||
private readonly config: SsoConfigsSchemaTypeForSelect
|
||||
|
||||
constructor(config: SsoConfigsSchemaTypeForSelect) {
|
||||
this.config = config
|
||||
this.samlAssertion = new SAML(buildSamlOptions(config, 'assertion'))
|
||||
this.samlResponse = new SAML(buildSamlOptions(config, 'response'))
|
||||
}
|
||||
|
||||
private resolveCallbackUrl(req: Request): string {
|
||||
return this.config.samlCallbackUrl?.trim()
|
||||
|| `${PublicApiUrl.base(req)}/module/sso/callback/${this.config.id}`
|
||||
}
|
||||
|
||||
async initiateLogin(req: Request, res: Response, relayState?: string): Promise<void> {
|
||||
const loginUrl = await this.samlAssertion.getAuthorizeUrlAsync(relayState ?? '', req.hostname, {})
|
||||
const saml = new SAML(buildSamlOptions({
|
||||
config: this.config,
|
||||
mode: 'assertion',
|
||||
callbackUrl: this.resolveCallbackUrl(req),
|
||||
}))
|
||||
const loginUrl = await saml.getAuthorizeUrlAsync(relayState ?? '', req.hostname, {})
|
||||
res.redirect(loginUrl)
|
||||
}
|
||||
|
||||
async handleCallback(req: Request): Promise<SsoAuthResult> {
|
||||
const callbackUrl = this.resolveCallbackUrl(req)
|
||||
let profile
|
||||
|
||||
try {
|
||||
const result = await this.samlAssertion.validatePostResponseAsync(req.body)
|
||||
const saml = new SAML(buildSamlOptions({ config: this.config, mode: 'assertion', callbackUrl }))
|
||||
const result = await saml.validatePostResponseAsync(req.body)
|
||||
profile = result.profile
|
||||
} catch {
|
||||
const result = await this.samlResponse.validatePostResponseAsync(req.body)
|
||||
const saml = new SAML(buildSamlOptions({ config: this.config, mode: 'response', callbackUrl }))
|
||||
const result = await saml.validatePostResponseAsync(req.body)
|
||||
profile = result.profile
|
||||
}
|
||||
|
||||
@@ -61,14 +73,13 @@ export class SamlProvider implements SsoProvider {
|
||||
throw new Error('SAML response missing nameID')
|
||||
}
|
||||
|
||||
const email = (
|
||||
profile.email
|
||||
?? profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']
|
||||
?? profile.nameID
|
||||
) as string
|
||||
const email = deriveSamlEmail(profile as Record<string, unknown>)
|
||||
if (!email) {
|
||||
throw new Error('SAML response missing email attribute')
|
||||
}
|
||||
|
||||
return {
|
||||
email: email.toLowerCase(),
|
||||
email,
|
||||
externalId: profile.nameID,
|
||||
displayName: (profile.displayName
|
||||
?? profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name']) as string | undefined,
|
||||
|
||||
@@ -4,6 +4,7 @@ export type SsoAuthResult = {
|
||||
email: string
|
||||
externalId: string
|
||||
displayName?: string
|
||||
preferredUsername?: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,64 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
import { resolveTxt } from 'node:dns/promises'
|
||||
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import { decryptField } from '../../utils/crypto'
|
||||
import { generateString } from '../../utils/helpers'
|
||||
import type { CheckSsoDomainProofArgs, SsoDomainVerificationMethod } from './types'
|
||||
|
||||
export const SSO_SECRET_FIELDS = ['samlCert', 'samlSigningKey', 'samlSigningCert', 'oidcClientSecret'] as const
|
||||
|
||||
export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) {
|
||||
export const SSO_DOMAIN_TXT_PREFIX = 'taskview-sso-verify='
|
||||
export const SSO_DOMAIN_WELL_KNOWN_PATH = '/.well-known/taskview-sso-verify.txt'
|
||||
|
||||
export function generateDomainVerifyToken(): string {
|
||||
return `tvdom_${randomBytes(32).toString('hex')}`
|
||||
}
|
||||
|
||||
const SAML_EMAIL_NAMEID_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
|
||||
const SAML_EMAIL_CLAIM = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
|
||||
|
||||
export function deriveSamlEmail(profile: Record<string, unknown>): string | null {
|
||||
const fromAttribute = profile.email ?? profile[SAML_EMAIL_CLAIM]
|
||||
if (typeof fromAttribute === 'string' && fromAttribute.trim()) {
|
||||
return fromAttribute.trim().toLowerCase()
|
||||
}
|
||||
if (profile.nameIDFormat === SAML_EMAIL_NAMEID_FORMAT
|
||||
&& typeof profile.nameID === 'string' && profile.nameID.trim()) {
|
||||
return profile.nameID.trim().toLowerCase()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function trustedSsoDomains(): string[] {
|
||||
const raw = process.env.SSO_TRUSTED_DOMAINS
|
||||
if (!raw?.trim()) return []
|
||||
return raw
|
||||
.split(',')
|
||||
.map((domain) => domain.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function isTrustedSsoDomain(domain: string): boolean {
|
||||
return trustedSsoDomains().includes(domain.trim().toLowerCase())
|
||||
}
|
||||
|
||||
export function isSsoDomainVerified(config: SsoConfigsSchemaTypeForSelect): boolean {
|
||||
if (isTrustedSsoDomain(config.emailDomainRestriction)) return true
|
||||
return !!config.domainVerifiedAt
|
||||
}
|
||||
|
||||
export function ssoDomainVerifyHttpUrl(domain: string): string {
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'
|
||||
return `${protocol}://${domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`
|
||||
}
|
||||
|
||||
export function ssoDomainVerifyDnsRecord(token: string): string {
|
||||
return `${SSO_DOMAIN_TXT_PREFIX}${token}`
|
||||
}
|
||||
|
||||
export function toClientSsoConfig(config: SsoConfigsSchemaTypeForSelect) {
|
||||
const { samlCert, samlSigningKey, samlSigningCert, oidcClientSecret, scimToken, ...safe } = config
|
||||
const token = config.domainVerifyToken
|
||||
return {
|
||||
...safe,
|
||||
hasSamlCert: !!samlCert,
|
||||
@@ -13,9 +66,65 @@ export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) {
|
||||
hasSamlSigningCert: !!samlSigningCert,
|
||||
hasOidcClientSecret: !!oidcClientSecret,
|
||||
hasScimToken: !!scimToken,
|
||||
isDomainVerified: isSsoDomainVerified(config),
|
||||
isDomainTrusted: isTrustedSsoDomain(config.emailDomainRestriction),
|
||||
domainVerifyDnsRecord: token ? ssoDomainVerifyDnsRecord(token) : null,
|
||||
domainVerifyHttpUrl: ssoDomainVerifyHttpUrl(config.emailDomainRestriction),
|
||||
}
|
||||
}
|
||||
|
||||
export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) {
|
||||
return toClientSsoConfig(config)
|
||||
}
|
||||
|
||||
function tokenMatchesProof(body: string, token: string): boolean {
|
||||
const trimmed = body.trim()
|
||||
return trimmed === token || trimmed === ssoDomainVerifyDnsRecord(token)
|
||||
}
|
||||
|
||||
export async function checkSsoDomainDnsTxt(args: CheckSsoDomainProofArgs): Promise<boolean> {
|
||||
try {
|
||||
const records = await resolveTxt(args.domain)
|
||||
return records.some((chunks) => tokenMatchesProof(chunks.join(''), args.token))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkSsoDomainHttpFile(args: CheckSsoDomainProofArgs): Promise<boolean> {
|
||||
const urls = process.env.NODE_ENV === 'production'
|
||||
? [`https://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`]
|
||||
: [
|
||||
`https://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`,
|
||||
`http://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`,
|
||||
]
|
||||
|
||||
for (const url of urls) {
|
||||
const urlError = validateMetadataUrl(url)
|
||||
if (urlError) continue
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!response.ok) continue
|
||||
if (tokenMatchesProof(await response.text(), args.token)) return true
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function proveSsoDomainOwnership(args: CheckSsoDomainProofArgs): Promise<SsoDomainVerificationMethod | null> {
|
||||
if (isTrustedSsoDomain(args.domain)) return 'trusted'
|
||||
if (await checkSsoDomainDnsTxt(args)) return 'dns'
|
||||
if (await checkSsoDomainHttpFile(args)) return 'http'
|
||||
return null
|
||||
}
|
||||
|
||||
export function decryptSsoConfig(config: SsoConfigsSchemaTypeForSelect): SsoConfigsSchemaTypeForSelect {
|
||||
return {
|
||||
...config,
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { type } from 'arktype'
|
||||
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import type { UserDbRecord } from '../../types/auth.types'
|
||||
|
||||
export type SamlOptionsArgs = {
|
||||
config: SsoConfigsSchemaTypeForSelect
|
||||
mode: 'assertion' | 'response'
|
||||
callbackUrl: string
|
||||
}
|
||||
|
||||
export const SsoProtocols = {
|
||||
SAML: 'saml',
|
||||
@@ -52,7 +60,76 @@ export const SsoConfigArkTypeUpdate = type({
|
||||
'oidcScope?': 'string',
|
||||
|
||||
'defaultOrgRole?': "'admin' | 'member'",
|
||||
'emailDomainRestriction?': 'string',
|
||||
'emailDomainRestriction?': 'string > 0',
|
||||
})
|
||||
|
||||
export type SsoConfigArgUpdate = typeof SsoConfigArkTypeUpdate.infer
|
||||
|
||||
export type CheckSsoDomainProofArgs = {
|
||||
domain: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export type SsoDomainVerificationMethod = 'dns' | 'http' | 'trusted'
|
||||
|
||||
export type StartDomainVerificationResult = {
|
||||
token: string
|
||||
dnsRecord: string
|
||||
httpUrl: string
|
||||
isDomainVerified: boolean
|
||||
isDomainTrusted: boolean
|
||||
}
|
||||
|
||||
export type CheckDomainVerificationResult = {
|
||||
verified: boolean
|
||||
method: SsoDomainVerificationMethod | null
|
||||
}
|
||||
|
||||
export class SsoDomainNotVerifiedError extends Error {
|
||||
readonly code = 'domain_unverified'
|
||||
|
||||
constructor() {
|
||||
super('SSO domain is not verified')
|
||||
this.name = 'SsoDomainNotVerifiedError'
|
||||
}
|
||||
}
|
||||
|
||||
export type FindSsoConfigByDomainAndOrgArgs = {
|
||||
domain: string
|
||||
organizationId: number
|
||||
}
|
||||
|
||||
export type FindSsoIdentityArgs = {
|
||||
ssoConfigId: number
|
||||
externalId: string
|
||||
}
|
||||
|
||||
export type FindSsoIdentityByUserArgs = {
|
||||
ssoConfigId: number
|
||||
userId: number
|
||||
}
|
||||
|
||||
export type UpsertSsoIdentityArgs = {
|
||||
userId: number
|
||||
ssoConfigId: number
|
||||
externalId: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export type ResolveSsoUserArgs = {
|
||||
ssoConfigId: number
|
||||
email: string
|
||||
externalId: string
|
||||
preferredUsername?: string
|
||||
}
|
||||
|
||||
export type ApplySsoIdpEmailArgs = {
|
||||
user: UserDbRecord
|
||||
email: string
|
||||
}
|
||||
|
||||
export type SsoCallbackError = 'authentication_failed' | 'email_in_use' | 'account_blocked'
|
||||
|
||||
export type ResolveSsoUserResult =
|
||||
| { ok: true, user: UserDbRecord }
|
||||
| { ok: false, error: SsoCallbackError }
|
||||
|
||||
@@ -2,25 +2,14 @@ import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import { CanAddTaskNew } from './middlewares/CanAddTaskNew';
|
||||
// import { CanAddTask } from './middlewares/CanAddTask';
|
||||
// import { CanUpdateTaskStatus } from './middlewares/CanUpdateTaskStatus';
|
||||
import { CanDeleteTask } from './middlewares/CanDeleteTask';
|
||||
// import { CanUpdateTaskAssignee } from './middlewares/CanUpdateTaskAssignee';
|
||||
import { CanFetchTask } from './middlewares/CanFetchTask';
|
||||
// import { CanUpdateTaskDescription } from './middlewares/CanUpdateTaskDescription';
|
||||
// import { CanUpdateTaskNote } from './middlewares/CanUpdateTaskNote';
|
||||
// import { CanUpdateTaskDeadline } from './middlewares/CanUpdateTaskDeadline';
|
||||
// import { CanFetchSubtasks } from './middlewares/CanFetchSubtasks';
|
||||
// import { CanUpdateTaskPriority } from './middlewares/CanUpdateTaskPriority';
|
||||
// import { CanMoveTask } from './middlewares/CanMoveTask';
|
||||
// import { CanSeeTaskAssignedUsers } from './middlewares/CanSeeTaskAssignedUsers';
|
||||
import { CanFetchTaskHistory } from './middlewares/CanFetchTaskHistory';
|
||||
import { CanFetchTasks } from './middlewares/CanFetchTasks';
|
||||
import { CanRecoveryTaskHistory } from './middlewares/CanRecoveryTaskHistory';
|
||||
import { CanUpdateTask } from './middlewares/CanUpdateTask';
|
||||
import { CanUpdateTaskAssigneeNew } from './middlewares/CanUpdateTaskAssigneeNew';
|
||||
import { TasksController } from './TasksController';
|
||||
// import { MainCanCreateTaskAction } from './middlewares/MainCanCreateTaskAction';
|
||||
|
||||
export default class TasksRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
import { GoalPermissions } from '../../../types/auth.types';
|
||||
import { ALL_TASKS_LIST_ID, DEFAULT_ID } from '../../../types/tasks.types';
|
||||
import { logError } from '../../../utils/api';
|
||||
|
||||
export const CanAddTask = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const listId = req.body.componentId;
|
||||
|
||||
if (!listId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
let permissions;
|
||||
if (Number(listId) === ALL_TASKS_LIST_ID && req.body.goalId && req.body.goalId !== DEFAULT_ID) {
|
||||
permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(Number(req.body.goalId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
|
||||
.catch(logError);
|
||||
} else {
|
||||
permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(Number(listId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASKLIST)
|
||||
.catch(logError);
|
||||
}
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for CanAddTask middleware');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (
|
||||
permissions.hasPermissions(GoalPermissions.COMPONENT_CAN_ADD_TASKS) ||
|
||||
permissions.hasPermissions(GoalPermissions.TASKS_CAN_ADD_SUBTASKS)
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
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 CanFetchSubtasks = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const taskId = req.query.taskId;
|
||||
|
||||
if (!taskId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
|
||||
.catch(logError);
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for CanFetchSubtasks');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_SUBTASKS)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { GoalPermissions } from '../../../types/auth.types';
|
||||
import { logError } from '../../../utils/api';
|
||||
|
||||
export const CanFetchTask = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const taskId = req.query.taskId || req.params.taskId;
|
||||
const taskId = req.params.taskId;
|
||||
|
||||
if (!taskId) {
|
||||
return res.status(400).end();
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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 CanMoveTask = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const taskId = req.body.taskId;
|
||||
|
||||
if (!taskId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
|
||||
.catch(logError);
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for CanMoveTask');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_DELETE)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
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 CanSeeTaskAssignedUsers = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const taskId = req.body.taskId;
|
||||
|
||||
if (!taskId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
|
||||
.catch(logError);
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for CanSeeTaskAssignedUsers');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_ASSIGNED_USERS)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
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';
|
||||
|
||||
/** @deprecated */
|
||||
export const CanUpdateTaskAssignee = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const taskId = req.body.taskId;
|
||||
|
||||
if (!taskId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
|
||||
.catch(logError);
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for CanUpdateTaskDescription');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
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 CanUpdateTaskDeadline = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const taskId = req.body.taskId;
|
||||
|
||||
if (!taskId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
|
||||
.catch(logError);
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for CanUpdateTaskDeadline');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_DEADLINE)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
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 CanUpdateTaskDescription = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const taskId = req.body.taskId;
|
||||
|
||||
if (!taskId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
|
||||
.catch(logError);
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for CanUpdateTaskDescription');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_DESCRIPTION)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
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 CanUpdateTaskNote = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const taskId = req.body.taskId;
|
||||
|
||||
if (!taskId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
|
||||
.catch(logError);
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for CanUpdateTaskNote');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_NOTE)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
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 CanUpdateTaskPriority = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const taskId = req.body.taskId;
|
||||
|
||||
if (!taskId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
|
||||
.catch(logError);
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for CanUpdateTaskPriority');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_PRIORITY)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
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 CanUpdateTaskStatus = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const taskId = req.body.taskId;
|
||||
|
||||
if (!taskId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
|
||||
.catch(logError);
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for CanAddTask middleware');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_STATUS)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -115,6 +115,12 @@ export type UpdateUserCredentialsArgs = {
|
||||
passwordHash: string;
|
||||
};
|
||||
|
||||
export type UpdateUserEmailArgs = {
|
||||
userId: number;
|
||||
oldEmail: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type UpdateUserCredentialsResult = 'ok' | 'conflict' | 'error';
|
||||
|
||||
export const RefreshTokenSchema = z.object({
|
||||
|
||||
@@ -29,6 +29,15 @@ export function generateString(length: number) {
|
||||
return result
|
||||
}
|
||||
|
||||
export function generateLetters(length: number) {
|
||||
let result = ''
|
||||
const characters = 'abcdefghijklmnopqrstuvwxyz'
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(randomInt(characters.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function time() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,23 @@ Users who sign in via SSO are automatically added to the organization that owns
|
||||
|
||||
Go to your organization's settings → **SSO** tab. You need the **admin** or **owner** role.
|
||||
|
||||
### Domain verification
|
||||
|
||||
SSO login stays off until the organization proves it owns the email domain (so another org on a shared instance cannot claim `gmail.com` or your company domain).
|
||||
|
||||
After you save the SSO config, TaskView shows a verification token. Use **one** of:
|
||||
|
||||
1. **DNS TXT** — add a TXT record on the domain:
|
||||
`taskview-sso-verify=<token>`
|
||||
2. **HTTP file** — serve the token (plain text) at:
|
||||
`https://<domain>/.well-known/taskview-sso-verify.txt`
|
||||
|
||||
Then click **Check domain**. Either method is enough. After a successful check, SSO login is enabled.
|
||||
|
||||
**Closed-network / air-gapped installs:** you may not have public DNS. Set `SSO_TRUSTED_DOMAINS=company.com,corp.local` on the API server. Domains in that list skip the DNS/HTTP check and are treated as verified.
|
||||
|
||||
Existing SSO configs created before this check are not verified: logins stop until an admin completes verification or the domain is listed in `SSO_TRUSTED_DOMAINS`.
|
||||
|
||||
### SAML 2.0
|
||||
|
||||
**Required fields:**
|
||||
@@ -145,6 +162,8 @@ Request IDs expire after 5 minutes.
|
||||
| POST | `/module/sso/admin/configs` | Create SSO config |
|
||||
| PATCH | `/module/sso/admin/configs/{configId}` | Update SSO config |
|
||||
| DELETE | `/module/sso/admin/configs/{configId}` | Delete SSO config |
|
||||
| POST | `/module/sso/admin/configs/{configId}/verify-domain` | Return DNS TXT and HTTP well-known proof for the domain |
|
||||
| POST | `/module/sso/admin/configs/{configId}/verify-domain/check` | Check DNS TXT then HTTP file; enable SSO on success |
|
||||
|
||||
## Database tables
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ Unlike everything else on this page, this variable is set on the **web app conta
|
||||
| `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. |
|
||||
| `SSO_TRUSTED_DOMAINS` | No | empty | Comma-separated email domains that skip DNS/HTTP ownership checks for SSO (air-gapped / closed-network installs). Example: `company.com,corp.local`. On a public instance leave this unset so every org must prove it owns the domain. |
|
||||
|
||||
::callout{icon="i-lucide-shield" color="warning"}
|
||||
Generate a strong JWT secret: `node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"`
|
||||
@@ -267,6 +268,8 @@ REFRESH_LIFE_TIME="9d"
|
||||
#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"
|
||||
# Air-gapped SSO: skip DNS/HTTP domain proof for these email domains
|
||||
#SSO_TRUSTED_DOMAINS="company.com,corp.local"
|
||||
|
||||
SMTP_HOST=smtp
|
||||
SMTP_PORT=587
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-monorepo",
|
||||
"version": "1.51.0",
|
||||
"version": "1.53.0",
|
||||
"private": true,
|
||||
"description": "TaskView CE monorepo containing web, API, and packages",
|
||||
"workspaces": [
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { TvApi } from '@/tv'
|
||||
import { TvPermissions } from '@/api/permissions'
|
||||
import axios from 'axios'
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { API_URL, initApi } from './init-api'
|
||||
|
||||
/**
|
||||
* GET /module/collaboration/:goalId must be an object-level protected route:
|
||||
* only a member of the goal holding task_can_assign_users or goal_can_manage_users
|
||||
* may read its collaborator list (emails, invitation dates, roles, goalOwner flag).
|
||||
*/
|
||||
describe('Collaboration goal member list access control', () => {
|
||||
let ownerApi: TvApi
|
||||
let outsiderApi: TvApi
|
||||
let outsiderEmail: string
|
||||
let deleteAllGoals: () => Promise<void>
|
||||
let manageUsersPermissionId: number
|
||||
const permissionIdByName = new Map<string, number>()
|
||||
|
||||
beforeAll(async () => {
|
||||
const init = await initApi()
|
||||
ownerApi = init.$tvApi
|
||||
outsiderApi = init.$tvApiForSecondUser
|
||||
outsiderEmail = init.user2Email
|
||||
deleteAllGoals = init.deleteAllGoals
|
||||
|
||||
const allPermissions = await ownerApi.collaboration.fetchAllPermissions()
|
||||
for (const permission of allPermissions) {
|
||||
permissionIdByName.set(permission.name, permission.id)
|
||||
}
|
||||
const found = permissionIdByName.get(TvPermissions.GOAL_CAN_MANAGE_USERS)
|
||||
if (!found) throw new Error('Permission "goal_can_manage_users" is not in DB')
|
||||
manageUsersPermissionId = found
|
||||
})
|
||||
|
||||
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 ?? e.status, `Expected ${status}, got ${e.response?.status ?? e.status}`).toBe(status)
|
||||
}
|
||||
}
|
||||
|
||||
// A goal owned by user1 that user2 is NOT a member of, holding a third-party email
|
||||
async function createPrivateGoal(organizationId?: number) {
|
||||
const goal = await ownerApi.goals.createGoal({
|
||||
name: `Private goal ${Date.now()}`,
|
||||
...(organizationId ? { organizationId } : {}),
|
||||
})
|
||||
if (!goal) throw new Error('Failed to create goal')
|
||||
|
||||
const invitedEmail = `outside-party-${Date.now()}@test.com`
|
||||
const invited = await ownerApi.collaboration.inviteUserToGoal({ email: invitedEmail, goalId: goal.id })
|
||||
if (!invited) throw new Error('Failed to invite third-party email')
|
||||
|
||||
return { goal, invitedEmail }
|
||||
}
|
||||
|
||||
// Invite user2 into user1's goal and grant a role carrying goal_can_manage_users
|
||||
async function shareGoalWithOutsider() {
|
||||
const goal = await ownerApi.goals.createGoal({ name: `Shared goal ${Date.now()}` })
|
||||
if (!goal) throw new Error('Failed to create goal')
|
||||
|
||||
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
|
||||
if (!collab) throw new Error('Failed to invite user2')
|
||||
|
||||
const role = await ownerApi.collaboration.createRoleForGoal({
|
||||
goalId: goal.id,
|
||||
roleName: `Manager ${Date.now()}`,
|
||||
})
|
||||
if (!role) throw new Error('Failed to create role')
|
||||
|
||||
const toggled = await ownerApi.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 ownerApi.collaboration.toggleUserRoles({
|
||||
goalId: goal.id,
|
||||
userId: collab.id,
|
||||
roles: [role.id],
|
||||
})
|
||||
|
||||
return { goal, role, collab }
|
||||
}
|
||||
|
||||
describe('JWT session of a non-member', () => {
|
||||
it('cannot read the collaborator list of someone else goal', async () => {
|
||||
const { goal } = await createPrivateGoal()
|
||||
|
||||
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(goal.id), 403)
|
||||
})
|
||||
|
||||
it('cannot enumerate collaborator emails by walking goal ids', async () => {
|
||||
const { goal, invitedEmail } = await createPrivateGoal()
|
||||
|
||||
let leaked: Awaited<ReturnType<typeof outsiderApi.collaboration.fetchUsersForGoal>> | null = null
|
||||
try {
|
||||
leaked = await outsiderApi.collaboration.fetchUsersForGoal(goal.id)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
expect(
|
||||
leaked ?? [],
|
||||
`Leaked collaborator list of goal ${goal.id}: ${JSON.stringify(leaked)}`,
|
||||
).toEqual([])
|
||||
expect((leaked ?? []).some(u => u.email === invitedEmail)).toBe(false)
|
||||
expect((leaked ?? []).some(u => u.goalOwner)).toBe(false)
|
||||
})
|
||||
|
||||
it('gets the same rejection for a goal id that does not exist', async () => {
|
||||
const nonExistentGoalId = 999999999
|
||||
|
||||
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(nonExistentGoalId), 403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Organization boundary', () => {
|
||||
it('a member of the same organization who is not a member of the goal is still rejected', async () => {
|
||||
const org = await ownerApi.organizations.create({ name: `Access org ${Date.now()}` })
|
||||
if (!org) throw new Error('Failed to create organization')
|
||||
|
||||
const added = await ownerApi.organizations.addMember({
|
||||
organizationId: org.id,
|
||||
email: outsiderEmail,
|
||||
role: 'member',
|
||||
})
|
||||
if (!added) throw new Error('Failed to add user2 to the organization')
|
||||
|
||||
// The goal lives in the shared org, but user2 was never invited into the goal itself
|
||||
const { goal } = await createPrivateGoal(org.id)
|
||||
|
||||
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(goal.id), 403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unauthenticated access', () => {
|
||||
it('is rejected with 401 rather than served', async () => {
|
||||
const { goal } = await createPrivateGoal()
|
||||
|
||||
const response = await axios.get(`${API_URL}/module/collaboration/${goal.id}`, {
|
||||
validateStatus: () => true,
|
||||
})
|
||||
|
||||
expect(
|
||||
response.status,
|
||||
`Anonymous request returned ${response.status}: ${JSON.stringify(response.data)}`,
|
||||
).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('API token of a non-member', () => {
|
||||
it('cannot read the collaborator list of someone else goal', async () => {
|
||||
const created = await outsiderApi.apiTokens.create({ name: `Access probe ${Date.now()}` })
|
||||
if (!created) throw new Error('Failed to create API token for user2')
|
||||
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created.token}` },
|
||||
}))
|
||||
|
||||
const { goal } = await createPrivateGoal()
|
||||
|
||||
try {
|
||||
await expectHttpStatus(tokenApi.collaboration.fetchUsersForGoal(goal.id), 403)
|
||||
} finally {
|
||||
await outsiderApi.apiTokens.delete(created.item.id)
|
||||
}
|
||||
})
|
||||
|
||||
it('cannot read a goal that is outside the token allowedGoalIds scope', async () => {
|
||||
const ownGoal = await outsiderApi.goals.createGoal({ name: `User2 goal ${Date.now()}` })
|
||||
if (!ownGoal) throw new Error('Failed to create user2 goal')
|
||||
|
||||
// Token is explicitly scoped to user2's own goal only
|
||||
const created = await outsiderApi.apiTokens.create({
|
||||
name: `Scoped probe ${Date.now()}`,
|
||||
allowedGoalIds: [ownGoal.id],
|
||||
})
|
||||
if (!created) throw new Error('Failed to create scoped API token for user2')
|
||||
|
||||
const tokenApi = new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${created.token}` },
|
||||
}))
|
||||
|
||||
const { goal } = await createPrivateGoal()
|
||||
|
||||
try {
|
||||
await expectHttpStatus(tokenApi.collaboration.fetchUsersForGoal(goal.id), 403)
|
||||
} finally {
|
||||
await outsiderApi.apiTokens.delete(created.item.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Legitimate access is preserved', () => {
|
||||
it('the goal owner can read the collaborator list', async () => {
|
||||
const { goal, invitedEmail } = await createPrivateGoal()
|
||||
|
||||
const users = await ownerApi.collaboration.fetchUsersForGoal(goal.id)
|
||||
expect(users).toBeDefined()
|
||||
expect(users?.some(u => u.email === invitedEmail)).toBe(true)
|
||||
})
|
||||
|
||||
it('a member with goal_can_manage_users can read the collaborator list', async () => {
|
||||
const { goal } = await shareGoalWithOutsider()
|
||||
|
||||
const users = await outsiderApi.collaboration.fetchUsersForGoal(goal.id)
|
||||
expect(users).toBeDefined()
|
||||
expect(users?.some(u => u.email === outsiderEmail)).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* A rank-and-file member must still see the project roster, otherwise the UI
|
||||
* cannot render task assignees. Both default roles created by the goal trigger
|
||||
* (editor and executor, migration 1.6.1/5.default-roles-for-project.sql) carry
|
||||
* task_can_watch_assigned_users, so this is the common case, not an edge one.
|
||||
*/
|
||||
it('a member with only task_can_watch_assigned_users can read the collaborator list', async () => {
|
||||
const goal = await ownerApi.goals.createGoal({ name: `Executor goal ${Date.now()}` })
|
||||
if (!goal) throw new Error('Failed to create goal')
|
||||
|
||||
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
|
||||
if (!collab) throw new Error('Failed to invite user2')
|
||||
|
||||
const roles = await ownerApi.collaboration.fetchRolesForGoal(goal.id)
|
||||
const executor = roles?.find(r => r.name === 'executor')
|
||||
if (!executor) throw new Error('Default "executor" role is missing on a fresh goal')
|
||||
|
||||
// the role grants the watch permission and neither of the two management ones,
|
||||
// so a pass here can only come from task_can_watch_assigned_users
|
||||
const matrix = await ownerApi.collaboration.fetchRoleToPermissionsForGoal(goal.id)
|
||||
const executorPermissionIds = (matrix ?? [])
|
||||
.filter(row => row.roleId === executor.id)
|
||||
.map(row => row.permissionId)
|
||||
expect(executorPermissionIds).toContain(permissionIdByName.get(TvPermissions.TASK_CAN_WATCH_ASSIGNED_USERS))
|
||||
expect(executorPermissionIds).not.toContain(permissionIdByName.get(TvPermissions.GOAL_CAN_MANAGE_USERS))
|
||||
expect(executorPermissionIds).not.toContain(permissionIdByName.get(TvPermissions.TASK_CAN_ASSIGN_USERS))
|
||||
|
||||
await ownerApi.collaboration.toggleUserRoles({
|
||||
goalId: goal.id,
|
||||
userId: collab.id,
|
||||
roles: [executor.id],
|
||||
})
|
||||
|
||||
const users = await outsiderApi.collaboration.fetchUsersForGoal(goal.id)
|
||||
expect(users).toBeDefined()
|
||||
expect(users?.some(u => u.email === outsiderEmail)).toBe(true)
|
||||
})
|
||||
|
||||
it('a member whose role carries none of the three permissions is rejected', async () => {
|
||||
const goal = await ownerApi.goals.createGoal({ name: `Bare role goal ${Date.now()}` })
|
||||
if (!goal) throw new Error('Failed to create goal')
|
||||
|
||||
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
|
||||
if (!collab) throw new Error('Failed to invite user2')
|
||||
|
||||
// a freshly created custom role carries no permissions at all
|
||||
const bareRole = await ownerApi.collaboration.createRoleForGoal({
|
||||
goalId: goal.id,
|
||||
roleName: `Bare ${Date.now()}`,
|
||||
})
|
||||
if (!bareRole) throw new Error('Failed to create role')
|
||||
|
||||
await ownerApi.collaboration.toggleUserRoles({
|
||||
goalId: goal.id,
|
||||
userId: collab.id,
|
||||
roles: [bareRole.id],
|
||||
})
|
||||
|
||||
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(goal.id), 403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Revoked access', () => {
|
||||
it('a removed collaborator loses access to the collaborator list', async () => {
|
||||
const { goal, collab } = await shareGoalWithOutsider()
|
||||
|
||||
// sanity: access is real before removal
|
||||
const before = await outsiderApi.collaboration.fetchUsersForGoal(goal.id)
|
||||
expect(before?.some(u => u.email === outsiderEmail)).toBe(true)
|
||||
|
||||
const removed = await ownerApi.collaboration.deleteUserFromGoal({ goalId: goal.id, id: collab.id })
|
||||
expect(removed).toBeTruthy()
|
||||
|
||||
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(goal.id), 403)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,9 @@ services:
|
||||
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"
|
||||
# Domains that skip DNS/HTTP ownership proof — used by sso.test.ts to
|
||||
# deterministically produce a *verified* config (method 'trusted').
|
||||
SSO_TRUSTED_DOMAINS: "owned-sso.example"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { TvApi } from '@/tv'
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
|
||||
|
||||
/**
|
||||
* resolveGoalId() for the graph module inspects req.body.source before falling
|
||||
* back to req.params.id, while deleteEdge acts on req.params.id. A caller must
|
||||
* not be able to point the guard at a task they own while the handler operates
|
||||
* on an edge belonging to someone else.
|
||||
*/
|
||||
describe('Graph object-level access control', () => {
|
||||
let ownerApi: TvApi
|
||||
let outsiderApi: TvApi
|
||||
let deleteAllGoals: () => Promise<void>
|
||||
let attackerAxios: AxiosInstance
|
||||
let victimGoalId: number
|
||||
let attackerTaskId: number
|
||||
let victimTaskId: number
|
||||
|
||||
beforeAll(async () => {
|
||||
const init = await initApi()
|
||||
ownerApi = init.$tvApi
|
||||
outsiderApi = init.$tvApiForSecondUser
|
||||
deleteAllGoals = init.deleteAllGoals
|
||||
|
||||
const auth = await axios.post(`${API_URL}/module/auth/login`, {
|
||||
login: DEFAULT_USER_2,
|
||||
password: DEFAULT_PASSWORD,
|
||||
})
|
||||
attackerAxios = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${auth.data.access}` },
|
||||
validateStatus: () => true,
|
||||
})
|
||||
|
||||
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim graph ${Date.now()}` })
|
||||
if (!victimGoal) throw new Error('Failed to create victim goal')
|
||||
victimGoalId = victimGoal.id
|
||||
|
||||
const victimTask = await ownerApi.tasks.createTask({
|
||||
goalId: victimGoalId,
|
||||
description: `victim-task-${Date.now()}`,
|
||||
})
|
||||
if (!victimTask) throw new Error('Failed to create victim task')
|
||||
victimTaskId = victimTask.id
|
||||
|
||||
const attackerGoal = await outsiderApi.goals.createGoal({ name: `Attacker graph ${Date.now()}` })
|
||||
if (!attackerGoal) throw new Error('Failed to create attacker goal')
|
||||
|
||||
const attackerTask = await outsiderApi.tasks.createTask({
|
||||
goalId: attackerGoal.id,
|
||||
description: `attacker-task-${Date.now()}`,
|
||||
})
|
||||
if (!attackerTask) throw new Error('Failed to create attacker task')
|
||||
attackerTaskId = attackerTask.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 ?? e.status, `Expected ${status}, got ${e.response?.status ?? e.status}`).toBe(status)
|
||||
}
|
||||
}
|
||||
|
||||
async function createVictimEdge(): Promise<number> {
|
||||
const from = await ownerApi.tasks.createTask({
|
||||
goalId: victimGoalId,
|
||||
description: `victim-edge-from-${Date.now()}`,
|
||||
})
|
||||
const to = await ownerApi.tasks.createTask({
|
||||
goalId: victimGoalId,
|
||||
description: `victim-edge-to-${Date.now()}`,
|
||||
})
|
||||
if (!from || !to) throw new Error('Failed to create victim tasks')
|
||||
|
||||
const edge = await ownerApi.graph.addEdge({ source: from.id, target: to.id })
|
||||
if (!edge) throw new Error('Failed to create victim edge')
|
||||
return edge.id
|
||||
}
|
||||
|
||||
async function victimEdgeExists(edgeId: number): Promise<boolean> {
|
||||
const edges = await ownerApi.graph.fetchAllEdges(victimGoalId)
|
||||
return (edges ?? []).some(e => e.id === edgeId)
|
||||
}
|
||||
|
||||
it('rejects deleting another user edge even when a self-owned source task is supplied', async () => {
|
||||
const edgeId = await createVictimEdge()
|
||||
|
||||
const response = await attackerAxios.delete(`/module/graph/${edgeId}`, {
|
||||
data: { source: attackerTaskId },
|
||||
})
|
||||
|
||||
expect(
|
||||
await victimEdgeExists(edgeId),
|
||||
`victim edge ${edgeId} was destroyed by a non-member`,
|
||||
).toBe(true)
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
// A graph lives inside one project, so an edge across two of them is not a
|
||||
// permission question but an impossible object: it is refused before any
|
||||
// permission is looked at. Driven through the SDK on purpose — this needs no
|
||||
// crafted request at all, an ordinary client using the public API reaches it.
|
||||
it('rejects creating an edge whose endpoints live in different projects', async () => {
|
||||
await expectHttpStatus(
|
||||
outsiderApi.graph.addEdge({ source: attackerTaskId, target: victimTaskId }),
|
||||
400,
|
||||
)
|
||||
})
|
||||
|
||||
// the mirror of the case above: a foreign source with an own target. This one
|
||||
// fails closed even without the endpoint comparison (the goal would resolve to
|
||||
// the victim project and the permission check would deny it), which is exactly
|
||||
// why it needs pinning — a regression here would be silent
|
||||
it('rejects creating an edge from a foreign task into a project the caller owns', async () => {
|
||||
await expectHttpStatus(
|
||||
outsiderApi.graph.addEdge({ source: victimTaskId, target: attackerTaskId }),
|
||||
400,
|
||||
)
|
||||
})
|
||||
|
||||
// both endpoints inside the victim project: the goal resolves cleanly, so this
|
||||
// is decided purely by the permission check on that goal
|
||||
it('rejects creating an edge between two tasks of a project the caller is not a member of', async () => {
|
||||
const second = await ownerApi.tasks.createTask({
|
||||
goalId: victimGoalId,
|
||||
description: `victim-second-${Date.now()}`,
|
||||
})
|
||||
if (!second) throw new Error('Failed to create second victim task')
|
||||
|
||||
await expectHttpStatus(
|
||||
outsiderApi.graph.addEdge({ source: victimTaskId, target: second.id }),
|
||||
403,
|
||||
)
|
||||
})
|
||||
|
||||
it('control: without the injected source the guard already rejects the delete', async () => {
|
||||
const edgeId = await createVictimEdge()
|
||||
|
||||
const response = await attackerAxios.delete(`/module/graph/${edgeId}`)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(await victimEdgeExists(edgeId)).toBe(true)
|
||||
})
|
||||
|
||||
it('control: the owner can still delete their own edge', async () => {
|
||||
const edgeId = await createVictimEdge()
|
||||
|
||||
const deleted = await ownerApi.graph.deleteEdge(edgeId)
|
||||
expect(deleted).toBeTruthy()
|
||||
expect(await victimEdgeExists(edgeId)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { TvApi } from '@/tv'
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
|
||||
|
||||
/**
|
||||
* Several guards pick the goal to authorize with `req.body.goalId ? req.body : req.params`,
|
||||
* while their handlers read `req.params`. Supplying a body that names a goal the caller owns
|
||||
* must not authorize a request whose path points at someone else's goal.
|
||||
*/
|
||||
describe('Guard/handler parameter confusion', () => {
|
||||
let ownerApi: TvApi
|
||||
let deleteAllGoals: () => Promise<void>
|
||||
let attackerAxios: AxiosInstance
|
||||
let victimGoalId: number
|
||||
let attackerGoalId: number
|
||||
let victimColumnId: number
|
||||
|
||||
beforeAll(async () => {
|
||||
const init = await initApi()
|
||||
ownerApi = init.$tvApi
|
||||
deleteAllGoals = init.deleteAllGoals
|
||||
|
||||
const auth = await axios.post(`${API_URL}/module/auth/login`, {
|
||||
login: DEFAULT_USER_2,
|
||||
password: DEFAULT_PASSWORD,
|
||||
})
|
||||
attackerAxios = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${auth.data.access}` },
|
||||
validateStatus: () => true,
|
||||
})
|
||||
|
||||
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim confusion ${Date.now()}` })
|
||||
if (!victimGoal) throw new Error('Failed to create victim goal')
|
||||
victimGoalId = victimGoal.id
|
||||
|
||||
const attackerGoal = await axios.post(
|
||||
`${API_URL}/module/goals`,
|
||||
{ name: `Attacker confusion ${Date.now()}` },
|
||||
{ headers: { Authorization: `Bearer ${auth.data.access}` } },
|
||||
)
|
||||
attackerGoalId = attackerGoal.data.response.id
|
||||
|
||||
await ownerApi.tasks.createTask({
|
||||
goalId: victimGoalId,
|
||||
description: `secret-task-${Date.now()}`,
|
||||
})
|
||||
|
||||
const columns = await ownerApi.kanban.fetchAllColumns(victimGoalId)
|
||||
if (!columns?.length) throw new Error('Victim goal has no kanban columns')
|
||||
victimColumnId = columns[0].id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteAllGoals()
|
||||
})
|
||||
|
||||
it('rejects reading another goal kanban tasks when a self-owned goalId is put in the body', async () => {
|
||||
const response = await attackerAxios.request({
|
||||
method: 'get',
|
||||
url: `/module/kanban/tasks/${victimGoalId}/${victimColumnId}/0`,
|
||||
data: { goalId: attackerGoalId, columnId: victimColumnId },
|
||||
})
|
||||
|
||||
expect(
|
||||
response.status,
|
||||
`Leaked kanban tasks of goal ${victimGoalId}: ${JSON.stringify(response.data)}`,
|
||||
).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects reading another goal task order when a self-owned goalId is put in the body', async () => {
|
||||
const response = await attackerAxios.request({
|
||||
method: 'get',
|
||||
url: `/module/kanban/tasks-order/${victimGoalId}/${victimColumnId}/0`,
|
||||
data: { goalId: attackerGoalId, columnId: victimColumnId },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects reading another goal role-to-permission matrix when a self-owned goalId is put in the body', async () => {
|
||||
const response = await attackerAxios.request({
|
||||
method: 'get',
|
||||
url: `/module/collaborationroles/role-to-permissions/${victimGoalId}`,
|
||||
data: { goalId: attackerGoalId },
|
||||
})
|
||||
|
||||
expect(
|
||||
response.status,
|
||||
`Leaked role matrix of goal ${victimGoalId}: ${JSON.stringify(response.data)}`,
|
||||
).toBe(403)
|
||||
})
|
||||
|
||||
it('control: the same requests without a body are already rejected', async () => {
|
||||
const kanban = await attackerAxios.get(`/module/kanban/tasks/${victimGoalId}/${victimColumnId}/0`)
|
||||
expect(kanban.status).toBe(403)
|
||||
|
||||
const roles = await attackerAxios.get(`/module/collaborationroles/role-to-permissions/${victimGoalId}`)
|
||||
expect(roles.status).toBe(403)
|
||||
})
|
||||
|
||||
it('control: the owner still reads their own kanban tasks and role matrix', async () => {
|
||||
const tasks = await ownerApi.kanban
|
||||
.fetchTasksForColumn(victimGoalId, victimColumnId, 0)
|
||||
.catch((e: any) => { throw new Error(`kanban read failed: ${e.response?.status}`) })
|
||||
expect(tasks).toBeDefined()
|
||||
|
||||
const matrix = await ownerApi.collaboration.fetchRoleToPermissionsForGoal(victimGoalId)
|
||||
.catch((e: any) => { throw new Error(`role matrix read failed: ${e.response?.status}`) })
|
||||
expect(matrix).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import { TvApi } from '@/tv'
|
||||
import { TvPermissions } from '@/api/permissions'
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
|
||||
|
||||
/**
|
||||
* The integrations guards resolve the project to authorize against via
|
||||
* resolveProjectId(), which prefers a projectId supplied by the caller over the
|
||||
* one derived from integrationId. The handlers, however, act on integrationId.
|
||||
* A caller must not be able to pass a project they own alongside someone else's
|
||||
* integration id and have the guard authorize the wrong object.
|
||||
*/
|
||||
describe('Integrations object-level access control', () => {
|
||||
let ownerApi: TvApi
|
||||
let outsiderApi: TvApi
|
||||
let deleteAllGoals: () => Promise<void>
|
||||
let outsiderEmail: string
|
||||
let attackerAxios: AxiosInstance
|
||||
let victimGoalId: number
|
||||
let attackerGoalId: number
|
||||
|
||||
beforeAll(async () => {
|
||||
const init = await initApi()
|
||||
ownerApi = init.$tvApi
|
||||
outsiderApi = init.$tvApiForSecondUser
|
||||
deleteAllGoals = init.deleteAllGoals
|
||||
outsiderEmail = init.user2Email
|
||||
|
||||
const auth = await axios.post(`${API_URL}/module/auth/login`, {
|
||||
login: DEFAULT_USER_2,
|
||||
password: DEFAULT_PASSWORD,
|
||||
})
|
||||
attackerAxios = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${auth.data.access}` },
|
||||
validateStatus: () => true,
|
||||
})
|
||||
|
||||
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim project ${Date.now()}` })
|
||||
if (!victimGoal) throw new Error('Failed to create victim goal')
|
||||
victimGoalId = victimGoal.id
|
||||
|
||||
const attackerGoal = await outsiderApi.goals.createGoal({ name: `Attacker project ${Date.now()}` })
|
||||
if (!attackerGoal) throw new Error('Failed to create attacker goal')
|
||||
attackerGoalId = attackerGoal.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteAllGoals()
|
||||
})
|
||||
|
||||
async function createVictimIntegration(): Promise<number> {
|
||||
const created = await ownerApi.integrations.createIntegration({
|
||||
provider: 'github',
|
||||
repoFullName: `victim-org/private-repo-${Date.now()}`,
|
||||
projectId: victimGoalId,
|
||||
})
|
||||
if (!created) throw new Error('Failed to create victim integration')
|
||||
return created.id
|
||||
}
|
||||
|
||||
async function victimIntegrationExists(integrationId: number): Promise<boolean> {
|
||||
const list = await ownerApi.integrations.fetchIntegrations(victimGoalId)
|
||||
return (list ?? []).some(i => i.id === integrationId)
|
||||
}
|
||||
|
||||
it('rejects deleting another user integration even when a self-owned projectId is supplied', async () => {
|
||||
const integrationId = await createVictimIntegration()
|
||||
|
||||
const response = await attackerAxios.delete('/module/integrations', {
|
||||
data: { id: integrationId, projectId: attackerGoalId },
|
||||
})
|
||||
|
||||
// impact first, mechanism second — so a failure reports whether data was actually destroyed
|
||||
expect(
|
||||
await victimIntegrationExists(integrationId),
|
||||
`victim integration ${integrationId} was destroyed by a non-member`,
|
||||
).toBe(true)
|
||||
expect(
|
||||
response.status,
|
||||
`Guard authorized project ${attackerGoalId} while the handler acted on integration ${integrationId}`,
|
||||
).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects toggling another user integration even when a self-owned projectId is supplied', async () => {
|
||||
const integrationId = await createVictimIntegration()
|
||||
|
||||
const response = await attackerAxios.patch('/module/integrations/toggle', {
|
||||
id: integrationId,
|
||||
isActive: false,
|
||||
projectId: attackerGoalId,
|
||||
})
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects reading another user integration repos even when a self-owned projectId is supplied', async () => {
|
||||
const integrationId = await createVictimIntegration()
|
||||
|
||||
const response = await attackerAxios.get('/module/integrations/repos', {
|
||||
params: { integrationId, projectId: attackerGoalId },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects syncing another user integration even when a self-owned projectId is supplied', async () => {
|
||||
const integrationId = await createVictimIntegration()
|
||||
|
||||
const response = await attackerAxios.post('/module/integrations/sync', {
|
||||
integrationId,
|
||||
projectId: attackerGoalId,
|
||||
})
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('control: without the injected projectId the guard already rejects the same request', async () => {
|
||||
const integrationId = await createVictimIntegration()
|
||||
|
||||
const response = await attackerAxios.delete('/module/integrations', {
|
||||
data: { id: integrationId },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(await victimIntegrationExists(integrationId)).toBe(true)
|
||||
})
|
||||
|
||||
// select-repo is the most consequential handler of the four: besides writing to
|
||||
// the integration it kicks off syncIssues() and registerWebhook() against the repo
|
||||
it('rejects selecting a repo on another user integration even when a self-owned projectId is supplied', async () => {
|
||||
const integrationId = await createVictimIntegration()
|
||||
|
||||
const response = await attackerAxios.patch('/module/integrations/select-repo', {
|
||||
integrationId,
|
||||
repoFullName: 'attacker-org/planted-repo',
|
||||
repoExternalId: '424242',
|
||||
projectId: attackerGoalId,
|
||||
})
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
// resolveProjectId reads projectId from the query string too, so a fix that only
|
||||
// hardens the body would still leave this door open
|
||||
it('rejects the same bypass when projectId arrives via the query string', async () => {
|
||||
const integrationId = await createVictimIntegration()
|
||||
|
||||
const response = await attackerAxios.delete('/module/integrations', {
|
||||
params: { projectId: attackerGoalId },
|
||||
data: { id: integrationId },
|
||||
})
|
||||
|
||||
expect(
|
||||
await victimIntegrationExists(integrationId),
|
||||
`victim integration ${integrationId} was destroyed via a query-string projectId`,
|
||||
).toBe(true)
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects listing the integrations of a project the caller is not a member of', async () => {
|
||||
await createVictimIntegration()
|
||||
|
||||
const response = await attackerAxios.get('/module/integrations', {
|
||||
params: { projectId: victimGoalId },
|
||||
})
|
||||
|
||||
expect(
|
||||
response.status,
|
||||
`Leaked integrations of project ${victimGoalId}: ${JSON.stringify(response.data)}`,
|
||||
).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects planting a new integration into a project the caller is not a member of', async () => {
|
||||
const response = await attackerAxios.post('/module/integrations', {
|
||||
provider: 'github',
|
||||
repoFullName: 'attacker-org/planted-repo',
|
||||
projectId: victimGoalId,
|
||||
})
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('control: the owner can still manage their own integration', async () => {
|
||||
const integrationId = await createVictimIntegration()
|
||||
|
||||
const deleted = await ownerApi.integrations.deleteIntegration(integrationId)
|
||||
expect(deleted).toBeTruthy()
|
||||
expect(await victimIntegrationExists(integrationId)).toBe(false)
|
||||
})
|
||||
|
||||
// guards against an over-strict fix: a project member holding integrations_can_manage
|
||||
// must keep working, not just the goal owner
|
||||
it('control: a project member with integrations_can_manage can delete the integration', async () => {
|
||||
const goal = await ownerApi.goals.createGoal({ name: `Shared integrations ${Date.now()}` })
|
||||
if (!goal) throw new Error('Failed to create goal')
|
||||
|
||||
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
|
||||
if (!collab) throw new Error('Failed to invite user2')
|
||||
|
||||
const allPermissions = await ownerApi.collaboration.fetchAllPermissions()
|
||||
const managePermission = allPermissions.find(p => p.name === TvPermissions.INTEGRATIONS_CAN_MANAGE)
|
||||
if (!managePermission) throw new Error('Permission "integrations_can_manage" is not in DB')
|
||||
|
||||
const role = await ownerApi.collaboration.createRoleForGoal({
|
||||
goalId: goal.id,
|
||||
roleName: `Integrator ${Date.now()}`,
|
||||
})
|
||||
if (!role) throw new Error('Failed to create role')
|
||||
|
||||
const toggled = await ownerApi.collaboration.toggleRolePermission({
|
||||
roleId: role.id,
|
||||
permissionId: managePermission.id,
|
||||
})
|
||||
if (!toggled || toggled.add !== true) {
|
||||
throw new Error(`Expected integrations_can_manage to be added, got ${JSON.stringify(toggled)}`)
|
||||
}
|
||||
|
||||
await ownerApi.collaboration.toggleUserRoles({
|
||||
goalId: goal.id,
|
||||
userId: collab.id,
|
||||
roles: [role.id],
|
||||
})
|
||||
|
||||
const created = await ownerApi.integrations.createIntegration({
|
||||
provider: 'github',
|
||||
repoFullName: `shared-org/repo-${Date.now()}`,
|
||||
projectId: goal.id,
|
||||
})
|
||||
if (!created) throw new Error('Failed to create integration')
|
||||
|
||||
const deleted = await outsiderApi.integrations.deleteIntegration(created.id)
|
||||
expect(deleted).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
import { TvApi } from '@/tv'
|
||||
import { TvPermissions } from '@/api/permissions'
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
|
||||
|
||||
/**
|
||||
* Board writes (columns and task placement) are gated by kanban_can_manage, the
|
||||
* permission the UI has always used. They used to accept component_can_add_tasks
|
||||
* or task_can_add_subtasks instead, which let a rank-and-file member delete other
|
||||
* people's board columns.
|
||||
*/
|
||||
describe('Kanban permission boundaries', () => {
|
||||
let ownerApi: TvApi
|
||||
let outsiderApi: TvApi
|
||||
let outsiderEmail: string
|
||||
let deleteAllGoals: () => Promise<void>
|
||||
let attackerAxios: AxiosInstance
|
||||
const permissionIdByName = new Map<string, number>()
|
||||
|
||||
beforeAll(async () => {
|
||||
const init = await initApi()
|
||||
ownerApi = init.$tvApi
|
||||
outsiderApi = init.$tvApiForSecondUser
|
||||
outsiderEmail = init.user2Email
|
||||
deleteAllGoals = init.deleteAllGoals
|
||||
|
||||
const auth = await axios.post(`${API_URL}/module/auth/login`, {
|
||||
login: DEFAULT_USER_2,
|
||||
password: DEFAULT_PASSWORD,
|
||||
})
|
||||
attackerAxios = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${auth.data.access}` },
|
||||
validateStatus: () => true,
|
||||
})
|
||||
|
||||
for (const permission of await ownerApi.collaboration.fetchAllPermissions()) {
|
||||
permissionIdByName.set(permission.name, permission.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 ?? e.status, `Expected ${status}, got ${e.response?.status ?? e.status}`).toBe(status)
|
||||
}
|
||||
}
|
||||
|
||||
/** A goal of user1 that user2 joins through a role carrying exactly `permissionNames`. */
|
||||
async function shareGoalWith(permissionNames: string[]) {
|
||||
const goal = await ownerApi.goals.createGoal({ name: `Kanban access ${Date.now()}` })
|
||||
if (!goal) throw new Error('Failed to create goal')
|
||||
|
||||
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
|
||||
if (!collab) throw new Error('Failed to invite user2')
|
||||
|
||||
const role = await ownerApi.collaboration.createRoleForGoal({
|
||||
goalId: goal.id,
|
||||
roleName: `Role ${Date.now()}`,
|
||||
})
|
||||
if (!role) throw new Error('Failed to create role')
|
||||
|
||||
for (const name of permissionNames) {
|
||||
const permissionId = permissionIdByName.get(name)
|
||||
if (!permissionId) throw new Error(`Permission "${name}" is not in DB`)
|
||||
|
||||
const toggled = await ownerApi.collaboration.toggleRolePermission({ roleId: role.id, permissionId })
|
||||
if (!toggled || toggled.add !== true) {
|
||||
throw new Error(`Expected "${name}" to be added, got ${JSON.stringify(toggled)}`)
|
||||
}
|
||||
}
|
||||
|
||||
await ownerApi.collaboration.toggleUserRoles({ goalId: goal.id, userId: collab.id, roles: [role.id] })
|
||||
|
||||
return goal
|
||||
}
|
||||
|
||||
async function addColumn(goalId: number, name: string) {
|
||||
const column = await ownerApi.kanban.addColumn({ goalId, name })
|
||||
if (!column) throw new Error('Failed to create column')
|
||||
return column
|
||||
}
|
||||
|
||||
describe('a member holding kanban_can_manage', () => {
|
||||
it('can create, rename and delete a board column', async () => {
|
||||
const goal = await shareGoalWith([TvPermissions.KANBAN_CAN_MANAGE])
|
||||
|
||||
const created = await outsiderApi.kanban.addColumn({ goalId: goal.id, name: 'Created by member' })
|
||||
expect(created?.id).toBeGreaterThan(0)
|
||||
|
||||
const renamed = await outsiderApi.kanban.updateColumn({ id: created!.id, name: 'Renamed by member' })
|
||||
expect(renamed).toBeTruthy()
|
||||
|
||||
const deleted = await outsiderApi.kanban.deleteColumn({ id: created!.id })
|
||||
expect(deleted).toBeTruthy()
|
||||
})
|
||||
|
||||
it('can move a task into another column', async () => {
|
||||
const goal = await shareGoalWith([TvPermissions.KANBAN_CAN_MANAGE])
|
||||
const from = await addColumn(goal.id, 'From')
|
||||
const to = await addColumn(goal.id, 'To')
|
||||
|
||||
const task = await ownerApi.tasks.createTask({
|
||||
goalId: goal.id,
|
||||
description: `movable-${Date.now()}`,
|
||||
statusId: from.id,
|
||||
})
|
||||
if (!task) throw new Error('Failed to create task')
|
||||
|
||||
const moved = await outsiderApi.kanban.updateTasksOrderAndColumn({
|
||||
goalId: goal.id,
|
||||
columnId: to.id,
|
||||
taskId: task.id,
|
||||
prevTaskId: null,
|
||||
nextTaskId: null,
|
||||
})
|
||||
expect(moved).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a member holding only task-level permissions', () => {
|
||||
// exactly the pair the routes used to accept — the escalation that was closed
|
||||
const TASK_LEVEL = [TvPermissions.COMPONENT_CAN_ADD_TASKS, TvPermissions.TASK_CAN_ADD_SUBTASKS]
|
||||
|
||||
it('cannot create a board column', async () => {
|
||||
const goal = await shareGoalWith(TASK_LEVEL)
|
||||
|
||||
await expectHttpStatus(outsiderApi.kanban.addColumn({ goalId: goal.id, name: 'Nope' }), 403)
|
||||
})
|
||||
|
||||
it('cannot rename or delete a board column', async () => {
|
||||
const goal = await shareGoalWith(TASK_LEVEL)
|
||||
const column = await addColumn(goal.id, 'Owned by user1')
|
||||
|
||||
await expectHttpStatus(outsiderApi.kanban.updateColumn({ id: column.id, name: 'Nope' }), 403)
|
||||
await expectHttpStatus(outsiderApi.kanban.deleteColumn({ id: column.id }), 403)
|
||||
|
||||
const survivors = await ownerApi.kanban.fetchAllColumns(goal.id)
|
||||
expect(survivors?.some(c => c.id === column.id), 'column was destroyed').toBe(true)
|
||||
})
|
||||
|
||||
it('cannot move a task into another column', async () => {
|
||||
const goal = await shareGoalWith(TASK_LEVEL)
|
||||
const from = await addColumn(goal.id, 'From')
|
||||
const to = await addColumn(goal.id, 'To')
|
||||
|
||||
const task = await ownerApi.tasks.createTask({
|
||||
goalId: goal.id,
|
||||
description: `pinned-${Date.now()}`,
|
||||
statusId: from.id,
|
||||
})
|
||||
if (!task) throw new Error('Failed to create task')
|
||||
|
||||
await expectHttpStatus(
|
||||
outsiderApi.kanban.updateTasksOrderAndColumn({
|
||||
goalId: goal.id,
|
||||
columnId: to.id,
|
||||
taskId: task.id,
|
||||
prevTaskId: null,
|
||||
nextTaskId: null,
|
||||
}),
|
||||
403,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a member holding only kanban_can_view', () => {
|
||||
it('can read the task order of a column', async () => {
|
||||
const goal = await shareGoalWith([TvPermissions.KANBAN_CAN_VIEW])
|
||||
const column = await addColumn(goal.id, 'Readable')
|
||||
|
||||
const order = await outsiderApi.kanban.getTaskOrdersForColumnAndCursor(goal.id, column.id, null)
|
||||
expect(order).toBeDefined()
|
||||
})
|
||||
|
||||
it('cannot create a board column', async () => {
|
||||
const goal = await shareGoalWith([TvPermissions.KANBAN_CAN_VIEW])
|
||||
|
||||
await expectHttpStatus(outsiderApi.kanban.addColumn({ goalId: goal.id, name: 'Nope' }), 403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the goal a column belongs to is never taken from the request', () => {
|
||||
it('rejects deleting or renaming a foreign column even when a self-owned goalId is supplied', async () => {
|
||||
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim board ${Date.now()}` })
|
||||
if (!victimGoal) throw new Error('Failed to create victim goal')
|
||||
const victimColumn = await addColumn(victimGoal.id, 'Victim column')
|
||||
|
||||
// a project user2 fully controls, offered to the guard as the authorization target
|
||||
const ownGoal = await outsiderApi.goals.createGoal({ name: `Attacker board ${Date.now()}` })
|
||||
if (!ownGoal) throw new Error('Failed to create attacker goal')
|
||||
|
||||
const deleteResponse = await attackerAxios.post('/module/kanban/delete-status', {
|
||||
id: victimColumn.id,
|
||||
goalId: ownGoal.id,
|
||||
})
|
||||
const updateResponse = await attackerAxios.post('/module/kanban/update-status', {
|
||||
id: victimColumn.id,
|
||||
name: 'Renamed by an outsider',
|
||||
goalId: ownGoal.id,
|
||||
})
|
||||
|
||||
const survivors = await ownerApi.kanban.fetchAllColumns(victimGoal.id)
|
||||
const survivor = (survivors ?? []).find(c => c.id === victimColumn.id)
|
||||
expect(survivor, `victim column ${victimColumn.id} was destroyed`).toBeDefined()
|
||||
expect(survivor?.name, 'victim column was renamed').toBe('Victim column')
|
||||
|
||||
expect(deleteResponse.status).toBe(403)
|
||||
expect(updateResponse.status).toBe(403)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -26,7 +26,7 @@ afterAll(async () => {
|
||||
describe('SSO: config management', () => {
|
||||
let configId: number
|
||||
|
||||
it('should create SSO config with SAML protocol', async () => {
|
||||
it('should create an unverified SSO config', async () => {
|
||||
const config = await user1Api.sso.createConfig({
|
||||
organizationId: testOrgId,
|
||||
protocol: 'saml',
|
||||
@@ -43,6 +43,9 @@ describe('SSO: config management', () => {
|
||||
expect(config.protocol).toBe('saml')
|
||||
expect(config.displayName).toBe('Test SAML')
|
||||
expect(config.emailDomainRestriction).toBe('sso-test.example')
|
||||
expect(config.enabled).toBe(0)
|
||||
expect(config.isDomainVerified).toBe(false)
|
||||
expect(config.domainVerifyToken).toBeTruthy()
|
||||
configId = config.id
|
||||
})
|
||||
|
||||
@@ -82,12 +85,24 @@ describe('SSO: config management', () => {
|
||||
expect(updated.displayName).toBe('Updated SAML')
|
||||
})
|
||||
|
||||
it('should check domain and find provider', async () => {
|
||||
it('should not list an unverified domain as a public provider', async () => {
|
||||
const provider = await user1Api.sso.checkDomain('sso-test.example')
|
||||
expect(provider).toBeNull()
|
||||
})
|
||||
|
||||
expect(provider).toBeTruthy()
|
||||
expect(provider!.id).toBe(configId)
|
||||
expect(provider!.protocol).toBe('saml')
|
||||
it('should return DNS and HTTP proof instructions', async () => {
|
||||
const started = await user1Api.sso.startDomainVerification(configId)
|
||||
|
||||
expect(started.token).toBeTruthy()
|
||||
expect(started.dnsRecord).toBe(`taskview-sso-verify=${started.token}`)
|
||||
expect(started.httpUrl).toContain('/.well-known/taskview-sso-verify.txt')
|
||||
expect(started.isDomainVerified).toBe(false)
|
||||
})
|
||||
|
||||
it('should not mark a domain verified when DNS and HTTP proofs are missing', async () => {
|
||||
const result = await user1Api.sso.checkDomainVerification(configId)
|
||||
expect(result.verified).toBe(false)
|
||||
expect(result.method).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for unknown domain', async () => {
|
||||
@@ -189,6 +204,131 @@ describe('SSO: config management', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSO: cross-org domain squatting', () => {
|
||||
const squatDomain = 'squat-test.example'
|
||||
let secondOrgId: number
|
||||
let firstConfigId: number
|
||||
let secondConfigId: number
|
||||
|
||||
beforeAll(async () => {
|
||||
const org = await user2Api.organizations.create({ name: 'SSO Squat Org' })
|
||||
secondOrgId = org.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await user1Api.sso.deleteConfig(firstConfigId).catch(() => {})
|
||||
await user2Api.sso.deleteConfig(secondConfigId).catch(() => {})
|
||||
await user2Api.organizations.delete(secondOrgId).catch(() => {})
|
||||
})
|
||||
|
||||
it('lets a different org create an unverified config for the same domain (no squatting)', async () => {
|
||||
const first = await user1Api.sso.createConfig({
|
||||
organizationId: testOrgId,
|
||||
protocol: 'saml',
|
||||
displayName: 'Squat First',
|
||||
emailDomainRestriction: squatDomain,
|
||||
samlEntryPoint: 'https://idp.example.com/saml/sso',
|
||||
samlIssuer: 'taskview-squat-1',
|
||||
samlCert: 'MIICmzCCAYMCBgF...',
|
||||
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
|
||||
})
|
||||
firstConfigId = first.id
|
||||
expect(first.isDomainVerified).toBe(false)
|
||||
|
||||
const second = await user2Api.sso.createConfig({
|
||||
organizationId: secondOrgId,
|
||||
protocol: 'saml',
|
||||
displayName: 'Squat Second',
|
||||
emailDomainRestriction: squatDomain,
|
||||
samlEntryPoint: 'https://idp.example.com/saml/sso',
|
||||
samlIssuer: 'taskview-squat-2',
|
||||
samlCert: 'MIICmzCCAYMCBgF...',
|
||||
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
|
||||
})
|
||||
secondConfigId = second.id
|
||||
expect(second.isDomainVerified).toBe(false)
|
||||
expect(second.id).not.toBe(first.id)
|
||||
})
|
||||
|
||||
it('still rejects a duplicate config for the same domain within one org', async () => {
|
||||
try {
|
||||
await user1Api.sso.createConfig({
|
||||
organizationId: testOrgId,
|
||||
protocol: 'oidc',
|
||||
displayName: 'Squat Same Org',
|
||||
emailDomainRestriction: squatDomain,
|
||||
oidcIssuer: 'https://accounts.google.com',
|
||||
oidcClientId: 'test',
|
||||
oidcClientSecret: 'test',
|
||||
oidcCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
|
||||
})
|
||||
expect.fail('Should have rejected duplicate domain within the same org')
|
||||
} catch (error: any) {
|
||||
expect(error.response?.status).toBe(409)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a second org creating a config for a domain another org already verified', async () => {
|
||||
const ownedDomain = 'owned-sso.example' // in SSO_TRUSTED_DOMAINS → verified on creation
|
||||
|
||||
const owner = await user1Api.sso.createConfig({
|
||||
organizationId: testOrgId,
|
||||
protocol: 'saml',
|
||||
displayName: 'Owned First',
|
||||
emailDomainRestriction: ownedDomain,
|
||||
samlEntryPoint: 'https://idp.example.com/saml/sso',
|
||||
samlIssuer: 'taskview-owned-1',
|
||||
samlCert: 'MIICmzCCAYMCBgF...',
|
||||
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
|
||||
})
|
||||
expect(owner.isDomainVerified).toBe(true)
|
||||
|
||||
try {
|
||||
await user2Api.sso.createConfig({
|
||||
organizationId: secondOrgId,
|
||||
protocol: 'saml',
|
||||
displayName: 'Owned Second',
|
||||
emailDomainRestriction: ownedDomain,
|
||||
samlEntryPoint: 'https://idp.example.com/saml/sso',
|
||||
samlIssuer: 'taskview-owned-2',
|
||||
samlCert: 'MIICmzCCAYMCBgF...',
|
||||
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
|
||||
})
|
||||
expect.fail('Should have rejected a domain already verified by another org')
|
||||
} catch (error: any) {
|
||||
expect(error.response?.status).toBe(409)
|
||||
} finally {
|
||||
await user1Api.sso.deleteConfig(owner.id).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects switching a pending config onto a domain another org already verified', async () => {
|
||||
const ownedDomain = 'owned-sso.example'
|
||||
|
||||
const owner = await user1Api.sso.createConfig({
|
||||
organizationId: testOrgId,
|
||||
protocol: 'saml',
|
||||
displayName: 'Owned For Update',
|
||||
emailDomainRestriction: ownedDomain,
|
||||
samlEntryPoint: 'https://idp.example.com/saml/sso',
|
||||
samlIssuer: 'taskview-owned-3',
|
||||
samlCert: 'MIICmzCCAYMCBgF...',
|
||||
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
|
||||
})
|
||||
expect(owner.isDomainVerified).toBe(true)
|
||||
|
||||
try {
|
||||
// user2's still-pending squat config tries to grab the owned domain
|
||||
await user2Api.sso.updateConfig(secondConfigId, { emailDomainRestriction: ownedDomain })
|
||||
expect.fail('Should have rejected switching onto a domain owned by another org')
|
||||
} catch (error: any) {
|
||||
expect(error.response?.status).toBe(409)
|
||||
} finally {
|
||||
await user1Api.sso.deleteConfig(owner.id).catch(() => {})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSO: SCIM token management', () => {
|
||||
let configId: number
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { TvApi } from '@/tv'
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
|
||||
|
||||
/**
|
||||
* CanFetchTask authorizes `req.query.taskId || req.params.taskId`, while
|
||||
* fetchTaskByIdNew reads `req.params`. A caller must not be able to name a task
|
||||
* they own in the query string and have the guard authorize it while the handler
|
||||
* returns someone else's task.
|
||||
*/
|
||||
describe('Task object-level access control', () => {
|
||||
let ownerApi: TvApi
|
||||
let outsiderApi: TvApi
|
||||
let deleteAllGoals: () => Promise<void>
|
||||
let attackerAxios: AxiosInstance
|
||||
let victimTaskId: number
|
||||
let attackerTaskId: number
|
||||
|
||||
beforeAll(async () => {
|
||||
const init = await initApi()
|
||||
ownerApi = init.$tvApi
|
||||
outsiderApi = init.$tvApiForSecondUser
|
||||
deleteAllGoals = init.deleteAllGoals
|
||||
|
||||
const auth = await axios.post(`${API_URL}/module/auth/login`, {
|
||||
login: DEFAULT_USER_2,
|
||||
password: DEFAULT_PASSWORD,
|
||||
})
|
||||
attackerAxios = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${auth.data.access}` },
|
||||
validateStatus: () => true,
|
||||
})
|
||||
|
||||
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim tasks ${Date.now()}` })
|
||||
if (!victimGoal) throw new Error('Failed to create victim goal')
|
||||
const victimTask = await ownerApi.tasks.createTask({
|
||||
goalId: victimGoal.id,
|
||||
description: `victim-secret-${Date.now()}`,
|
||||
})
|
||||
if (!victimTask) throw new Error('Failed to create victim task')
|
||||
victimTaskId = victimTask.id
|
||||
|
||||
const attackerGoal = await outsiderApi.goals.createGoal({ name: `Attacker tasks ${Date.now()}` })
|
||||
if (!attackerGoal) throw new Error('Failed to create attacker goal')
|
||||
const attackerTask = await outsiderApi.tasks.createTask({
|
||||
goalId: attackerGoal.id,
|
||||
description: `attacker-own-${Date.now()}`,
|
||||
})
|
||||
if (!attackerTask) throw new Error('Failed to create attacker task')
|
||||
attackerTaskId = attackerTask.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteAllGoals()
|
||||
})
|
||||
|
||||
it('rejects reading another user task when a self-owned taskId is put in the query string', async () => {
|
||||
const response = await attackerAxios.get(`/module/tasks/${victimTaskId}`, {
|
||||
params: { taskId: attackerTaskId },
|
||||
})
|
||||
|
||||
expect(
|
||||
response.status,
|
||||
`Leaked task ${victimTaskId}: ${JSON.stringify(response.data)}`,
|
||||
).toBe(403)
|
||||
})
|
||||
|
||||
it('control: without the query parameter the guard already rejects the same request', async () => {
|
||||
const response = await attackerAxios.get(`/module/tasks/${victimTaskId}`)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('control: the owner can still read their own task', async () => {
|
||||
const task = await ownerApi.tasks.fetchTaskById(victimTaskId)
|
||||
expect(task?.id).toBe(victimTaskId)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,14 @@ export default class TvGraph extends TvApiBase {
|
||||
);
|
||||
}
|
||||
|
||||
public async fetchTaskEdges(taskId: number) {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<GraphResponseAddEdge[]>>(
|
||||
`${this.moduleUrl}/task/${taskId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async fetchAllEdges(goalId: number) {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<GraphResponseAddEdge[]>>(
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
SsoConfig,
|
||||
SsoConfigArgCreate,
|
||||
SsoConfigArgUpdate,
|
||||
SsoDomainVerificationCheck,
|
||||
SsoDomainVerificationStart,
|
||||
SsoProviderPublic,
|
||||
SsoPublicUrls,
|
||||
} from './sso.types'
|
||||
@@ -63,6 +65,18 @@ export default class TvSsoApi extends TvApiBase {
|
||||
)
|
||||
}
|
||||
|
||||
public async startDomainVerification(configId: number) {
|
||||
return this.request(
|
||||
this.$axios.post<AppResponse<SsoDomainVerificationStart>>(`${this.moduleUrl}/admin/configs/${configId}/verify-domain`)
|
||||
)
|
||||
}
|
||||
|
||||
public async checkDomainVerification(configId: number) {
|
||||
return this.request(
|
||||
this.$axios.post<AppResponse<SsoDomainVerificationCheck>>(`${this.moduleUrl}/admin/configs/${configId}/verify-domain/check`)
|
||||
)
|
||||
}
|
||||
|
||||
public async checkDomain(domain: string) {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<SsoProviderPublic | null>>(`${this.moduleUrl}/providers`, {
|
||||
|
||||
@@ -25,11 +25,30 @@ export type SsoConfig = {
|
||||
hasSamlSigningCert: boolean
|
||||
hasOidcClientSecret: boolean
|
||||
hasScimToken: boolean
|
||||
domainVerifyToken: string | null
|
||||
domainVerifiedAt: string | null
|
||||
isDomainVerified: boolean
|
||||
isDomainTrusted: boolean
|
||||
domainVerifyDnsRecord: string | null
|
||||
domainVerifyHttpUrl: string
|
||||
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type SsoDomainVerificationStart = {
|
||||
token: string
|
||||
dnsRecord: string
|
||||
httpUrl: string
|
||||
isDomainVerified: boolean
|
||||
isDomainTrusted: boolean
|
||||
}
|
||||
|
||||
export type SsoDomainVerificationCheck = {
|
||||
verified: boolean
|
||||
method: 'dns' | 'http' | 'trusted' | null
|
||||
}
|
||||
|
||||
export type SsoConfigArgCreate = {
|
||||
organizationId: number
|
||||
protocol: 'saml' | 'oidc'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { bigint, integer, pgSchema, text, timestamp, unique, varchar } from 'drizzle-orm/pg-core'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { bigint, integer, pgSchema, text, timestamp, unique, uniqueIndex, varchar } from 'drizzle-orm/pg-core'
|
||||
import { UsersSchema } from './users.schema'
|
||||
import { OrganizationsSchema } from './organizations.schema'
|
||||
|
||||
@@ -24,14 +25,21 @@ export const SsoConfigsSchema = pgSchema('tv_auth').table('sso_configs', {
|
||||
oidcScope: varchar('oidc_scope'),
|
||||
|
||||
defaultOrgRole: varchar('default_org_role').notNull().default('member'),
|
||||
emailDomainRestriction: varchar('email_domain_restriction').notNull().unique(),
|
||||
emailDomainRestriction: varchar('email_domain_restriction').notNull(),
|
||||
|
||||
scimToken: varchar('scim_token'),
|
||||
scimEnabled: integer('scim_enabled').notNull().default(0),
|
||||
|
||||
domainVerifyToken: varchar('domain_verify_token'),
|
||||
domainVerifiedAt: timestamp('domain_verified_at'),
|
||||
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow(),
|
||||
})
|
||||
}, (table) => [
|
||||
uniqueIndex('sso_configs_verified_domain_uniq')
|
||||
.on(table.emailDomainRestriction)
|
||||
.where(sql`${table.domainVerifiedAt} IS NOT NULL`),
|
||||
])
|
||||
|
||||
export type SsoConfigsSchemaTypeForSelect = typeof SsoConfigsSchema.$inferSelect
|
||||
export type SsoConfigsSchemaTypeForInsert = typeof SsoConfigsSchema.$inferInsert
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-mcp",
|
||||
"version": "1.51.0",
|
||||
"version": "1.53.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": {
|
||||
|
||||
@@ -41,6 +41,8 @@ services:
|
||||
condition: service_completed_successfully
|
||||
env_file:
|
||||
- ../../dockers-check/.env.taskview
|
||||
environment:
|
||||
SSO_TRUSTED_DOMAINS: sso-e2e.test,auto.sso-e2e.test
|
||||
volumes:
|
||||
- ./e2e_logs:/usr/src/app/logs
|
||||
- ./e2e_updates:/usr/src/app/updates
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createServer, type IncomingMessage, type Server } from 'node:http'
|
||||
import { createSign, generateKeyPairSync, randomUUID } from 'node:crypto'
|
||||
|
||||
export type MockIdpUser = {
|
||||
sub: string
|
||||
email: string
|
||||
name?: string
|
||||
preferredUsername?: string
|
||||
}
|
||||
|
||||
export type MockOidcIdp = {
|
||||
issuer: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
setUser: (user: MockIdpUser) => void
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
function base64url(input: Buffer | string): string {
|
||||
return Buffer.from(input).toString('base64url')
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let data = ''
|
||||
req.on('data', (chunk) => (data += chunk))
|
||||
req.on('end', () => resolve(data))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal in-process OIDC identity provider for e2e tests: serves discovery,
|
||||
* authorize (immediate redirect back with a code), token (RS256-signed id_token
|
||||
* with the claims of the current test user) and JWKS endpoints.
|
||||
*/
|
||||
export async function startMockOidcIdp(port: number): Promise<MockOidcIdp> {
|
||||
const issuer = `http://127.0.0.1:${port}`
|
||||
const clientId = 'taskview-e2e-client'
|
||||
const clientSecret = 'taskview-e2e-secret'
|
||||
const kid = 'e2e-key'
|
||||
|
||||
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
|
||||
const publicJwk = { ...publicKey.export({ format: 'jwk' }), kid, alg: 'RS256', use: 'sig' }
|
||||
|
||||
let currentUser: MockIdpUser = { sub: 'e2e-sub', email: 'e2e@example.test' }
|
||||
const nonceByCode = new Map<string, string>()
|
||||
|
||||
function signIdToken(nonce: string | undefined): string {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT', kid }))
|
||||
const payload = base64url(
|
||||
JSON.stringify({
|
||||
iss: issuer,
|
||||
aud: clientId,
|
||||
sub: currentUser.sub,
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
email: currentUser.email,
|
||||
...(currentUser.name ? { name: currentUser.name } : {}),
|
||||
...(currentUser.preferredUsername ? { preferred_username: currentUser.preferredUsername } : {}),
|
||||
...(nonce ? { nonce } : {}),
|
||||
}),
|
||||
)
|
||||
const signature = createSign('RSA-SHA256').update(`${header}.${payload}`).sign(privateKey)
|
||||
return `${header}.${payload}.${base64url(signature)}`
|
||||
}
|
||||
|
||||
const server: Server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', issuer)
|
||||
|
||||
if (url.pathname === '/.well-known/openid-configuration') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/authorize`,
|
||||
token_endpoint: `${issuer}/token`,
|
||||
jwks_uri: `${issuer}/jwks`,
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['RS256'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
scopes_supported: ['openid', 'email', 'profile'],
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === '/authorize') {
|
||||
const redirectUri = url.searchParams.get('redirect_uri')
|
||||
const state = url.searchParams.get('state')
|
||||
const nonce = url.searchParams.get('nonce')
|
||||
if (!redirectUri) {
|
||||
res.writeHead(400).end('missing redirect_uri')
|
||||
return
|
||||
}
|
||||
const code = randomUUID()
|
||||
if (nonce) nonceByCode.set(code, nonce)
|
||||
const target = new URL(redirectUri)
|
||||
target.searchParams.set('code', code)
|
||||
if (state) target.searchParams.set('state', state)
|
||||
res.writeHead(302, { location: target.href }).end()
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === '/token' && req.method === 'POST') {
|
||||
const body = new URLSearchParams(await readBody(req))
|
||||
const code = body.get('code') ?? ''
|
||||
const nonce = nonceByCode.get(code)
|
||||
nonceByCode.delete(code)
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
access_token: randomUUID(),
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
scope: 'openid email profile',
|
||||
id_token: signIdToken(nonce),
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === '/jwks') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ keys: [publicJwk] }))
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404).end()
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve))
|
||||
|
||||
return {
|
||||
issuer,
|
||||
clientId,
|
||||
clientSecret,
|
||||
setUser: (user) => (currentUser = user),
|
||||
close: () => new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import { test, expect, request as pwRequest, type APIRequestContext } from '@playwright/test'
|
||||
import { TEST_USER } from './fixtures/auth'
|
||||
import { startMockOidcIdp, type MockIdpUser, type MockOidcIdp } from './fixtures/mock-oidc-idp'
|
||||
|
||||
const API_URL = process.env.E2E_API_URL ?? 'http://localhost:1401'
|
||||
const IDP_PORT = 14655
|
||||
const RUN_ID = Date.now()
|
||||
const EMAIL_DOMAIN = 'sso-e2e.test'
|
||||
const AUTO_EMAIL_DOMAIN = 'auto.sso-e2e.test'
|
||||
const TRUSTED_DOMAINS_HINT = 'SSO_TRUSTED_DOMAINS=sso-e2e.test,auto.sso-e2e.test'
|
||||
const RANDOM_LOGIN_RE = /^[A-Za-z0-9]{7}$/
|
||||
|
||||
let api: APIRequestContext
|
||||
let idp: MockOidcIdp
|
||||
let adminToken: string
|
||||
let ssoConfigId: number
|
||||
let adminOrgId: number
|
||||
let firstCollisionLogin: string
|
||||
|
||||
function getSetCookies(headers: { name: string, value: string }[]): string[] {
|
||||
return headers
|
||||
.filter((h) => h.name.toLowerCase() === 'set-cookie')
|
||||
.map((h) => h.value.split(';')[0])
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): { userData: { id: number, login: string, email: string } } {
|
||||
return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the full OIDC dance against the running API and the mock IdP:
|
||||
* initiate -> IdP authorize -> API callback -> one-time code -> JWT.
|
||||
* Returns the login stored for the user, taken from the issued JWT payload.
|
||||
*/
|
||||
async function loginViaSso(args: { user: MockIdpUser, configId?: number }): Promise<{ login: string, email: string }> {
|
||||
const { user, configId = ssoConfigId } = args
|
||||
idp.setUser(user)
|
||||
const flow = await pwRequest.newContext()
|
||||
try {
|
||||
const initiate = await flow.get(`${API_URL}/module/sso/login/${configId}`, { maxRedirects: 0 })
|
||||
expect(initiate.status()).toBe(302)
|
||||
const cookies = getSetCookies(initiate.headersArray())
|
||||
expect(cookies.length).toBeGreaterThanOrEqual(3)
|
||||
const authorizeUrl = initiate.headers()['location']
|
||||
|
||||
const authorize = await flow.get(authorizeUrl, { maxRedirects: 0 })
|
||||
expect(authorize.status()).toBe(302)
|
||||
const callbackUrl = authorize.headers()['location']
|
||||
|
||||
const callback = await flow.get(callbackUrl, {
|
||||
maxRedirects: 0,
|
||||
headers: { cookie: cookies.join('; ') },
|
||||
})
|
||||
expect(callback.status()).toBe(302)
|
||||
const redirect = new URL(callback.headers()['location'])
|
||||
expect(redirect.searchParams.has('sso_error'), `SSO callback failed: ${redirect.href}`).toBe(false)
|
||||
|
||||
const authData = JSON.parse(redirect.searchParams.get('tokens')!)
|
||||
const byCode = await flow.post(`${API_URL}/module/auth/login-by-code`, {
|
||||
data: { email: authData.email, code: authData.code },
|
||||
})
|
||||
expect(byCode.ok()).toBe(true)
|
||||
const { access } = await byCode.json()
|
||||
const { userData } = decodeJwtPayload(access)
|
||||
return { login: userData.login, email: userData.email }
|
||||
} finally {
|
||||
await flow.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.describe('SSO OIDC login', () => {
|
||||
test.beforeAll(async () => {
|
||||
idp = await startMockOidcIdp(IDP_PORT)
|
||||
api = await pwRequest.newContext()
|
||||
|
||||
const login = await api.post(`${API_URL}/module/auth/login`, {
|
||||
form: { login: TEST_USER.login, password: TEST_USER.password },
|
||||
})
|
||||
expect(login.ok()).toBe(true)
|
||||
adminToken = (await login.json()).access
|
||||
|
||||
const orgs = await api.get(`${API_URL}/module/organizations`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
})
|
||||
const adminOrg = (await orgs.json()).response
|
||||
.find((org: { currentUserRole: string }) => ['owner', 'admin'].includes(org.currentUserRole))
|
||||
expect(adminOrg).toBeTruthy()
|
||||
adminOrgId = adminOrg.id
|
||||
|
||||
const listed = await api.get(`${API_URL}/module/sso/admin/configs`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
params: { organizationId: adminOrg.id },
|
||||
})
|
||||
expect(listed.ok()).toBe(true)
|
||||
for (const config of (await listed.json()).response as { id: number, emailDomainRestriction: string }[]) {
|
||||
if ([EMAIL_DOMAIN, AUTO_EMAIL_DOMAIN].includes(config.emailDomainRestriction)) {
|
||||
await api.delete(`${API_URL}/module/sso/admin/configs/${config.id}`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const created = await api.post(`${API_URL}/module/sso/admin/configs`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
data: {
|
||||
organizationId: adminOrg.id,
|
||||
protocol: 'oidc',
|
||||
displayName: `E2E OIDC ${RUN_ID}`,
|
||||
enabled: 1,
|
||||
oidcIssuer: idp.issuer,
|
||||
oidcClientId: idp.clientId,
|
||||
oidcClientSecret: idp.clientSecret,
|
||||
oidcCallbackUrl: `${API_URL}/module/sso/callback/0`,
|
||||
oidcScope: 'openid email profile',
|
||||
defaultOrgRole: 'member',
|
||||
emailDomainRestriction: EMAIL_DOMAIN,
|
||||
},
|
||||
})
|
||||
expect(created.ok()).toBe(true)
|
||||
const createdBody = await created.json()
|
||||
ssoConfigId = createdBody.response.id
|
||||
expect(
|
||||
createdBody.response.isDomainTrusted || createdBody.response.isDomainVerified,
|
||||
`SSO login e2e needs the API to trust ${EMAIL_DOMAIN}. Set ${TRUSTED_DOMAINS_HINT} on the API.`,
|
||||
).toBe(true)
|
||||
expect(createdBody.response.enabled).toBe(1)
|
||||
|
||||
const patched = await api.patch(`${API_URL}/module/sso/admin/configs/${ssoConfigId}`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
data: { oidcCallbackUrl: `${API_URL}/module/sso/callback/${ssoConfigId}` },
|
||||
})
|
||||
expect(patched.ok()).toBe(true)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (ssoConfigId) {
|
||||
await api.delete(`${API_URL}/module/sso/admin/configs/${ssoConfigId}`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
})
|
||||
}
|
||||
await api?.dispose()
|
||||
await idp?.close()
|
||||
})
|
||||
|
||||
test('unverified domain cannot start SSO login', async () => {
|
||||
const created = await api.post(`${API_URL}/module/sso/admin/configs`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
data: {
|
||||
organizationId: adminOrgId,
|
||||
protocol: 'oidc',
|
||||
displayName: `E2E OIDC unverified ${RUN_ID}`,
|
||||
enabled: 1,
|
||||
oidcIssuer: idp.issuer,
|
||||
oidcClientId: idp.clientId,
|
||||
oidcClientSecret: idp.clientSecret,
|
||||
oidcCallbackUrl: `${API_URL}/module/sso/callback/0`,
|
||||
oidcScope: 'openid email profile',
|
||||
defaultOrgRole: 'member',
|
||||
emailDomainRestriction: `unverified-${RUN_ID}.example`,
|
||||
},
|
||||
})
|
||||
expect(created.ok()).toBe(true)
|
||||
const unverified = await created.json()
|
||||
expect(unverified.response.isDomainVerified).toBe(false)
|
||||
expect(unverified.response.enabled).toBe(0)
|
||||
const unverifiedId = unverified.response.id
|
||||
|
||||
try {
|
||||
const initiate = await api.get(`${API_URL}/module/sso/login/${unverifiedId}`, { maxRedirects: 0 })
|
||||
expect([302, 404]).toContain(initiate.status())
|
||||
if (initiate.status() === 302) {
|
||||
const location = new URL(initiate.headers()['location'])
|
||||
expect(location.searchParams.get('sso_error')).toBe('domain_unverified')
|
||||
}
|
||||
} finally {
|
||||
await api.delete(`${API_URL}/module/sso/admin/configs/${unverifiedId}`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('new SSO user gets preferred_username as login', async () => {
|
||||
const preferredUsername = `pu.${RUN_ID}`
|
||||
const { login } = await loginViaSso({
|
||||
user: {
|
||||
sub: `sub-1-${RUN_ID}`,
|
||||
email: `first.${RUN_ID}@${EMAIL_DOMAIN}`,
|
||||
name: 'First User',
|
||||
preferredUsername,
|
||||
},
|
||||
})
|
||||
expect(login).toBe(preferredUsername)
|
||||
})
|
||||
|
||||
test('new SSO user without preferred_username gets a random 7-char login', async () => {
|
||||
const { login } = await loginViaSso({
|
||||
user: {
|
||||
sub: `sub-2-${RUN_ID}`,
|
||||
email: `second.${RUN_ID}@${EMAIL_DOMAIN}`,
|
||||
name: 'Second User',
|
||||
},
|
||||
})
|
||||
expect(login).toMatch(RANDOM_LOGIN_RE)
|
||||
})
|
||||
|
||||
test('taken preferred_username gets a random letter suffix', async () => {
|
||||
const preferredUsername = `pu.${RUN_ID}`
|
||||
const { login } = await loginViaSso({
|
||||
user: {
|
||||
sub: `sub-3-${RUN_ID}`,
|
||||
email: `third.${RUN_ID}@${EMAIL_DOMAIN}`,
|
||||
name: 'Third User',
|
||||
preferredUsername,
|
||||
},
|
||||
})
|
||||
expect(login).toMatch(new RegExp(`^pu\\.${RUN_ID}\\.[a-z]{3}$`))
|
||||
firstCollisionLogin = login
|
||||
})
|
||||
|
||||
test('second collision gets a different letter suffix', async () => {
|
||||
const preferredUsername = `pu.${RUN_ID}`
|
||||
const { login } = await loginViaSso({
|
||||
user: {
|
||||
sub: `sub-4-${RUN_ID}`,
|
||||
email: `fourth.${RUN_ID}@${EMAIL_DOMAIN}`,
|
||||
name: 'Fourth User',
|
||||
preferredUsername,
|
||||
},
|
||||
})
|
||||
expect(login).toMatch(new RegExp(`^pu\\.${RUN_ID}\\.[a-z]{3}$`))
|
||||
expect(login).not.toBe(firstCollisionLogin)
|
||||
})
|
||||
|
||||
test('IdP email change keeps the same TaskView user', async () => {
|
||||
const preferredUsername = `email.change.${RUN_ID}`
|
||||
const sub = `sub-email-${RUN_ID}`
|
||||
const first = await loginViaSso({
|
||||
user: {
|
||||
sub,
|
||||
email: `before.${RUN_ID}@${EMAIL_DOMAIN}`,
|
||||
name: 'Before Change',
|
||||
preferredUsername,
|
||||
},
|
||||
})
|
||||
expect(first.login).toBe(preferredUsername)
|
||||
|
||||
const second = await loginViaSso({
|
||||
user: {
|
||||
sub,
|
||||
email: `after.${RUN_ID}@${EMAIL_DOMAIN}`,
|
||||
name: 'After Change',
|
||||
preferredUsername: `should.not.apply.${RUN_ID}`,
|
||||
},
|
||||
})
|
||||
expect(second.login).toBe(preferredUsername)
|
||||
expect(second.email).toBe(`after.${RUN_ID}@${EMAIL_DOMAIN}`)
|
||||
})
|
||||
|
||||
test('IdP email change refuses an address already used by another user', async () => {
|
||||
const taken = await loginViaSso({
|
||||
user: {
|
||||
sub: `sub-taken-${RUN_ID}`,
|
||||
email: `taken.${RUN_ID}@${EMAIL_DOMAIN}`,
|
||||
name: 'Taken User',
|
||||
preferredUsername: `taken.${RUN_ID}`,
|
||||
},
|
||||
})
|
||||
expect(taken.login).toBe(`taken.${RUN_ID}`)
|
||||
|
||||
await loginViaSso({
|
||||
user: {
|
||||
sub: `sub-changer-${RUN_ID}`,
|
||||
email: `changer.${RUN_ID}@${EMAIL_DOMAIN}`,
|
||||
name: 'Changer',
|
||||
preferredUsername: `changer.${RUN_ID}`,
|
||||
},
|
||||
})
|
||||
|
||||
const flow = await pwRequest.newContext()
|
||||
try {
|
||||
idp.setUser({
|
||||
sub: `sub-changer-${RUN_ID}`,
|
||||
email: `taken.${RUN_ID}@${EMAIL_DOMAIN}`,
|
||||
name: 'Changer',
|
||||
preferredUsername: `changer.${RUN_ID}`,
|
||||
})
|
||||
const initiate = await flow.get(`${API_URL}/module/sso/login/${ssoConfigId}`, { maxRedirects: 0 })
|
||||
expect(initiate.status()).toBe(302)
|
||||
const cookies = getSetCookies(initiate.headersArray())
|
||||
const authorize = await flow.get(initiate.headers()['location'], { maxRedirects: 0 })
|
||||
expect(authorize.status()).toBe(302)
|
||||
const callback = await flow.get(authorize.headers()['location'], {
|
||||
maxRedirects: 0,
|
||||
headers: { cookie: cookies.join('; ') },
|
||||
})
|
||||
expect(callback.status()).toBe(302)
|
||||
const redirect = new URL(callback.headers()['location'])
|
||||
expect(redirect.searchParams.get('sso_error')).toBe('email_in_use')
|
||||
} finally {
|
||||
await flow.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('second IdP user cannot take an email already linked on this SSO', async () => {
|
||||
const linked = await loginViaSso({
|
||||
user: {
|
||||
sub: `sub-linked-${RUN_ID}`,
|
||||
email: `linked.${RUN_ID}@${EMAIL_DOMAIN}`,
|
||||
name: 'Linked User',
|
||||
preferredUsername: `linked.${RUN_ID}`,
|
||||
},
|
||||
})
|
||||
expect(linked.login).toBe(`linked.${RUN_ID}`)
|
||||
|
||||
const flow = await pwRequest.newContext()
|
||||
try {
|
||||
idp.setUser({
|
||||
sub: `sub-other-${RUN_ID}`,
|
||||
email: `linked.${RUN_ID}@${EMAIL_DOMAIN}`,
|
||||
name: 'Other User',
|
||||
preferredUsername: `other.${RUN_ID}`,
|
||||
})
|
||||
const initiate = await flow.get(`${API_URL}/module/sso/login/${ssoConfigId}`, { maxRedirects: 0 })
|
||||
expect(initiate.status()).toBe(302)
|
||||
const cookies = getSetCookies(initiate.headersArray())
|
||||
const authorize = await flow.get(initiate.headers()['location'], { maxRedirects: 0 })
|
||||
expect(authorize.status()).toBe(302)
|
||||
const callback = await flow.get(authorize.headers()['location'], {
|
||||
maxRedirects: 0,
|
||||
headers: { cookie: cookies.join('; ') },
|
||||
})
|
||||
expect(callback.status()).toBe(302)
|
||||
const redirect = new URL(callback.headers()['location'])
|
||||
expect(redirect.searchParams.get('sso_error')).toBe('email_in_use')
|
||||
} finally {
|
||||
await flow.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('flow works without configured callback URL (auto-derived from request)', async () => {
|
||||
const created = await api.post(`${API_URL}/module/sso/admin/configs`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
data: {
|
||||
organizationId: adminOrgId,
|
||||
protocol: 'oidc',
|
||||
displayName: `E2E OIDC auto ${RUN_ID}`,
|
||||
enabled: 1,
|
||||
oidcIssuer: idp.issuer,
|
||||
oidcClientId: idp.clientId,
|
||||
oidcClientSecret: idp.clientSecret,
|
||||
oidcCallbackUrl: '',
|
||||
oidcScope: 'openid email profile',
|
||||
defaultOrgRole: 'member',
|
||||
emailDomainRestriction: AUTO_EMAIL_DOMAIN,
|
||||
},
|
||||
})
|
||||
expect(created.ok()).toBe(true)
|
||||
const autoBody = await created.json()
|
||||
expect(
|
||||
autoBody.response.isDomainTrusted || autoBody.response.isDomainVerified,
|
||||
`SSO login e2e needs the API to trust ${AUTO_EMAIL_DOMAIN}. Set ${TRUSTED_DOMAINS_HINT} on the API.`,
|
||||
).toBe(true)
|
||||
const autoConfigId = autoBody.response.id
|
||||
|
||||
try {
|
||||
const preferredUsername = `auto.pu.${RUN_ID}`
|
||||
const { login } = await loginViaSso({
|
||||
user: {
|
||||
sub: `sub-5-${RUN_ID}`,
|
||||
email: `fifth.${RUN_ID}@${AUTO_EMAIL_DOMAIN}`,
|
||||
name: 'Fifth User',
|
||||
preferredUsername,
|
||||
},
|
||||
configId: autoConfigId,
|
||||
})
|
||||
expect(login).toBe(preferredUsername)
|
||||
} finally {
|
||||
await api.delete(`${API_URL}/module/sso/admin/configs/${autoConfigId}`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "web-nuxt-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "1.51.0",
|
||||
"version": "1.53.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build && pnpm --filter capacitor-widget-bridge build",
|
||||
|
||||
@@ -68,6 +68,10 @@
|
||||
placeholder="https://api.example.com"
|
||||
class="w-full"
|
||||
autofocus
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
inputmode="url"
|
||||
@keyup.enter="handleAddServer"
|
||||
/>
|
||||
</UFormField>
|
||||
@@ -104,6 +108,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core'
|
||||
import { useAdditionalServer } from '@/composables/useAdditionalServer'
|
||||
import { normalizeServerUrl } from '@/helpers/serverUrl'
|
||||
|
||||
const { t } = useI18n()
|
||||
const bp = useBreakpoints({ ...breakpointsTailwind, fullscreenModalMax: 1366 })
|
||||
@@ -153,15 +158,7 @@ const serverOptions = computed(() => {
|
||||
return options
|
||||
})
|
||||
|
||||
const isValidUrl = computed(() => {
|
||||
if (!newServerUrl.value) return false
|
||||
try {
|
||||
const url = new URL(newServerUrl.value)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const isValidUrl = computed(() => normalizeServerUrl(newServerUrl.value) !== null)
|
||||
|
||||
watch(selectedServer, (newServer) => {
|
||||
if (newServer && newServer !== mainServer.value) {
|
||||
@@ -171,9 +168,8 @@ watch(selectedServer, (newServer) => {
|
||||
})
|
||||
|
||||
function handleAddServer() {
|
||||
if (!isValidUrl.value) return
|
||||
|
||||
const url = newServerUrl.value.trim().replace(/\/$/, '')
|
||||
const url = normalizeServerUrl(newServerUrl.value)
|
||||
if (!url) return
|
||||
|
||||
if (!allServers.value.includes(url) && url !== systemServer.value) {
|
||||
addServerFn.value(url)
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { ALL_TASKS_LIST_ID, type DefaultView } from 'taskview-api'
|
||||
import type { RouteLocationRaw, Router } from 'vue-router'
|
||||
import { $tvApi } from '@/plugins/axios'
|
||||
import { useUserStore } from '@/stores/user.store'
|
||||
import { useOrganizationStore } from '@/stores/organization.store'
|
||||
import { useUiPreferencesStore } from '@/stores/uiPreferences.store'
|
||||
|
||||
const VIEW_ROUTES: Record<DefaultView, string> = {
|
||||
tasks: 'user',
|
||||
kanban: 'kanban',
|
||||
graph: 'graph',
|
||||
sprints: 'sprints',
|
||||
}
|
||||
import { useProjectRoute } from '@/composables/useProjectRoute'
|
||||
|
||||
export const redirectToUser = async (router: Router) => {
|
||||
const userStore = useUserStore()
|
||||
@@ -36,15 +29,12 @@ export const resolveDefaultRoute = async (): Promise<RouteLocationRaw | null> =>
|
||||
// The default project may live in any of the user's organizations; try the current one first
|
||||
const orgStore = useOrganizationStore()
|
||||
const orgs = [...orgStore.organizations].sort((a) => (a.slug === orgStore.currentOrgSlug ? -1 : 1))
|
||||
const { projectRoute } = useProjectRoute()
|
||||
|
||||
for (const org of orgs) {
|
||||
const goals = await $tvApi.goals.fetchGoals(org.id)
|
||||
if (!goals?.some((goal) => goal.id === projectId)) continue
|
||||
|
||||
const view = uiPrefs.settings.defaultView ?? 'tasks'
|
||||
const params: Record<string, string | number> = { orgSlug: org.slug, projectId }
|
||||
if (view === 'tasks') params.listId = ALL_TASKS_LIST_ID
|
||||
return { name: VIEW_ROUTES[view], params }
|
||||
const goal = goals?.find((g) => g.id === projectId)
|
||||
if (goal) return projectRoute(goal, org.slug)
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
class="w-full"
|
||||
:ui="{ list: 'rounded-2xl', trigger: 'rounded-xl', indicator: 'rounded-xl' }"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<span :data-testid="`collab-tab-${item.value}`">{{ item.label }}</span>
|
||||
</template>
|
||||
|
||||
<template #members>
|
||||
<MembersList
|
||||
:members="users"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
v-model="email"
|
||||
type="email"
|
||||
:placeholder="t('collaboration.members.emailPlaceholder')"
|
||||
data-testid="collab-member-email-input"
|
||||
size="xl"
|
||||
variant="soft"
|
||||
class="w-full"
|
||||
@@ -16,6 +17,7 @@
|
||||
v-if="isValid"
|
||||
icon="i-lucide-corner-down-left"
|
||||
:label="t('collaboration.members.add')"
|
||||
data-testid="collab-member-add-button"
|
||||
color="primary"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<label
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
:data-testid="`collab-member-role-${role.name}`"
|
||||
class="flex items-center gap-3 p-2 rounded hover:bg-elevated cursor-pointer"
|
||||
>
|
||||
<UCheckbox
|
||||
@@ -51,6 +52,7 @@
|
||||
/>
|
||||
<UButton
|
||||
:label="t('common.save')"
|
||||
data-testid="collab-member-roles-save"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
@click="handleSave"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between p-3 rounded-2xl border border-default hover:bg-elevated transition-colors">
|
||||
<div
|
||||
:data-testid="`collab-member-${member.email}`"
|
||||
class="flex items-center justify-between p-3 rounded-2xl border border-default hover:bg-elevated transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UAvatar
|
||||
:alt="member.email"
|
||||
@@ -42,6 +45,7 @@
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
data-testid="collab-member-menu-trigger"
|
||||
@click.stop="$emit('menu', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
<div class="p-1 flex flex-col gap-1">
|
||||
<UButton
|
||||
:label="t('collaboration.members.assignRoles')"
|
||||
data-testid="collab-member-assign-roles"
|
||||
icon="i-lucide-user-cog"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@@ -56,6 +57,7 @@
|
||||
<USeparator class="my-1" />
|
||||
<UButton
|
||||
:label="t('collaboration.members.remove')"
|
||||
data-testid="collab-member-remove"
|
||||
icon="i-lucide-user-minus"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
:placeholder="t('collaboration.permissions.searchPlaceholder')"
|
||||
data-testid="collab-permission-search"
|
||||
size="xl"
|
||||
variant="soft"
|
||||
class="w-full"
|
||||
@@ -39,6 +40,7 @@
|
||||
<label
|
||||
v-for="permission in group.permissions"
|
||||
:key="permission.id"
|
||||
:data-testid="`collab-permission-${permission.name}`"
|
||||
class="flex items-start gap-3 p-2 rounded hover:bg-elevated cursor-pointer"
|
||||
>
|
||||
<UCheckbox
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<UInput
|
||||
v-model="name"
|
||||
:placeholder="t('collaboration.roles.namePlaceholder')"
|
||||
data-testid="collab-role-name-input"
|
||||
size="xl"
|
||||
variant="soft"
|
||||
class="w-full"
|
||||
@@ -18,6 +19,7 @@
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:aria-label="t('collaboration.roles.create')"
|
||||
data-testid="collab-role-add-button"
|
||||
@click="addRole"
|
||||
/>
|
||||
<UIcon
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
/>
|
||||
<UButton
|
||||
:label="t('contextMenu.delete')"
|
||||
data-testid="collab-role-delete-confirm"
|
||||
color="error"
|
||||
variant="soft"
|
||||
@click="handleDelete"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between p-3 rounded-2xl border border-default hover:bg-elevated transition-colors">
|
||||
<div
|
||||
:data-testid="`collab-role-${role.name}`"
|
||||
class="flex items-center justify-between p-3 rounded-2xl border border-default hover:bg-elevated transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon
|
||||
name="i-lucide-shield"
|
||||
@@ -18,6 +21,7 @@
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
data-testid="collab-role-menu-trigger"
|
||||
@click.stop="$emit('menu', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
<div class="p-1 flex flex-col gap-1">
|
||||
<UButton
|
||||
:label="t('collaboration.roles.assignPermissions')"
|
||||
data-testid="collab-role-assign-permissions"
|
||||
icon="i-lucide-shield-check"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@@ -56,6 +57,7 @@
|
||||
<USeparator class="my-1" />
|
||||
<UButton
|
||||
:label="t('contextMenu.delete')"
|
||||
data-testid="collab-role-delete"
|
||||
icon="i-lucide-trash-2"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
v-model:nodes="store.nodes"
|
||||
v-model:edges="store.edges"
|
||||
:min-zoom="-2"
|
||||
fit-view-on-init
|
||||
only-render-visible-elements
|
||||
elevate-edges-on-select
|
||||
elevate-nodes-on-select
|
||||
:pan-on-scroll-mode="PanOnScrollMode.Free"
|
||||
@@ -14,6 +14,7 @@
|
||||
:nodes-connectable="canManageGraph"
|
||||
:nodes-draggable="canManageGraph"
|
||||
class="h-full w-full"
|
||||
:class="{ 'opacity-0': !layoutReady }"
|
||||
@connect-start="onConnectStart"
|
||||
@connect-end="onConnectEnd"
|
||||
>
|
||||
@@ -118,7 +119,9 @@ const applyFilters = () => {
|
||||
const nodeIds = new Set(filtered.map((n) => n.id))
|
||||
store.nodes = filtered
|
||||
store.edges = store.allEdges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target))
|
||||
setTimeout(() => layoutGraph(layoutDirection.value), 50)
|
||||
// Node sizes are estimated from data, so the layout can run right away —
|
||||
// no need to wait for nodes to render and be measured
|
||||
layoutGraph(layoutDirection.value)
|
||||
}
|
||||
|
||||
watch(listIds, applyFilters, { deep: true })
|
||||
@@ -132,8 +135,6 @@ const {
|
||||
onEdgesChange,
|
||||
onEdgeClick,
|
||||
onNodeDragStop,
|
||||
getEdges,
|
||||
updateEdgeData,
|
||||
removeEdges,
|
||||
screenToFlowCoordinate,
|
||||
} = useVueFlow()
|
||||
@@ -144,6 +145,8 @@ const store = useGraphStore()
|
||||
const { t } = useI18n()
|
||||
const { canManageGraph, canViewGraph } = useGoalPermissions()
|
||||
|
||||
const layoutReady = ref(false)
|
||||
|
||||
const addNewTaskToGraph = ref(false)
|
||||
const currentSession = ref<number | null>(null)
|
||||
const successfulSession = ref<number | null>(null)
|
||||
@@ -152,7 +155,7 @@ const nodePosition = ref<{ x: number; y: number } | undefined>(undefined)
|
||||
|
||||
const defaultEdgeOptions: DefaultEdgeOptions = {
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
animated: false,
|
||||
style: {
|
||||
strokeWidth: 3,
|
||||
},
|
||||
@@ -170,6 +173,7 @@ watch(
|
||||
projectId,
|
||||
(id) => {
|
||||
if (!id) return
|
||||
layoutReady.value = false
|
||||
store.fetchAllTasksAndLists(id).then(() => {
|
||||
applyFilters()
|
||||
})
|
||||
@@ -199,10 +203,15 @@ onConnect(async (params) => {
|
||||
addEdges([newEdge])
|
||||
})
|
||||
|
||||
function setAnimatedEdge(id: string | null) {
|
||||
store.edges = store.edges.map((edge) => ({ ...edge, animated: edge.id === id }))
|
||||
}
|
||||
|
||||
onEdgesChange((params) => {
|
||||
params.forEach((param) => {
|
||||
if (param.type === 'select' && !param.selected) {
|
||||
selectedEdge.value = null
|
||||
setAnimatedEdge(null)
|
||||
}
|
||||
if (param.type === 'remove') {
|
||||
deleteSelectedEdge(+param.id)
|
||||
@@ -213,6 +222,7 @@ onEdgesChange((params) => {
|
||||
|
||||
onEdgeClick((params) => {
|
||||
selectedEdge.value = params.edge
|
||||
setAnimatedEdge(params.edge.id)
|
||||
})
|
||||
|
||||
const newToken = () => {
|
||||
@@ -285,9 +295,7 @@ const layoutGraph = async (direction: 'LR' | 'TB') => {
|
||||
store.nodes = layout(store.nodes, store.edges, direction)
|
||||
nextTick(() => {
|
||||
fitView()
|
||||
getEdges.value.forEach((edge) => {
|
||||
updateEdgeData(edge.id, edge)
|
||||
})
|
||||
layoutReady.value = true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -27,10 +27,10 @@
|
||||
:style="sourceHandleStyle"
|
||||
/>
|
||||
|
||||
<TaskItem
|
||||
:task="props.data.task"
|
||||
<TaskItemCard
|
||||
:task="props.data.task"
|
||||
class="w-full"
|
||||
@toggle="toggleComplete($event)"
|
||||
@toggle="toggleComplete($event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -39,6 +39,7 @@
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { computed } from 'vue'
|
||||
import type { TaskItem } from '@/types/tasks.types'
|
||||
import TaskItemCard from '@/components/features/tasks/parts/TaskItem.vue'
|
||||
import { useTasksStore } from '@/stores/tasks.store'
|
||||
import { Task } from 'taskview-api'
|
||||
|
||||
|
||||
@@ -1,56 +1,106 @@
|
||||
import dagre from '@dagrejs/dagre'
|
||||
import { type Edge, type Node, Position, useVueFlow } from '@vue-flow/core'
|
||||
import { ref } from 'vue'
|
||||
import { type Edge, type Node, Position } from '@vue-flow/core'
|
||||
|
||||
const NODE_WIDTH = 288 // w-72 wrapper in TaskNode
|
||||
const NODE_MIN_HEIGHT = 74 // checkbox + priority column with paddings
|
||||
const NODE_PADDING_Y = 28 // p-3.5 top + bottom
|
||||
const TITLE_LINE_HEIGHT = 24 // text-base
|
||||
// Conservative: word-wrapping rarely fills lines completely, better to
|
||||
// overestimate height than to let ranks overlap
|
||||
const TITLE_CHARS_PER_LINE = 22
|
||||
const BADGE_ROW_HEIGHT = 30
|
||||
const BADGE_ROW_GAP = 8
|
||||
const TITLE_BADGES_GAP = 4
|
||||
// Handles stick out ~8px beyond the card on both sides, and the height estimate
|
||||
// can be off by a line — this safety margin keeps neighbors from touching
|
||||
const NODE_SAFETY = 24
|
||||
const CONTENT_WIDTH = 230 // node width minus paddings and the checkbox column
|
||||
const BADGE_CHROME_WIDTH = 34 // badge paddings + icon
|
||||
const BADGE_CHAR_WIDTH = 6.5
|
||||
const BADGE_GAP = 8
|
||||
|
||||
const ISOLATED_GAP_X = 40
|
||||
const ISOLATED_GAP_Y = 32
|
||||
const ISOLATED_BLOCK_OFFSET = 120
|
||||
const ISOLATED_MIN_ROW_WIDTH = 1200
|
||||
|
||||
// Estimates the rendered TaskNode size from task data alone, so the layout can
|
||||
// run before (and without) rendering every node — a prerequisite for
|
||||
// only-render-visible-elements, where offscreen nodes are never measured.
|
||||
function estimateNodeSize(node: Node): { width: number; height: number } {
|
||||
const task = node.data?.task
|
||||
if (!task) return { width: NODE_WIDTH, height: NODE_MIN_HEIGHT }
|
||||
|
||||
const titleLines = Math.max(1, Math.ceil((task.description?.length ?? 0) / TITLE_CHARS_PER_LINE))
|
||||
|
||||
// Estimated pixel widths of the badges TaskItem renders, in render order
|
||||
const badgeWidth = (labelLength: number) =>
|
||||
Math.min(CONTENT_WIDTH, BADGE_CHROME_WIDTH + labelLength * BADGE_CHAR_WIDTH)
|
||||
const badgeWidths: number[] = []
|
||||
if (task.endDate) badgeWidths.push(badgeWidth(11)) // dd.Mon.yyyy
|
||||
if (task.recurrenceRuleId) badgeWidths.push(BADGE_CHROME_WIDTH) // icon-only
|
||||
if (task.goalListId) badgeWidths.push(badgeWidth(10)) // list name (unknown here)
|
||||
if (task.amount) badgeWidths.push(badgeWidth(String(task.amount).length + 1))
|
||||
for (let i = 0; i < (task.assignedUsers?.length ?? 0); i++) badgeWidths.push(badgeWidth(20)) // email
|
||||
for (let i = 0; i < (task.tags?.length ?? 0); i++) badgeWidths.push(badgeWidth(9)) // tag name (unknown here)
|
||||
|
||||
// Greedy flex-wrap simulation: how many rows the badges take
|
||||
let badgeRows = 0
|
||||
let rowRemaining = 0
|
||||
for (const width of badgeWidths) {
|
||||
if (width + (badgeRows === 0 || rowRemaining === CONTENT_WIDTH ? 0 : BADGE_GAP) > rowRemaining) {
|
||||
badgeRows += 1
|
||||
rowRemaining = CONTENT_WIDTH - width
|
||||
} else {
|
||||
rowRemaining -= width + BADGE_GAP
|
||||
}
|
||||
}
|
||||
|
||||
const height =
|
||||
NODE_PADDING_Y +
|
||||
titleLines * TITLE_LINE_HEIGHT +
|
||||
(badgeRows > 0 ? TITLE_BADGES_GAP + badgeRows * BADGE_ROW_HEIGHT + (badgeRows - 1) * BADGE_ROW_GAP : 0)
|
||||
|
||||
return { width: NODE_WIDTH, height: Math.max(NODE_MIN_HEIGHT, height) + NODE_SAFETY }
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable to run the layout algorithm on the graph.
|
||||
* It uses the `dagre` library to calculate the layout of the nodes and edges.
|
||||
* Connected nodes are laid out with `dagre`; isolated nodes (no edges) are
|
||||
* arranged in a grid below the graph so they don't push linked nodes apart.
|
||||
* Node sizes are estimated from data, so no prior render is required.
|
||||
*/
|
||||
export function useLayout() {
|
||||
const { findNode } = useVueFlow()
|
||||
|
||||
const graph = ref(new dagre.graphlib.Graph())
|
||||
|
||||
const previousDirection = ref('LR')
|
||||
|
||||
function layout(nodes: Node[], edges: Edge[], direction: 'LR' | 'TB') {
|
||||
// we create a new graph instance, in case some nodes/edges were removed, otherwise dagre would act as if they were still there
|
||||
const dagreGraph = new dagre.graphlib.Graph()
|
||||
|
||||
graph.value = dagreGraph
|
||||
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}))
|
||||
|
||||
const isHorizontal = direction === 'LR'
|
||||
|
||||
// Isolated nodes would become extra dagre roots and push linked nodes apart —
|
||||
// lay out only the connected subgraph, grid the rest separately
|
||||
const connectedIds = new Set(edges.flatMap((edge) => [edge.source, edge.target]))
|
||||
const connectedNodes = nodes.filter((node) => connectedIds.has(node.id))
|
||||
const isolatedNodes = nodes.filter((node) => !connectedIds.has(node.id))
|
||||
|
||||
const dagreGraph = new dagre.graphlib.Graph()
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}))
|
||||
dagreGraph.setGraph({
|
||||
rankdir: direction,
|
||||
// align: 'UL', // Align to upper left
|
||||
nodesep: 50, // Minimum space between nodes
|
||||
ranksep: 100, // Minimum space between ranks
|
||||
marginx: 20,
|
||||
marginy: 20,
|
||||
})
|
||||
|
||||
previousDirection.value = direction
|
||||
|
||||
for (const node of nodes) {
|
||||
// if you need width+height of nodes for your layout, you can use the dimensions property of the internal node (`GraphNode` type)
|
||||
const graphNode = findNode(node.id)
|
||||
|
||||
dagreGraph.setNode(node.id, {
|
||||
width: graphNode?.dimensions.width || 150,
|
||||
height: graphNode?.dimensions.height || 50,
|
||||
})
|
||||
for (const node of connectedNodes) {
|
||||
dagreGraph.setNode(node.id, estimateNodeSize(node))
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
dagreGraph.setEdge(edge.source, edge.target)
|
||||
}
|
||||
|
||||
dagre.layout(dagreGraph)
|
||||
|
||||
// set nodes with updated positions
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
// dagre returns node centers — keep them as centers for the TB inversion below
|
||||
const layoutedConnected = connectedNodes.map((node) => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id)
|
||||
|
||||
return {
|
||||
@@ -63,58 +113,60 @@ export function useLayout() {
|
||||
|
||||
// For TB mode, invert Y coordinates to put root at top
|
||||
if (!isHorizontal) {
|
||||
const maxY = Math.max(...layoutedNodes.map((node) => node.position.y))
|
||||
const maxY = Math.max(...layoutedConnected.map((node) => node.position.y))
|
||||
|
||||
layoutedNodes.forEach((node) => {
|
||||
const graphNode = findNode(node.id)
|
||||
const nodeHeight = graphNode?.dimensions.height || 50
|
||||
|
||||
// Invert Y coordinate and adjust for node height to keep center aligned
|
||||
node.position.y = maxY - node.position.y + nodeHeight
|
||||
layoutedConnected.forEach((node) => {
|
||||
node.position.y = maxY - node.position.y
|
||||
})
|
||||
}
|
||||
|
||||
return layoutedNodes
|
||||
// Convert centers to top-left corners (what vue-flow positions actually are)
|
||||
layoutedConnected.forEach((node) => {
|
||||
const { width, height } = estimateNodeSize(node)
|
||||
node.position.x -= width / 2
|
||||
node.position.y -= height / 2
|
||||
})
|
||||
|
||||
// Grid for isolated nodes below the connected graph
|
||||
const hasConnected = layoutedConnected.length > 0
|
||||
const boundsBottom = hasConnected
|
||||
? Math.max(...layoutedConnected.map((node) => node.position.y + estimateNodeSize(node).height))
|
||||
: 0
|
||||
const boundsLeft = hasConnected
|
||||
? Math.min(...layoutedConnected.map((node) => node.position.x))
|
||||
: 0
|
||||
const boundsWidth = hasConnected
|
||||
? Math.max(...layoutedConnected.map((node) => node.position.x + estimateNodeSize(node).width)) - boundsLeft
|
||||
: 0
|
||||
const rowWidth = Math.max(boundsWidth, ISOLATED_MIN_ROW_WIDTH)
|
||||
|
||||
let x = boundsLeft
|
||||
let y = boundsBottom + (hasConnected ? ISOLATED_BLOCK_OFFSET : 0)
|
||||
let rowHeight = 0
|
||||
|
||||
const layoutedIsolated = isolatedNodes.map((node) => {
|
||||
const { width, height } = estimateNodeSize(node)
|
||||
|
||||
if (x > boundsLeft && x + width > boundsLeft + rowWidth) {
|
||||
x = boundsLeft
|
||||
y += rowHeight + ISOLATED_GAP_Y
|
||||
rowHeight = 0
|
||||
}
|
||||
|
||||
const position = { x, y }
|
||||
x += width + ISOLATED_GAP_X
|
||||
rowHeight = Math.max(rowHeight, height)
|
||||
|
||||
return {
|
||||
...node,
|
||||
targetPosition: isHorizontal ? Position.Left : Position.Top,
|
||||
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
|
||||
position,
|
||||
}
|
||||
})
|
||||
|
||||
return [...layoutedConnected, ...layoutedIsolated]
|
||||
}
|
||||
|
||||
// function layout(nodes: Node[], edges: Edge[], direction: 'LR' | 'TB') {
|
||||
// // we create a new graph instance, in case some nodes/edges were removed, otherwise dagre would act as if they were still there
|
||||
// const dagreGraph = new dagre.graphlib.Graph()
|
||||
|
||||
// graph.value = dagreGraph
|
||||
|
||||
// dagreGraph.setDefaultEdgeLabel(() => ({}))
|
||||
|
||||
// const isHorizontal = direction === 'LR'
|
||||
// dagreGraph.setGraph({ rankdir: direction })
|
||||
|
||||
// previousDirection.value = direction
|
||||
|
||||
// for (const node of nodes) {
|
||||
// // if you need width+height of nodes for your layout, you can use the dimensions property of the internal node (`GraphNode` type)
|
||||
// const graphNode = findNode(node.id)
|
||||
|
||||
// dagreGraph.setNode(node.id, { width: graphNode.dimensions.width || 150, height: graphNode.dimensions.height || 50 })
|
||||
// }
|
||||
|
||||
// for (const edge of edges) {
|
||||
// dagreGraph.setEdge(edge.source, edge.target)
|
||||
// }
|
||||
|
||||
// dagre.layout(dagreGraph)
|
||||
|
||||
// // set nodes with updated positions
|
||||
// return nodes.map((node) => {
|
||||
// const nodeWithPosition = dagreGraph.node(node.id)
|
||||
|
||||
// return {
|
||||
// ...node,
|
||||
// targetPosition: isHorizontal ? Position.Left : Position.Top,
|
||||
// sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
|
||||
// position: { x: nodeWithPosition.x, y: nodeWithPosition.y },
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
return { graph, layout, previousDirection }
|
||||
return { layout }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<div
|
||||
v-for="status in kanbanStore.statuses"
|
||||
:key="status.id"
|
||||
:data-testid="`kanban-column-${t(status.name)}`"
|
||||
class="h-full max-w-[340px] min-w-[272px] shadow-lg gap-2 flex flex-col w-[91.666667%] rounded-lg"
|
||||
>
|
||||
<div class="bg-elevated rounded-lg p-2 px-3 flex items-center text-base h-10 rounded-b-none">
|
||||
@@ -46,6 +47,7 @@
|
||||
<div
|
||||
:data-order="element.kanbanOrder"
|
||||
:data-task-id="element.id"
|
||||
data-testid="kanban-task-card"
|
||||
>
|
||||
<TaskItem
|
||||
:task="element"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<UInput
|
||||
v-model="statusName"
|
||||
:placeholder="t('kanban.addColumn')"
|
||||
data-testid="kanban-add-column-input"
|
||||
icon="i-lucide-plus"
|
||||
size="xl"
|
||||
variant="soft"
|
||||
@@ -12,6 +13,7 @@
|
||||
|
||||
<UButton
|
||||
v-if="statusName.trim()"
|
||||
data-testid="kanban-add-column-submit"
|
||||
@click="addStatus"
|
||||
>
|
||||
{{ t('kanban.addColumn') }}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<UButton
|
||||
color="error"
|
||||
variant="soft"
|
||||
data-testid="kanban-delete-confirm"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<UInput
|
||||
v-model="name"
|
||||
:placeholder="t('kanban.columnName')"
|
||||
data-testid="kanban-edit-name"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
@@ -35,6 +36,7 @@
|
||||
</UButton>
|
||||
<UButton
|
||||
variant="outline"
|
||||
data-testid="kanban-edit-save"
|
||||
@click="save"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid="kanban-column-menu-trigger"
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
|
||||
@@ -48,6 +49,7 @@ const menuItems = computed<DropdownMenuItem[][]>(() => [
|
||||
label: t('kanban.edit'),
|
||||
icon: 'i-lucide-pencil',
|
||||
ui,
|
||||
'data-testid': 'kanban-menu-edit',
|
||||
// size: 'lg',
|
||||
onSelect: () => {
|
||||
editOpen.value = true
|
||||
@@ -58,6 +60,7 @@ const menuItems = computed<DropdownMenuItem[][]>(() => [
|
||||
icon: 'i-lucide-trash-2',
|
||||
color: 'error' as const,
|
||||
ui,
|
||||
'data-testid': 'kanban-menu-delete',
|
||||
onSelect: () => {
|
||||
deleteOpen.value = true
|
||||
},
|
||||
|
||||
@@ -9,11 +9,9 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
|
||||
const { t } = useI18n()
|
||||
const goalsStore = useGoalsStore()
|
||||
const hasActiveGoals = computed(() => goalsStore.goals.length > 0)
|
||||
const { hasActiveGoals } = useTaskView()
|
||||
</script>
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
</template>
|
||||
|
||||
<UTabs
|
||||
v-model="activeTab"
|
||||
:items="tabs"
|
||||
class="w-full"
|
||||
:ui="{ list: 'rounded-2xl', indicator: 'rounded-xl' }"
|
||||
@@ -106,6 +107,7 @@
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Organization } from 'taskview-api'
|
||||
import type { OrgDetailTab } from '../types'
|
||||
import { useOrganizationStore } from '@/stores/organization.store'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
import { useOrgPermissions } from '@/composables/useOrgPermissions'
|
||||
@@ -115,6 +117,7 @@ import OrgSsoSettings from './OrgSsoSettings.vue'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{
|
||||
organization: Organization | null
|
||||
initialTab?: OrgDetailTab
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -127,17 +130,26 @@ const editName = ref('')
|
||||
const editSlug = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
const activeTab = ref<OrgDetailTab>('general')
|
||||
|
||||
const tabs = computed(() => {
|
||||
const items = [
|
||||
{ label: t('organizations.general'), slot: 'general' },
|
||||
{ label: t('organizations.general'), slot: 'general', value: 'general' },
|
||||
]
|
||||
if (isAdmin.value) {
|
||||
items.push({ label: t('organizations.members'), slot: 'members' })
|
||||
items.push({ label: 'SSO', slot: 'sso' })
|
||||
items.push({ label: t('organizations.members'), slot: 'members', value: 'members' })
|
||||
items.push({ label: 'SSO', slot: 'sso', value: 'sso' })
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
const requested = props.initialTab ?? 'general'
|
||||
activeTab.value = tabs.value.some(tab => tab.value === requested) ? requested : 'general'
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.organization, (org) => {
|
||||
if (org) {
|
||||
editName.value = org.name
|
||||
|
||||
@@ -10,9 +10,17 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge :color="config.enabled ? 'success' : 'neutral'">
|
||||
<span
|
||||
class="text-xs"
|
||||
:class="config.enabled ? 'text-success' : 'text-dimmed'"
|
||||
>
|
||||
{{ config.enabled ? t('sso.enabled') : t('sso.disabled') }}
|
||||
</UBadge>
|
||||
</span>
|
||||
<USwitch
|
||||
:model-value="!!config.enabled"
|
||||
:loading="toggling"
|
||||
@update:model-value="toggleEnabled"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-pencil"
|
||||
size="xs"
|
||||
@@ -44,6 +52,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OrgSsoDomainSection
|
||||
:config="config"
|
||||
@updated="$emit('updated')"
|
||||
/>
|
||||
|
||||
<OrgSsoScimSection
|
||||
:config="config"
|
||||
:endpoint-url="scimEndpointUrl"
|
||||
@@ -53,17 +66,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { $tvApi } from '@/plugins/axios'
|
||||
import type { SsoConfig } from 'taskview-api'
|
||||
import OrgSsoScimSection from './OrgSsoScimSection.vue'
|
||||
import OrgSsoDomainSection from './OrgSsoDomainSection.vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
config: SsoConfig
|
||||
callbackUrl: string
|
||||
scimEndpointUrl: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
edit: []
|
||||
delete: []
|
||||
updated: []
|
||||
@@ -72,6 +88,24 @@ defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const toggling = ref(false)
|
||||
|
||||
async function toggleEnabled(enabled: boolean) {
|
||||
toggling.value = true
|
||||
try {
|
||||
await $tvApi.sso.updateConfig(props.config.id, { enabled: enabled ? 1 : 0 })
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
toast.add({
|
||||
title: status === 403 ? t('sso.enableRequiresVerifiedDomain') : t('sso.toggleFailed'),
|
||||
color: 'error',
|
||||
})
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 p-3 rounded-md border border-default">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
<p class="text-sm font-medium">
|
||||
{{ t('sso.domainVerification') }}
|
||||
</p>
|
||||
<p class="text-xs text-dimmed">
|
||||
{{ t('sso.domainVerificationDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
<UBadge
|
||||
:color="verified ? 'success' : 'warning'"
|
||||
icon="mage:exclamation-triangle"
|
||||
>
|
||||
{{ verified ? t('sso.domainVerified') : t('sso.domainUnverified') }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="config.isDomainTrusted"
|
||||
class="text-xs text-dimmed"
|
||||
>
|
||||
{{ t('sso.domainTrustedHint') }}
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xs text-dimmed">
|
||||
{{ t('sso.domainVerifyDns') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="text-xs flex-1 break-all">{{ dnsRecord }}</code>
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
:disabled="!dnsRecord"
|
||||
@click="copyToClipboard(dnsRecord)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xs text-dimmed">
|
||||
{{ t('sso.domainVerifyHttp') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="text-xs flex-1 break-all">{{ httpUrl }}</code>
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
:disabled="!httpUrl"
|
||||
@click="copyToClipboard(httpUrl)"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-dimmed">
|
||||
{{ t('sso.domainVerifyHttpBody', { token: config.domainVerifyToken || '' }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
:label="t('sso.domainVerifyCheck')"
|
||||
icon="i-lucide-shield-check"
|
||||
variant="soft"
|
||||
size="lg"
|
||||
:loading="checking"
|
||||
@click="check"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { $tvApi } from '@/plugins/axios'
|
||||
import type { SsoConfig } from 'taskview-api'
|
||||
|
||||
const props = defineProps<{
|
||||
config: SsoConfig
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updated: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const checking = ref(false)
|
||||
|
||||
const verified = computed(() => props.config.isDomainVerified)
|
||||
const dnsRecord = computed(() => props.config.domainVerifyDnsRecord || '')
|
||||
const httpUrl = computed(() => props.config.domainVerifyHttpUrl || '')
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.config.domainVerifyToken || props.config.isDomainTrusted) return
|
||||
try {
|
||||
await $tvApi.sso.startDomainVerification(props.config.id)
|
||||
emit('updated')
|
||||
} catch {
|
||||
/* token will appear after the next fetch */
|
||||
}
|
||||
})
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.add({ title: t('sso.copied'), color: 'success' })
|
||||
} catch {
|
||||
toast.add({ title: t('sso.copyFailed'), color: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function check() {
|
||||
checking.value = true
|
||||
try {
|
||||
const result = await $tvApi.sso.checkDomainVerification(props.config.id)
|
||||
if (result.verified) {
|
||||
toast.add({ title: t('sso.domainVerifySuccess'), color: 'success' })
|
||||
emit('updated')
|
||||
} else {
|
||||
toast.add({ title: t('sso.domainVerifyFailed'), color: 'error' })
|
||||
}
|
||||
} catch {
|
||||
toast.add({ title: t('sso.domainVerifyFailed'), color: 'error' })
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -30,7 +30,10 @@
|
||||
</template>
|
||||
</UFormField>
|
||||
|
||||
<UFormField :label="t('sso.oidcCallbackUrl')">
|
||||
<UFormField
|
||||
:label="t('sso.oidcCallbackUrl')"
|
||||
:description="t('sso.callbackUrlAutoHint')"
|
||||
>
|
||||
<UInput
|
||||
v-model="form.oidcCallbackUrl"
|
||||
:placeholder="callbackUrlPlaceholder"
|
||||
|
||||
@@ -49,7 +49,10 @@
|
||||
</template>
|
||||
</UFormField>
|
||||
|
||||
<UFormField :label="t('sso.samlCallbackUrl')">
|
||||
<UFormField
|
||||
:label="t('sso.samlCallbackUrl')"
|
||||
:description="t('sso.callbackUrlAutoHint')"
|
||||
>
|
||||
<UInput
|
||||
v-model="form.samlCallbackUrl"
|
||||
:placeholder="callbackUrlPlaceholder"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type OrgDetailTab = 'general' | 'members' | 'sso'
|
||||
@@ -25,9 +25,10 @@ import { useOrganizationStore } from '@/stores/organization.store'
|
||||
import ProjectsList from '@/components/features/projects/ProjectsList.vue'
|
||||
import ArchiveList from '@/components/features/projects/ArchiveList.vue'
|
||||
import type { Project, ProjectSaveData } from '@/components/features/projects/types'
|
||||
import { ALL_TASKS_LIST_ID } from 'taskview-api'
|
||||
import { useProjectRoute } from '@/composables/useProjectRoute'
|
||||
|
||||
const router = useRouter()
|
||||
const { projectRoute } = useProjectRoute()
|
||||
|
||||
const goalsStore = useGoalsStore()
|
||||
const orgStore = useOrganizationStore()
|
||||
@@ -75,7 +76,7 @@ async function handleAdd(name: string) {
|
||||
...(orgStore.currentOrg && { organizationId: orgStore.currentOrg.id }),
|
||||
})
|
||||
if (newProject) {
|
||||
router.push({ name: 'user', params: { projectId: newProject.id, listId: ALL_TASKS_LIST_ID } })
|
||||
router.push(projectRoute(newProject))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
variant="taskview"
|
||||
:to="{ name: 'user', params: { projectId: project.id, listId: '-1401' } }"
|
||||
:to="projectRoute(project)"
|
||||
:active="currentProjectId === project.id"
|
||||
>
|
||||
<div
|
||||
@@ -209,6 +209,7 @@ import ProjectDeleteDialog from '@/components/features/projects/parts/ProjectDel
|
||||
import ProjectAddInput from '@/components/features/projects/parts/ProjectAddInput.vue'
|
||||
import type { Project, ProjectSaveData } from '@/components/features/projects/types'
|
||||
import { useGoalPermissionsFor } from '@/composables/useGoalPermissions'
|
||||
import { useProjectRoute } from '@/composables/useProjectRoute'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -234,6 +235,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { projectRoute } = useProjectRoute()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { required: false, default: true })
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user