mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
feat: implement multi-user access and invite flows (#3784)
## Thinking Path > - Paperclip is the control plane for autonomous AI companies. > - V1 needs to stay local-first while also supporting shared, authenticated deployments. > - Human operators need real identities, company membership, invite flows, profile surfaces, and company-scoped access controls. > - Agents and operators also need the existing issue, inbox, workspace, approval, and plugin flows to keep working under those authenticated boundaries. > - This branch accumulated the multi-user implementation, follow-up QA fixes, workspace/runtime refinements, invite UX improvements, release-branch conflict resolution, and review hardening. > - This pull request consolidates that branch onto the current `master` branch as a single reviewable PR. > - The benefit is a complete multi-user implementation path with tests and docs carried forward without dropping existing branch work. ## What Changed - Added authenticated human-user access surfaces: auth/session routes, company user directory, profile settings, company access/member management, join requests, and invite management. - Added invite creation, invite landing, onboarding, logo/branding, invite grants, deduped join requests, and authenticated multi-user E2E coverage. - Tightened company-scoped and instance-admin authorization across board, plugin, adapter, access, issue, and workspace routes. - Added profile-image URL validation hardening, avatar preservation on name-only profile updates, and join-request uniqueness migration cleanup for pending human requests. - Added an atomic member role/status/grants update path so Company Access saves no longer leave partially updated permissions. - Improved issue chat, inbox, assignee identity rendering, sidebar/account/company navigation, workspace routing, and execution workspace reuse behavior for multi-user operation. - Added and updated server/UI tests covering auth, invites, membership, issue workspace inheritance, plugin authz, inbox/chat behavior, and multi-user flows. - Merged current `public-gh/master` into this branch, resolved all conflicts, and verified no `pnpm-lock.yaml` change is included in this PR diff. ## Verification - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts ui/src/components/IssueChatThread.test.tsx ui/src/pages/Inbox.test.tsx` - `pnpm run preflight:workspace-links && pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts` - `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts server/src/__tests__/workspace-runtime-service-authz.test.ts server/src/__tests__/access-validators.test.ts` - `pnpm exec vitest run server/src/__tests__/authz-company-access.test.ts server/src/__tests__/routines-routes.test.ts server/src/__tests__/sidebar-preferences-routes.test.ts server/src/__tests__/approval-routes-idempotency.test.ts server/src/__tests__/openclaw-invite-prompt-route.test.ts server/src/__tests__/agent-cross-tenant-authz-routes.test.ts server/src/__tests__/routines-e2e.test.ts` - `pnpm exec vitest run server/src/__tests__/auth-routes.test.ts ui/src/pages/CompanyAccess.test.tsx` - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/db typecheck && pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm db:generate` - `npx playwright test --config tests/e2e/playwright.config.ts --list` - Confirmed branch has no uncommitted changes and is `0` commits behind `public-gh/master` before PR creation. - Confirmed no `pnpm-lock.yaml` change is staged or present in the PR diff. ## Risks - High review surface area: this PR contains the accumulated multi-user branch plus follow-up fixes, so reviewers should focus especially on company-boundary enforcement and authenticated-vs-local deployment behavior. - UI behavior changed across invites, inbox, issue chat, access settings, and sidebar navigation; no browser screenshots are included in this branch-consolidation PR. - Plugin install, upgrade, and lifecycle/config mutations now require instance-admin access, which is intentional but may change expectations for non-admin board users. - A join-request dedupe migration rejects duplicate pending human requests before creating unique indexes; deployments with unusual historical duplicates should review the migration behavior. - Company member role/status/grant saves now use a new combined endpoint; older separate endpoints remain for compatibility. - Full production build was not run locally in this heartbeat; CI should cover the full matrix. ## Model Used - OpenAI Codex coding agent, GPT-5-based model, CLI/tool-use environment. Exact deployed model identifier and context window were not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Note on screenshots: this is a branch-consolidation PR for an already-developed multi-user branch, and no browser screenshots were captured during this heartbeat. --------- Co-authored-by: dotta <dotta@example.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,7 @@
|
||||
node_modules
|
||||
node_modules/
|
||||
**/node_modules
|
||||
**/node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/paperclipai/paperclip/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
|
||||
<a href="https://github.com/paperclipai/paperclip/stargazers"><img src="https://img.shields.io/github/stars/paperclipai/paperclip?style=flat" alt="Stars" /></a>
|
||||
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/badge/discord-join%20chat-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/discord/000000000?label=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
authUsers,
|
||||
companies,
|
||||
createDb,
|
||||
projects,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
const ORIGINAL_CWD = process.cwd();
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const itEmbeddedPostgres = embeddedPostgresSupport.supported ? it : it.skip;
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
@@ -373,6 +375,97 @@ describe("worktree helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
itEmbeddedPostgres(
|
||||
"seeds authenticated users into minimally cloned worktree instances",
|
||||
async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-auth-seed-"));
|
||||
const worktreeRoot = path.join(tempRoot, "PAP-999-auth-seed");
|
||||
const sourceHome = path.join(tempRoot, "source-home");
|
||||
const sourceConfigDir = path.join(sourceHome, "instances", "source");
|
||||
const sourceConfigPath = path.join(sourceConfigDir, "config.json");
|
||||
const sourceEnvPath = path.join(sourceConfigDir, ".env");
|
||||
const sourceKeyPath = path.join(sourceConfigDir, "secrets", "master.key");
|
||||
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const originalCwd = process.cwd();
|
||||
const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-");
|
||||
|
||||
try {
|
||||
const sourceDbClient = createDb(sourceDb.connectionString);
|
||||
await sourceDbClient.insert(authUsers).values({
|
||||
id: "user-existing",
|
||||
email: "existing@paperclip.ing",
|
||||
name: "Existing User",
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
|
||||
fs.mkdirSync(worktreeRoot, { recursive: true });
|
||||
|
||||
const sourceConfig = buildSourceConfig();
|
||||
sourceConfig.database = {
|
||||
mode: "postgres",
|
||||
embeddedPostgresDataDir: path.join(sourceConfigDir, "db"),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(sourceConfigDir, "backups"),
|
||||
},
|
||||
connectionString: sourceDb.connectionString,
|
||||
};
|
||||
sourceConfig.logging.logDir = path.join(sourceConfigDir, "logs");
|
||||
sourceConfig.storage.localDisk.baseDir = path.join(sourceConfigDir, "storage");
|
||||
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
|
||||
|
||||
fs.writeFileSync(sourceConfigPath, JSON.stringify(sourceConfig, null, 2) + "\n", "utf8");
|
||||
fs.writeFileSync(sourceEnvPath, "", "utf8");
|
||||
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
|
||||
|
||||
process.chdir(worktreeRoot);
|
||||
await worktreeInitCommand({
|
||||
name: "PAP-999-auth-seed",
|
||||
home: worktreeHome,
|
||||
fromConfig: sourceConfigPath,
|
||||
force: true,
|
||||
});
|
||||
|
||||
const targetConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(worktreeRoot, ".paperclip", "config.json"), "utf8"),
|
||||
) as PaperclipConfig;
|
||||
const { default: EmbeddedPostgres } = await import("embedded-postgres");
|
||||
const targetPg = new EmbeddedPostgres({
|
||||
databaseDir: targetConfig.database.embeddedPostgresDataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port: targetConfig.database.embeddedPostgresPort,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
await targetPg.start();
|
||||
try {
|
||||
const targetDb = createDb(
|
||||
`postgres://paperclip:paperclip@127.0.0.1:${targetConfig.database.embeddedPostgresPort}/paperclip`,
|
||||
);
|
||||
const seededUsers = await targetDb.select().from(authUsers);
|
||||
expect(seededUsers.some((row) => row.email === "existing@paperclip.ing")).toBe(true);
|
||||
} finally {
|
||||
await targetPg.stop();
|
||||
}
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
await sourceDb.cleanup();
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
20000,
|
||||
);
|
||||
|
||||
it("avoids ports already claimed by sibling worktree instance configs", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
|
||||
@@ -142,3 +142,4 @@ This prevents lockout when a user migrates from long-running local trusted usage
|
||||
- implementation plan: `doc/plans/deployment-auth-mode-consolidation.md`
|
||||
- V1 contract: `doc/SPEC-implementation.md`
|
||||
- operator workflows: `doc/DEVELOPING.md` and `doc/CLI.md`
|
||||
- invite/join state map: `doc/spec/invite-flow.md`
|
||||
|
||||
@@ -115,38 +115,6 @@ If the first real publish returns npm `E404`, check npm-side prerequisites befor
|
||||
- The initial publish must include `--access public` for a public scoped package.
|
||||
- npm also requires either account 2FA for publishing or a granular token that is allowed to bypass 2FA.
|
||||
|
||||
### Manual first publish for `@paperclipai/mcp-server`
|
||||
|
||||
If you need to publish only the MCP server package once by hand, use:
|
||||
|
||||
- `@paperclipai/mcp-server`
|
||||
|
||||
Recommended flow from the repo root:
|
||||
|
||||
```bash
|
||||
# optional sanity check: this 404s until the first publish exists
|
||||
npm view @paperclipai/mcp-server version
|
||||
|
||||
# make sure the build output is fresh
|
||||
pnpm --filter @paperclipai/mcp-server build
|
||||
|
||||
# confirm your local npm auth before the real publish
|
||||
npm whoami
|
||||
|
||||
# safe preview of the exact publish payload
|
||||
cd packages/mcp-server
|
||||
pnpm publish --dry-run --no-git-checks --access public
|
||||
|
||||
# real publish
|
||||
pnpm publish --no-git-checks --access public
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Publish from `packages/mcp-server/`, not the repo root.
|
||||
- If `npm view @paperclipai/mcp-server version` already returns the same version that is in [`packages/mcp-server/package.json`](../packages/mcp-server/package.json), do not republish. Bump the version or use the normal repo-wide release flow in [`scripts/release.sh`](../scripts/release.sh).
|
||||
- The same npm-side prerequisites apply as above: valid npm auth, permission to publish to the `@paperclipai` scope, `--access public`, and the required publish auth/2FA policy.
|
||||
|
||||
## Version formats
|
||||
|
||||
Paperclip uses calendar versions:
|
||||
|
||||
299
doc/spec/invite-flow.md
Normal file
299
doc/spec/invite-flow.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# Invite Flow State Map
|
||||
|
||||
Status: Current implementation map
|
||||
Date: 2026-04-13
|
||||
|
||||
This document maps the current invite creation and acceptance states implemented in:
|
||||
|
||||
- `ui/src/pages/CompanyInvites.tsx`
|
||||
- `ui/src/pages/CompanySettings.tsx`
|
||||
- `ui/src/pages/InviteLanding.tsx`
|
||||
- `server/src/routes/access.ts`
|
||||
- `server/src/lib/join-request-dedupe.ts`
|
||||
|
||||
## State Legend
|
||||
|
||||
- Invite state: `active`, `revoked`, `accepted`, `expired`
|
||||
- Join request status: `pending_approval`, `approved`, `rejected`
|
||||
- Claim secret state for agent joins: `available`, `consumed`, `expired`
|
||||
- Invite type: `company_join` or `bootstrap_ceo`
|
||||
- Join type: `human`, `agent`, or `both`
|
||||
|
||||
## Entity Lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Board[Board user on invite screen]
|
||||
HumanInvite[Create human company invite]
|
||||
OpenClawInvite[Generate OpenClaw invite prompt]
|
||||
Active[Invite state: active]
|
||||
Revoked[Invite state: revoked]
|
||||
Expired[Invite state: expired]
|
||||
Accepted[Invite state: accepted]
|
||||
BootstrapDone[Bootstrap accepted<br/>no join request]
|
||||
HumanReuse{Matching human join request<br/>already exists for same user/email?}
|
||||
HumanPending[Join request<br/>pending_approval]
|
||||
HumanApproved[Join request<br/>approved]
|
||||
HumanRejected[Join request<br/>rejected]
|
||||
AgentPending[Agent join request<br/>pending_approval<br/>+ optional claim secret]
|
||||
AgentApproved[Agent join request<br/>approved]
|
||||
AgentRejected[Agent join request<br/>rejected]
|
||||
ClaimAvailable[Claim secret available]
|
||||
ClaimConsumed[Claim secret consumed]
|
||||
ClaimExpired[Claim secret expired]
|
||||
OpenClawReplay[Special replay path:<br/>accepted invite can be POSTed again<br/>for openclaw_gateway only]
|
||||
|
||||
Board --> HumanInvite --> Active
|
||||
Board --> OpenClawInvite --> Active
|
||||
Active --> Revoked: revoke
|
||||
Active --> Expired: expiresAt passes
|
||||
|
||||
Active --> BootstrapDone: bootstrap_ceo accept
|
||||
BootstrapDone --> Accepted
|
||||
|
||||
Active --> HumanReuse: human accept
|
||||
HumanReuse --> HumanPending: reuse existing pending request
|
||||
HumanReuse --> HumanApproved: reuse existing approved request
|
||||
HumanReuse --> HumanPending: no reusable request<br/>create new request
|
||||
HumanPending --> HumanApproved: board approves
|
||||
HumanPending --> HumanRejected: board rejects
|
||||
HumanPending --> Accepted
|
||||
HumanApproved --> Accepted
|
||||
|
||||
Active --> AgentPending: agent accept
|
||||
AgentPending --> Accepted
|
||||
AgentPending --> AgentApproved: board approves
|
||||
AgentPending --> AgentRejected: board rejects
|
||||
AgentApproved --> ClaimAvailable: createdAgentId + claimSecretHash
|
||||
ClaimAvailable --> ClaimConsumed: POST claim-api-key succeeds
|
||||
ClaimAvailable --> ClaimExpired: secret expires
|
||||
|
||||
Accepted --> OpenClawReplay
|
||||
OpenClawReplay --> AgentPending
|
||||
OpenClawReplay --> AgentApproved
|
||||
```
|
||||
|
||||
## Board-Side Screen States
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> CompanySelection
|
||||
|
||||
CompanySelection --> NoCompany: no company selected
|
||||
CompanySelection --> LoadingHistory: selectedCompanyId present
|
||||
LoadingHistory --> HistoryError: listInvites failed
|
||||
LoadingHistory --> Ready: listInvites succeeded
|
||||
|
||||
state Ready {
|
||||
[*] --> EmptyHistory
|
||||
EmptyHistory --> PopulatedHistory: invites exist
|
||||
PopulatedHistory --> LoadingMore: View more
|
||||
LoadingMore --> PopulatedHistory: next page loaded
|
||||
|
||||
PopulatedHistory --> RevokePending: Revoke active invite
|
||||
RevokePending --> PopulatedHistory: revoke succeeded
|
||||
RevokePending --> PopulatedHistory: revoke failed
|
||||
|
||||
EmptyHistory --> CreatePending: Create invite
|
||||
PopulatedHistory --> CreatePending: Create invite
|
||||
CreatePending --> LatestInviteVisible: create succeeded
|
||||
CreatePending --> Ready: create failed
|
||||
LatestInviteVisible --> CopiedToast: clipboard copy succeeded
|
||||
LatestInviteVisible --> Ready: navigate away or refresh
|
||||
}
|
||||
|
||||
CompanySelection --> OpenClawPromptReady: Company settings prompt generator
|
||||
OpenClawPromptReady --> OpenClawPromptPending: Generate OpenClaw Invite Prompt
|
||||
OpenClawPromptPending --> OpenClawSnippetVisible: prompt generated
|
||||
OpenClawPromptPending --> OpenClawPromptReady: generation failed
|
||||
```
|
||||
|
||||
## Invite Landing Screen States
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> TokenGate
|
||||
|
||||
TokenGate --> InvalidToken: token missing
|
||||
TokenGate --> Loading: token present
|
||||
Loading --> InviteUnavailable: invite fetch failed or invite not returned
|
||||
Loading --> CheckingAccess: signed-in session and invite.companyId
|
||||
Loading --> InviteResolved: invite loaded without membership check
|
||||
Loading --> AcceptedInviteSummary: invite already consumed<br/>but linked join request still exists
|
||||
|
||||
CheckingAccess --> RedirectToBoard: current user already belongs to company
|
||||
CheckingAccess --> InviteResolved: membership check finished and no join-request summary state is active
|
||||
CheckingAccess --> AcceptedInviteSummary: membership check finished and invite has joinRequestStatus
|
||||
|
||||
state InviteResolved {
|
||||
[*] --> Branch
|
||||
Branch --> AgentForm: company_join + allowedJoinTypes=agent
|
||||
Branch --> InlineAuth: authenticated mode + no session + join is not agent-only
|
||||
Branch --> AcceptReady: bootstrap invite or human-ready session/local_trusted
|
||||
|
||||
InlineAuth --> InlineAuth: toggle sign-up/sign-in
|
||||
InlineAuth --> InlineAuth: auth validation or auth error message
|
||||
InlineAuth --> RedirectToBoard: auth succeeded and company membership already exists
|
||||
InlineAuth --> AcceptPending: auth succeeded and invite still needs acceptance
|
||||
|
||||
AgentForm --> AcceptPending: submit request
|
||||
AgentForm --> AgentForm: validation or accept error
|
||||
|
||||
AcceptReady --> AcceptPending: Accept invite
|
||||
AcceptReady --> AcceptReady: accept error
|
||||
}
|
||||
|
||||
AcceptPending --> BootstrapComplete: bootstrapAccepted=true
|
||||
AcceptPending --> RedirectToBoard: join status=approved
|
||||
AcceptPending --> PendingApprovalResult: join status=pending_approval
|
||||
AcceptPending --> RejectedResult: join status=rejected
|
||||
|
||||
state AcceptedInviteSummary {
|
||||
[*] --> SummaryBranch
|
||||
SummaryBranch --> PendingApprovalReload: joinRequestStatus=pending_approval
|
||||
SummaryBranch --> OpeningCompany: joinRequestStatus=approved<br/>and human invite user is now a member
|
||||
SummaryBranch --> RejectedReload: joinRequestStatus=rejected
|
||||
SummaryBranch --> ConsumedReload: approved agent invite or other consumed state
|
||||
}
|
||||
|
||||
PendingApprovalResult --> PendingApprovalReload: reload after submit
|
||||
RejectedResult --> RejectedReload: reload after board rejects
|
||||
RedirectToBoard --> OpeningCompany: brief pre-navigation render when approved membership is detected
|
||||
OpeningCompany --> RedirectToBoard: navigate to board
|
||||
```
|
||||
|
||||
## Sequence Diagrams
|
||||
|
||||
### Human Invite Creation And First Acceptance
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Board as Board user
|
||||
participant Settings as Company Invites UI
|
||||
participant API as Access routes
|
||||
participant Invites as invites table
|
||||
actor Invitee as Invite recipient
|
||||
participant Landing as Invite landing UI
|
||||
participant Auth as Auth session
|
||||
participant Join as join_requests table
|
||||
|
||||
Board->>Settings: Choose role and click Create invite
|
||||
Settings->>API: POST /api/companies/:companyId/invites
|
||||
API->>Invites: Insert active invite
|
||||
API-->>Settings: inviteUrl + metadata
|
||||
|
||||
Invitee->>Landing: Open invite URL
|
||||
Landing->>API: GET /api/invites/:token
|
||||
API->>Invites: Load active invite
|
||||
API-->>Landing: Invite summary
|
||||
|
||||
alt Authenticated mode and no session
|
||||
Landing->>Auth: Sign up or sign in
|
||||
Auth-->>Landing: Session established
|
||||
end
|
||||
|
||||
Landing->>API: POST /api/invites/:token/accept (requestType=human)
|
||||
API->>Join: Look for reusable human join request
|
||||
alt Reusable pending or approved request exists
|
||||
API->>Invites: Mark invite accepted
|
||||
API-->>Landing: Existing join request status
|
||||
else No reusable request exists
|
||||
API->>Invites: Mark invite accepted
|
||||
API->>Join: Insert pending_approval join request
|
||||
API-->>Landing: New pending_approval join request
|
||||
end
|
||||
```
|
||||
|
||||
### Human Approval And Reload Path
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Invitee as Invite recipient
|
||||
participant Landing as Invite landing UI
|
||||
participant API as Access routes
|
||||
participant Join as join_requests table
|
||||
actor Approver as Company admin
|
||||
participant Queue as Access queue UI
|
||||
participant Membership as company_memberships + grants
|
||||
|
||||
Invitee->>Landing: Reload consumed invite URL
|
||||
Landing->>API: GET /api/invites/:token
|
||||
API->>Join: Load join request by inviteId
|
||||
API-->>Landing: joinRequestStatus + joinRequestType
|
||||
|
||||
alt joinRequestStatus = pending_approval
|
||||
Landing-->>Invitee: Show waiting-for-approval panel
|
||||
Approver->>Queue: Review request in Company Settings -> Access
|
||||
Queue->>API: POST /companies/:companyId/join-requests/:requestId/approve
|
||||
API->>Membership: Ensure membership and grants
|
||||
API->>Join: Mark join request approved
|
||||
Invitee->>Landing: Refresh after approval
|
||||
Landing->>API: GET /api/invites/:token
|
||||
API->>Join: Reload approved join request
|
||||
API-->>Landing: approved status
|
||||
Landing-->>Invitee: Opening company and redirect
|
||||
else joinRequestStatus = rejected
|
||||
Landing-->>Invitee: Show rejected error panel
|
||||
else joinRequestStatus = approved but membership missing
|
||||
Landing-->>Invitee: Fall through to consumed/unavailable state
|
||||
end
|
||||
```
|
||||
|
||||
### Agent Invite Approval, Claim, And Replay
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Board as Board user
|
||||
participant Settings as Company Settings UI
|
||||
participant API as Access routes
|
||||
participant Invites as invites table
|
||||
actor Gateway as OpenClaw gateway agent
|
||||
participant Join as join_requests table
|
||||
actor Approver as Company admin
|
||||
participant Agents as agents table
|
||||
participant Keys as agent_api_keys table
|
||||
|
||||
Board->>Settings: Generate OpenClaw invite prompt
|
||||
Settings->>API: POST /api/companies/:companyId/openclaw-invite-prompt
|
||||
API->>Invites: Insert active agent invite
|
||||
API-->>Settings: Prompt text + invite token
|
||||
|
||||
Gateway->>API: POST /api/invites/:token/accept (agent, openclaw_gateway)
|
||||
API->>Invites: Mark invite accepted
|
||||
API->>Join: Insert pending_approval join request + claimSecretHash
|
||||
API-->>Gateway: requestId + claimSecret + claimApiKeyPath
|
||||
|
||||
Approver->>API: POST /companies/:companyId/join-requests/:requestId/approve
|
||||
API->>Agents: Create agent + membership + grants
|
||||
API->>Join: Mark request approved and store createdAgentId
|
||||
|
||||
Gateway->>API: POST /api/join-requests/:requestId/claim-api-key (claimSecret)
|
||||
API->>Keys: Create initial API key
|
||||
API->>Join: Mark claim secret consumed
|
||||
API-->>Gateway: Plaintext Paperclip API key
|
||||
|
||||
opt Replay accepted invite for updated gateway defaults
|
||||
Gateway->>API: POST /api/invites/:token/accept again
|
||||
API->>Join: Reuse existing approved or pending request
|
||||
API->>Agents: Update approved agent adapter config when applicable
|
||||
API-->>Gateway: Updated join request payload
|
||||
end
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `GET /api/invites/:token` treats `revoked` and `expired` invites as unavailable. Accepted invites remain resolvable when they already have a linked join request, and the summary now includes `joinRequestStatus` plus `joinRequestType`.
|
||||
- Human acceptance consumes the invite immediately and then either creates a new join request or reuses an existing `pending_approval` or `approved` human join request for the same user/email.
|
||||
- The landing page has two layers of post-accept UI:
|
||||
- immediate mutation-result UI from `POST /api/invites/:token/accept`
|
||||
- reload-time summary UI from `GET /api/invites/:token` once the invite has already been consumed
|
||||
- Reload behavior for accepted company invites is now status-sensitive:
|
||||
- `pending_approval` re-renders the waiting-for-approval panel
|
||||
- `rejected` renders the "This join request was not approved." error panel
|
||||
- `approved` only becomes a success path for human invites after membership is visible to the current session; otherwise the page falls through to the generic consumed/unavailable state
|
||||
- `GET /api/invites/:token/logo` still rejects accepted invites, so accepted-invite reload states may fall back to the generated company icon even though the summary payload still carries `companyLogoUrl`.
|
||||
- The only accepted-invite replay path in the current implementation is `POST /api/invites/:token/accept` for `agent` requests with `adapterType=openclaw_gateway`, and only when the existing join request is still `pending_approval` or already `approved`.
|
||||
- `bootstrap_ceo` invites are one-time and do not create join requests.
|
||||
@@ -48,6 +48,8 @@
|
||||
"guides/board-operator/managing-tasks",
|
||||
"guides/board-operator/execution-workspaces-and-runtime-services",
|
||||
"guides/board-operator/delegation",
|
||||
"guides/board-operator/execution-workspaces-and-runtime-services",
|
||||
"guides/board-operator/delegation",
|
||||
"guides/board-operator/approvals",
|
||||
"guides/board-operator/costs-and-budgets",
|
||||
"guides/board-operator/activity-log",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
|
||||
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
|
||||
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed",
|
||||
"test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts",
|
||||
"evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval",
|
||||
"test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts",
|
||||
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed",
|
||||
|
||||
84
packages/db/scripts/create-auth-bootstrap-invite.ts
Normal file
84
packages/db/scripts/create-auth-bootstrap-invite.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { createDb } from "../src/client.js";
|
||||
import { invites } from "../src/schema/index.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
function createInviteToken() {
|
||||
return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
function readArg(flag: string) {
|
||||
const index = process.argv.indexOf(flag);
|
||||
if (index === -1) return null;
|
||||
return process.argv[index + 1] ?? null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const configPath = readArg("--config");
|
||||
const baseUrl = readArg("--base-url");
|
||||
|
||||
if (!configPath || !baseUrl) {
|
||||
throw new Error("Usage: tsx create-auth-bootstrap-invite.ts --config <path> --base-url <url>");
|
||||
}
|
||||
|
||||
const config = JSON.parse(readFileSync(path.resolve(configPath), "utf8")) as {
|
||||
database?: {
|
||||
mode?: string;
|
||||
embeddedPostgresPort?: number;
|
||||
connectionString?: string;
|
||||
};
|
||||
};
|
||||
const dbUrl =
|
||||
config.database?.mode === "postgres"
|
||||
? config.database.connectionString
|
||||
: `postgres://paperclip:paperclip@127.0.0.1:${config.database?.embeddedPostgresPort ?? 54329}/paperclip`;
|
||||
if (!dbUrl) {
|
||||
throw new Error(`Could not resolve database connection from ${configPath}`);
|
||||
}
|
||||
|
||||
const db = createDb(dbUrl);
|
||||
const closableDb = db as typeof db & {
|
||||
$client?: {
|
||||
end?: (options?: { timeout?: number }) => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
await db
|
||||
.update(invites)
|
||||
.set({ revokedAt: now, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(invites.inviteType, "bootstrap_ceo"),
|
||||
isNull(invites.revokedAt),
|
||||
isNull(invites.acceptedAt),
|
||||
gt(invites.expiresAt, now)
|
||||
)
|
||||
);
|
||||
|
||||
const token = createInviteToken();
|
||||
await db.insert(invites).values({
|
||||
inviteType: "bootstrap_ceo",
|
||||
tokenHash: hashToken(token),
|
||||
allowedJoinTypes: "human",
|
||||
expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000),
|
||||
invitedByUserId: "system",
|
||||
});
|
||||
|
||||
process.stdout.write(`${baseUrl.replace(/\/+$/, "")}/invite/${token}\n`);
|
||||
} finally {
|
||||
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
57
packages/db/src/migrations/0057_tidy_join_requests.sql
Normal file
57
packages/db/src/migrations/0057_tidy_join_requests.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
WITH ranked_user_requests AS (
|
||||
SELECT
|
||||
id,
|
||||
row_number() OVER (
|
||||
PARTITION BY company_id, requesting_user_id
|
||||
ORDER BY created_at ASC, id ASC
|
||||
) AS rank
|
||||
FROM join_requests
|
||||
WHERE request_type = 'human'
|
||||
AND status = 'pending_approval'
|
||||
AND requesting_user_id IS NOT NULL
|
||||
)
|
||||
UPDATE join_requests
|
||||
SET
|
||||
status = 'rejected',
|
||||
rejected_at = COALESCE(rejected_at, now()),
|
||||
updated_at = now()
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM ranked_user_requests
|
||||
WHERE rank > 1
|
||||
);
|
||||
--> statement-breakpoint
|
||||
WITH ranked_email_requests AS (
|
||||
SELECT
|
||||
id,
|
||||
row_number() OVER (
|
||||
PARTITION BY company_id, lower(request_email_snapshot)
|
||||
ORDER BY created_at ASC, id ASC
|
||||
) AS rank
|
||||
FROM join_requests
|
||||
WHERE request_type = 'human'
|
||||
AND status = 'pending_approval'
|
||||
AND request_email_snapshot IS NOT NULL
|
||||
)
|
||||
UPDATE join_requests
|
||||
SET
|
||||
status = 'rejected',
|
||||
rejected_at = COALESCE(rejected_at, now()),
|
||||
updated_at = now()
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM ranked_email_requests
|
||||
WHERE rank > 1
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "join_requests_pending_human_user_uq"
|
||||
ON "join_requests" USING btree ("company_id", "requesting_user_id")
|
||||
WHERE "request_type" = 'human'
|
||||
AND "status" = 'pending_approval'
|
||||
AND "requesting_user_id" IS NOT NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "join_requests_pending_human_email_uq"
|
||||
ON "join_requests" USING btree ("company_id", lower("request_email_snapshot"))
|
||||
WHERE "request_type" = 'human'
|
||||
AND "status" = 'pending_approval'
|
||||
AND "request_email_snapshot" IS NOT NULL;
|
||||
13432
packages/db/src/migrations/meta/0057_snapshot.json
Normal file
13432
packages/db/src/migrations/meta/0057_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -400,6 +400,13 @@
|
||||
"when": 1776084034244,
|
||||
"tag": "0056_spooky_ultragirl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 57,
|
||||
"version": "7",
|
||||
"when": 1776309613598,
|
||||
"tag": "0057_tidy_join_requests",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { invites } from "./invites.js";
|
||||
@@ -37,5 +38,11 @@ export const joinRequests = pgTable(
|
||||
table.requestType,
|
||||
table.createdAt,
|
||||
),
|
||||
pendingHumanUserUniqueIdx: uniqueIndex("join_requests_pending_human_user_uq")
|
||||
.on(table.companyId, table.requestingUserId)
|
||||
.where(sql`${table.requestType} = 'human' AND ${table.status} = 'pending_approval' AND ${table.requestingUserId} IS NOT NULL`),
|
||||
pendingHumanEmailUniqueIdx: uniqueIndex("join_requests_pending_human_email_uq")
|
||||
.on(table.companyId, sql`lower(${table.requestEmailSnapshot})`)
|
||||
.where(sql`${table.requestType} = 'human' AND ${table.status} = 'pending_approval' AND ${table.requestEmailSnapshot} IS NOT NULL`),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -362,6 +362,30 @@ export type PrincipalType = (typeof PRINCIPAL_TYPES)[number];
|
||||
export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended"] as const;
|
||||
export type MembershipStatus = (typeof MEMBERSHIP_STATUSES)[number];
|
||||
|
||||
export const COMPANY_MEMBERSHIP_ROLES = [
|
||||
"owner",
|
||||
"admin",
|
||||
"operator",
|
||||
"viewer",
|
||||
"member",
|
||||
] as const;
|
||||
export type CompanyMembershipRole = (typeof COMPANY_MEMBERSHIP_ROLES)[number];
|
||||
|
||||
export const HUMAN_COMPANY_MEMBERSHIP_ROLES = [
|
||||
"owner",
|
||||
"admin",
|
||||
"operator",
|
||||
"viewer",
|
||||
] as const;
|
||||
export type HumanCompanyMembershipRole = (typeof HUMAN_COMPANY_MEMBERSHIP_ROLES)[number];
|
||||
|
||||
export const HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS: Record<HumanCompanyMembershipRole, string> = {
|
||||
owner: "Owner",
|
||||
admin: "Admin",
|
||||
operator: "Operator",
|
||||
viewer: "Viewer",
|
||||
};
|
||||
|
||||
export const INSTANCE_USER_ROLES = ["instance_admin"] as const;
|
||||
export type InstanceUserRole = (typeof INSTANCE_USER_ROLES)[number];
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ export {
|
||||
LIVE_EVENT_TYPES,
|
||||
PRINCIPAL_TYPES,
|
||||
MEMBERSHIP_STATUSES,
|
||||
COMPANY_MEMBERSHIP_ROLES,
|
||||
HUMAN_COMPANY_MEMBERSHIP_ROLES,
|
||||
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
|
||||
INSTANCE_USER_ROLES,
|
||||
INVITE_TYPES,
|
||||
INVITE_JOIN_TYPES,
|
||||
@@ -127,6 +130,8 @@ export {
|
||||
type LiveEventType,
|
||||
type PrincipalType,
|
||||
type MembershipStatus,
|
||||
type CompanyMembershipRole,
|
||||
type HumanCompanyMembershipRole,
|
||||
type InstanceUserRole,
|
||||
type InviteType,
|
||||
type InviteJoinType,
|
||||
@@ -308,11 +313,21 @@ export type {
|
||||
SidebarBadges,
|
||||
SidebarOrderPreference,
|
||||
InboxDismissal,
|
||||
AccessUserProfile,
|
||||
CompanyMemberRecord,
|
||||
CompanyMembersResponse,
|
||||
CompanyMembership,
|
||||
CompanyInviteListResponse,
|
||||
CompanyInviteRecord,
|
||||
PrincipalPermissionGrant,
|
||||
Invite,
|
||||
JoinRequest,
|
||||
JoinRequestInviteSummary,
|
||||
JoinRequestRecord,
|
||||
InstanceUserRoleGrant,
|
||||
AdminUserDirectoryEntry,
|
||||
UserCompanyAccessEntry,
|
||||
UserCompanyAccessResponse,
|
||||
CompanyPortabilityInclude,
|
||||
CompanyPortabilityEnvInput,
|
||||
CompanyPortabilityFileEntry,
|
||||
@@ -566,12 +581,19 @@ export {
|
||||
createCompanyInviteSchema,
|
||||
createOpenClawInvitePromptSchema,
|
||||
acceptInviteSchema,
|
||||
listCompanyInvitesQuerySchema,
|
||||
listJoinRequestsQuerySchema,
|
||||
claimJoinRequestApiKeySchema,
|
||||
boardCliAuthAccessLevelSchema,
|
||||
createCliAuthChallengeSchema,
|
||||
resolveCliAuthChallengeSchema,
|
||||
currentUserProfileSchema,
|
||||
authSessionSchema,
|
||||
updateCurrentUserProfileSchema,
|
||||
updateCompanyMemberSchema,
|
||||
updateCompanyMemberWithPermissionsSchema,
|
||||
updateMemberPermissionsSchema,
|
||||
searchAdminUsersQuerySchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
type CreateCostEvent,
|
||||
type CreateFinanceEvent,
|
||||
@@ -580,12 +602,19 @@ export {
|
||||
type CreateCompanyInvite,
|
||||
type CreateOpenClawInvitePrompt,
|
||||
type AcceptInvite,
|
||||
type ListCompanyInvitesQuery,
|
||||
type ListJoinRequestsQuery,
|
||||
type ClaimJoinRequestApiKey,
|
||||
type BoardCliAuthAccessLevel,
|
||||
type CreateCliAuthChallenge,
|
||||
type ResolveCliAuthChallenge,
|
||||
type CurrentUserProfile,
|
||||
type AuthSession,
|
||||
type UpdateCurrentUserProfile,
|
||||
type UpdateCompanyMember,
|
||||
type UpdateCompanyMemberWithPermissions,
|
||||
type UpdateMemberPermissions,
|
||||
type SearchAdminUsersQuery,
|
||||
type UpdateUserCompanyAccess,
|
||||
companySkillSourceTypeSchema,
|
||||
companySkillTrustLevelSchema,
|
||||
@@ -663,18 +692,23 @@ export {
|
||||
AGENT_MENTION_SCHEME,
|
||||
PROJECT_MENTION_SCHEME,
|
||||
SKILL_MENTION_SCHEME,
|
||||
USER_MENTION_SCHEME,
|
||||
buildAgentMentionHref,
|
||||
buildProjectMentionHref,
|
||||
buildSkillMentionHref,
|
||||
buildUserMentionHref,
|
||||
extractAgentMentionIds,
|
||||
extractProjectMentionIds,
|
||||
extractSkillMentionIds,
|
||||
extractUserMentionIds,
|
||||
parseAgentMentionHref,
|
||||
parseProjectMentionHref,
|
||||
parseSkillMentionHref,
|
||||
extractProjectMentionIds,
|
||||
parseUserMentionHref,
|
||||
type ParsedAgentMention,
|
||||
type ParsedProjectMention,
|
||||
type ParsedSkillMention,
|
||||
type ParsedUserMention,
|
||||
} from "./project-mentions.js";
|
||||
|
||||
export {
|
||||
|
||||
@@ -3,12 +3,15 @@ import {
|
||||
buildAgentMentionHref,
|
||||
buildProjectMentionHref,
|
||||
buildSkillMentionHref,
|
||||
buildUserMentionHref,
|
||||
extractAgentMentionIds,
|
||||
extractProjectMentionIds,
|
||||
extractSkillMentionIds,
|
||||
extractUserMentionIds,
|
||||
parseAgentMentionHref,
|
||||
parseProjectMentionHref,
|
||||
parseSkillMentionHref,
|
||||
parseUserMentionHref,
|
||||
} from "./project-mentions.js";
|
||||
|
||||
describe("project-mentions", () => {
|
||||
@@ -30,6 +33,14 @@ describe("project-mentions", () => {
|
||||
expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]);
|
||||
});
|
||||
|
||||
it("round-trips user mentions", () => {
|
||||
const href = buildUserMentionHref("user-123");
|
||||
expect(parseUserMentionHref(href)).toEqual({
|
||||
userId: "user-123",
|
||||
});
|
||||
expect(extractUserMentionIds(`[@Taylor](${href})`)).toEqual(["user-123"]);
|
||||
});
|
||||
|
||||
it("round-trips skill mentions with slug metadata", () => {
|
||||
const href = buildSkillMentionHref("skill-123", "release-changelog");
|
||||
expect(parseSkillMentionHref(href)).toEqual({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const PROJECT_MENTION_SCHEME = "project://";
|
||||
export const AGENT_MENTION_SCHEME = "agent://";
|
||||
export const USER_MENTION_SCHEME = "user://";
|
||||
export const SKILL_MENTION_SCHEME = "skill://";
|
||||
|
||||
const HEX_COLOR_RE = /^[0-9a-f]{6}$/i;
|
||||
@@ -8,6 +9,7 @@ const HEX_COLOR_WITH_HASH_RE = /^#[0-9a-f]{6}$/i;
|
||||
const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i;
|
||||
const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi;
|
||||
const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi;
|
||||
const USER_MENTION_LINK_RE = /\[[^\]]*]\((user:\/\/[^)\s]+)\)/gi;
|
||||
const SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi;
|
||||
const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i;
|
||||
const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i;
|
||||
@@ -22,6 +24,10 @@ export interface ParsedAgentMention {
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export interface ParsedUserMention {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface ParsedSkillMention {
|
||||
skillId: string;
|
||||
slug: string | null;
|
||||
@@ -111,6 +117,28 @@ export function parseAgentMentionHref(href: string): ParsedAgentMention | null {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUserMentionHref(userId: string): string {
|
||||
return `${USER_MENTION_SCHEME}${userId.trim()}`;
|
||||
}
|
||||
|
||||
export function parseUserMentionHref(href: string): ParsedUserMention | null {
|
||||
if (!href.startsWith(USER_MENTION_SCHEME)) return null;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(href);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.protocol !== "user:") return null;
|
||||
|
||||
const userId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim();
|
||||
if (!userId) return null;
|
||||
|
||||
return { userId };
|
||||
}
|
||||
|
||||
export function buildSkillMentionHref(skillId: string, slug?: string | null): string {
|
||||
const trimmedSkillId = skillId.trim();
|
||||
const normalizedSlug = normalizeSkillSlug(slug ?? null);
|
||||
@@ -165,6 +193,18 @@ export function extractAgentMentionIds(markdown: string): string[] {
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export function extractUserMentionIds(markdown: string): string[] {
|
||||
if (!markdown) return [];
|
||||
const ids = new Set<string>();
|
||||
const re = new RegExp(USER_MENTION_LINK_RE);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(markdown)) !== null) {
|
||||
const parsed = parseUserMentionHref(match[1]);
|
||||
if (parsed) ids.add(parsed.userId);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export function extractSkillMentionIds(markdown: string): string[] {
|
||||
if (!markdown) return [];
|
||||
const ids = new Set<string>();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
AgentAdapterType,
|
||||
CompanyStatus,
|
||||
HumanCompanyMembershipRole,
|
||||
InstanceUserRole,
|
||||
InviteJoinType,
|
||||
InviteType,
|
||||
@@ -33,6 +35,30 @@ export interface PrincipalPermissionGrant {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface AccessUserProfile {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
export interface CompanyMemberRecord extends CompanyMembership {
|
||||
principalType: "user";
|
||||
membershipRole: HumanCompanyMembershipRole | null;
|
||||
user: AccessUserProfile | null;
|
||||
grants: PrincipalPermissionGrant[];
|
||||
}
|
||||
|
||||
export interface CompanyMembersResponse {
|
||||
members: CompanyMemberRecord[];
|
||||
access: {
|
||||
currentUserRole: HumanCompanyMembershipRole | null;
|
||||
canManageMembers: boolean;
|
||||
canInviteUsers: boolean;
|
||||
canApproveJoinRequests: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Invite {
|
||||
id: string;
|
||||
companyId: string | null;
|
||||
@@ -48,6 +74,22 @@ export interface Invite {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type InviteState = "active" | "revoked" | "accepted" | "expired";
|
||||
|
||||
export interface CompanyInviteRecord extends Invite {
|
||||
companyName: string | null;
|
||||
humanRole: HumanCompanyMembershipRole | null;
|
||||
inviteMessage: string | null;
|
||||
state: InviteState;
|
||||
invitedByUser: AccessUserProfile | null;
|
||||
relatedJoinRequestId: string | null;
|
||||
}
|
||||
|
||||
export interface CompanyInviteListResponse {
|
||||
invites: CompanyInviteRecord[];
|
||||
nextOffset: number | null;
|
||||
}
|
||||
|
||||
export interface JoinRequest {
|
||||
id: string;
|
||||
inviteId: string;
|
||||
@@ -72,6 +114,26 @@ export interface JoinRequest {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface JoinRequestInviteSummary {
|
||||
id: string;
|
||||
inviteType: InviteType;
|
||||
allowedJoinTypes: InviteJoinType;
|
||||
humanRole: HumanCompanyMembershipRole | null;
|
||||
inviteMessage: string | null;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
revokedAt: Date | null;
|
||||
acceptedAt: Date | null;
|
||||
invitedByUser: AccessUserProfile | null;
|
||||
}
|
||||
|
||||
export interface JoinRequestRecord extends JoinRequest {
|
||||
requesterUser: AccessUserProfile | null;
|
||||
approvedByUser: AccessUserProfile | null;
|
||||
rejectedByUser: AccessUserProfile | null;
|
||||
invite: JoinRequestInviteSummary | null;
|
||||
}
|
||||
|
||||
export interface InstanceUserRoleGrant {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -79,3 +141,21 @@ export interface InstanceUserRoleGrant {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface AdminUserDirectoryEntry extends AccessUserProfile {
|
||||
isInstanceAdmin: boolean;
|
||||
activeCompanyMembershipCount: number;
|
||||
}
|
||||
|
||||
export interface UserCompanyAccessEntry extends CompanyMembership {
|
||||
principalType: "user";
|
||||
companyName: string | null;
|
||||
companyStatus: CompanyStatus | null;
|
||||
}
|
||||
|
||||
export interface UserCompanyAccessResponse {
|
||||
user: (AccessUserProfile & {
|
||||
isInstanceAdmin: boolean;
|
||||
}) | null;
|
||||
companyAccess: UserCompanyAccessEntry[];
|
||||
}
|
||||
|
||||
@@ -173,11 +173,21 @@ export type { SidebarBadges } from "./sidebar-badges.js";
|
||||
export type { SidebarOrderPreference } from "./sidebar-preferences.js";
|
||||
export type { InboxDismissal } from "./inbox-dismissal.js";
|
||||
export type {
|
||||
AccessUserProfile,
|
||||
CompanyMemberRecord,
|
||||
CompanyMembersResponse,
|
||||
CompanyMembership,
|
||||
CompanyInviteListResponse,
|
||||
CompanyInviteRecord,
|
||||
PrincipalPermissionGrant,
|
||||
Invite,
|
||||
JoinRequest,
|
||||
JoinRequestInviteSummary,
|
||||
JoinRequestRecord,
|
||||
InstanceUserRoleGrant,
|
||||
AdminUserDirectoryEntry,
|
||||
UserCompanyAccessEntry,
|
||||
UserCompanyAccessResponse,
|
||||
} from "./access.js";
|
||||
export type { QuotaWindow, ProviderQuotaResult } from "./quota.js";
|
||||
export type {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { PauseReason, ProjectStatus } from "../constants.js";
|
||||
import type { AgentEnvConfig } from "./secrets.js";
|
||||
import type {
|
||||
ProjectExecutionWorkspacePolicy,
|
||||
ProjectWorkspaceRuntimeConfig,
|
||||
WorkspaceRuntimeService,
|
||||
} from "./workspace-runtime.js";
|
||||
import type { AgentEnvConfig } from "./secrets.js";
|
||||
|
||||
export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path";
|
||||
export type ProjectWorkspaceVisibility = "default" | "advanced";
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AGENT_ADAPTER_TYPES,
|
||||
HUMAN_COMPANY_MEMBERSHIP_ROLES,
|
||||
INVITE_JOIN_TYPES,
|
||||
JOIN_REQUEST_STATUSES,
|
||||
JOIN_REQUEST_TYPES,
|
||||
MEMBERSHIP_STATUSES,
|
||||
PERMISSION_KEYS,
|
||||
} from "../constants.js";
|
||||
import { optionalAgentAdapterTypeSchema } from "../adapter-type.js";
|
||||
|
||||
export const createCompanyInviteSchema = z.object({
|
||||
allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"),
|
||||
humanRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(),
|
||||
defaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
agentMessage: z.string().max(4000).optional().nullable(),
|
||||
});
|
||||
@@ -46,6 +50,14 @@ export const listJoinRequestsQuerySchema = z.object({
|
||||
|
||||
export type ListJoinRequestsQuery = z.infer<typeof listJoinRequestsQuerySchema>;
|
||||
|
||||
export const listCompanyInvitesQuerySchema = z.object({
|
||||
state: z.enum(["active", "revoked", "accepted", "expired"]).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
|
||||
offset: z.coerce.number().int().min(0).optional().default(0),
|
||||
});
|
||||
|
||||
export type ListCompanyInvitesQuery = z.infer<typeof listCompanyInvitesQuerySchema>;
|
||||
|
||||
export const claimJoinRequestApiKeySchema = z.object({
|
||||
claimSecret: z.string().min(16).max(256),
|
||||
});
|
||||
@@ -85,8 +97,82 @@ export const updateMemberPermissionsSchema = z.object({
|
||||
|
||||
export type UpdateMemberPermissions = z.infer<typeof updateMemberPermissionsSchema>;
|
||||
|
||||
export const updateCompanyMemberSchema = z.object({
|
||||
membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(),
|
||||
status: z.enum(MEMBERSHIP_STATUSES).optional(),
|
||||
}).refine((value) => value.membershipRole !== undefined || value.status !== undefined, {
|
||||
message: "membershipRole or status is required",
|
||||
});
|
||||
|
||||
export type UpdateCompanyMember = z.infer<typeof updateCompanyMemberSchema>;
|
||||
|
||||
export const updateCompanyMemberWithPermissionsSchema = z.object({
|
||||
membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(),
|
||||
status: z.enum(MEMBERSHIP_STATUSES).optional(),
|
||||
grants: updateMemberPermissionsSchema.shape.grants.default([]),
|
||||
}).refine((value) => value.membershipRole !== undefined || value.status !== undefined, {
|
||||
message: "membershipRole or status is required",
|
||||
});
|
||||
|
||||
export type UpdateCompanyMemberWithPermissions = z.infer<typeof updateCompanyMemberWithPermissionsSchema>;
|
||||
|
||||
export const updateUserCompanyAccessSchema = z.object({
|
||||
companyIds: z.array(z.string().uuid()).default([]),
|
||||
});
|
||||
|
||||
export type UpdateUserCompanyAccess = z.infer<typeof updateUserCompanyAccessSchema>;
|
||||
|
||||
export const searchAdminUsersQuerySchema = z.object({
|
||||
query: z.string().trim().max(120).optional().default(""),
|
||||
});
|
||||
|
||||
export type SearchAdminUsersQuery = z.infer<typeof searchAdminUsersQuerySchema>;
|
||||
|
||||
const profileImageAssetPathPattern = /^\/api\/assets\/[^/?#]+\/content(?:\?[^#]*)?(?:#.*)?$/;
|
||||
|
||||
function isValidProfileImage(value: string): boolean {
|
||||
if (profileImageAssetPathPattern.test(value)) return true;
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" || url.protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const profileImageSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(4000)
|
||||
.refine(isValidProfileImage, { message: "Invalid profile image URL" });
|
||||
|
||||
export const currentUserProfileSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
email: z.string().email().nullable(),
|
||||
name: z.string().min(1).max(120).nullable(),
|
||||
image: profileImageSchema.nullable(),
|
||||
});
|
||||
|
||||
export type CurrentUserProfile = z.infer<typeof currentUserProfileSchema>;
|
||||
|
||||
export const authSessionSchema = z.object({
|
||||
session: z.object({
|
||||
id: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
}),
|
||||
user: currentUserProfileSchema,
|
||||
});
|
||||
|
||||
export type AuthSession = z.infer<typeof authSessionSchema>;
|
||||
|
||||
export const updateCurrentUserProfileSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
image: z
|
||||
.union([profileImageSchema, z.literal(""), z.null()])
|
||||
.optional()
|
||||
.transform((value) => value === "" ? null : value),
|
||||
});
|
||||
|
||||
export type UpdateCurrentUserProfile = z.infer<typeof updateCurrentUserProfileSchema>;
|
||||
|
||||
@@ -99,7 +99,6 @@ export const workspaceRuntimeServiceSchema = z.object({
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
}).strict();
|
||||
|
||||
export const executionWorkspaceCloseReadinessSchema = z.object({
|
||||
workspaceId: z.string().uuid(),
|
||||
state: executionWorkspaceCloseReadinessStateSchema,
|
||||
|
||||
@@ -253,22 +253,36 @@ export {
|
||||
createCompanyInviteSchema,
|
||||
createOpenClawInvitePromptSchema,
|
||||
acceptInviteSchema,
|
||||
listCompanyInvitesQuerySchema,
|
||||
listJoinRequestsQuerySchema,
|
||||
claimJoinRequestApiKeySchema,
|
||||
boardCliAuthAccessLevelSchema,
|
||||
createCliAuthChallengeSchema,
|
||||
resolveCliAuthChallengeSchema,
|
||||
currentUserProfileSchema,
|
||||
authSessionSchema,
|
||||
updateCurrentUserProfileSchema,
|
||||
updateCompanyMemberSchema,
|
||||
updateCompanyMemberWithPermissionsSchema,
|
||||
updateMemberPermissionsSchema,
|
||||
searchAdminUsersQuerySchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
type CreateCompanyInvite,
|
||||
type CreateOpenClawInvitePrompt,
|
||||
type AcceptInvite,
|
||||
type ListCompanyInvitesQuery,
|
||||
type ListJoinRequestsQuery,
|
||||
type ClaimJoinRequestApiKey,
|
||||
type BoardCliAuthAccessLevel,
|
||||
type CreateCliAuthChallenge,
|
||||
type ResolveCliAuthChallenge,
|
||||
type CurrentUserProfile,
|
||||
type AuthSession,
|
||||
type UpdateCurrentUserProfile,
|
||||
type UpdateCompanyMember,
|
||||
type UpdateCompanyMemberWithPermissions,
|
||||
type UpdateMemberPermissions,
|
||||
type SearchAdminUsersQuery,
|
||||
type UpdateUserCompanyAccess,
|
||||
} from "./access.js";
|
||||
|
||||
|
||||
107
releases/v2026.414.0.md
Normal file
107
releases/v2026.414.0.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# v2026.414.0
|
||||
|
||||
> Released: 2026-04-14
|
||||
|
||||
## Security
|
||||
|
||||
- **Authorization hardening (GHSA-68qg-g8mg-6pr7)** — Scoped import, approval, activity, and heartbeat API routes to enforce proper authorization checks. Previously, certain administrative endpoints were accessible without adequate permission verification. All users are strongly encouraged to upgrade. ([#3315](https://github.com/paperclipai/paperclip/pull/3315), [#3009](https://github.com/paperclipai/paperclip/pull/3009), @KhairulA)
|
||||
- **Removed hardcoded JWT secret fallback** — The `createBetterAuthInstance` function no longer falls back to a hardcoded JWT secret, closing a credential-hygiene gap. ([#3124](https://github.com/paperclipai/paperclip/pull/3124), @cleanunicorn)
|
||||
- **Redact Bearer tokens in logs** — Server log output now redacts Bearer tokens to prevent accidental credential exposure.
|
||||
- **Dependency bumps** — Updated `multer` to 2.1.1 (HIGH CVEs) and `rollup` to 4.59.0 (path-traversal CVE). ([#2909](https://github.com/paperclipai/paperclip/pull/2909), @marysomething99-prog)
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Multi-user access and invites** — Full multi-user authentication, company roles, and invite management. Board users can create invite links, approve join requests, and manage member roles. Invite flows support auto-accept for signed-in users, paginated history, and human-readable requester identities in approval views.
|
||||
- **Human user identities everywhere** — Human users now appear with real names and avatars across activity feeds, issue tables, assignee pickers, and @-mention menus. A lightweight user directory endpoint powers consistent identity resolution across the UI.
|
||||
- **Issue chat thread** — Replaced the classic comment timeline with a full chat-style thread powered by assistant-ui. Agent run transcripts, chain-of-thought, and user messages render inline as a continuous conversation with polished avatars, action bars, and relative timestamps. ([#3079](https://github.com/paperclipai/paperclip/pull/3079))
|
||||
- **External adapter plugin system** — Third-party adapters can now be installed as npm packages or loaded from local directories. Plugins declare a config schema and an optional UI transcript parser; built-in adapters can be overridden by external ones. Includes Hermes local session management and provider/model display in run details. ([#2649](https://github.com/paperclipai/paperclip/pull/2649), [#2650](https://github.com/paperclipai/paperclip/pull/2650), [#2651](https://github.com/paperclipai/paperclip/pull/2651), [#2654](https://github.com/paperclipai/paperclip/pull/2654), [#2655](https://github.com/paperclipai/paperclip/pull/2655), [#2659](https://github.com/paperclipai/paperclip/pull/2659), @plind-dm)
|
||||
- **Execution policies** — Issues can carry a review/approval execution policy with multi-stage signoff workflows. Reviewers and approvers are selected per-stage, and Paperclip routes the issue through each stage automatically. ([#3222](https://github.com/paperclipai/paperclip/pull/3222))
|
||||
- **Blocker dependencies** — First-class issue blocker relations with automatic wake-on-dependency-resolved. Set `blockedByIssueIds` on any issue and Paperclip wakes the assignee when all blockers reach `done`. ([#2797](https://github.com/paperclipai/paperclip/pull/2797))
|
||||
- **Standalone MCP server** — New `@paperclipai/mcp-server` package exposing the Paperclip API as an MCP tool server, including approval creation. ([#2435](https://github.com/paperclipai/paperclip/pull/2435))
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Invite UX polish** — Auto-submit for signed-in invites, inline auth flow, paginated invite history, requester identity in join approvals, and prevention of duplicate join requests and member re-invites.
|
||||
- **Board approvals** — Generic issue-linked board approvals with card styling and visibility improvements in the issue detail sidebar. ([#3220](https://github.com/paperclipai/paperclip/pull/3220))
|
||||
- **Inbox parent-child nesting** — Parent issues group their children in the inbox Mine view with a toggle button, j/k keyboard traversal across nested items, and collapsible groups. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz)
|
||||
- **Inbox workspace grouping** — Issues can now be grouped by workspace in the inbox with collapsible mobile groups and shared column controls across inbox and issues lists. ([#3356](https://github.com/paperclipai/paperclip/pull/3356))
|
||||
- **Issue search** — Trigram-indexed full-text search across titles, identifiers, descriptions, and comments with debounced input. Comment matches now surface in search results. ([#2999](https://github.com/paperclipai/paperclip/pull/2999))
|
||||
- **Sub-issues inline** — Sub-issues moved from a separate tab to inline display on the issue detail, with parent-inherited workspace defaults and assignee propagation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355))
|
||||
- **Issue-to-issue navigation** — Faster navigation between issues with scroll reset, prefetch, and detail-view optimizations. ([#3542](https://github.com/paperclipai/paperclip/pull/3542))
|
||||
- **Auto-checkout for scoped wakes** — Agent harness now automatically checks out the scoped issue on comment-driven wakes, reducing latency for agent heartbeats. ([#3538](https://github.com/paperclipai/paperclip/pull/3538))
|
||||
- **Document revision diff viewer** — Side-by-side diff viewer for issue document revisions with improved modal layout. ([#2792](https://github.com/paperclipai/paperclip/pull/2792))
|
||||
- **Keyboard shortcuts cheatsheet** — Press `?` to open a keyboard shortcut reference dialog; new `g i` (go to inbox), `g c` (comment composer), and inbox archive undo shortcuts. ([#2772](https://github.com/paperclipai/paperclip/pull/2772))
|
||||
- **Bedrock model selection** — Claude local adapter now supports AWS Bedrock authentication and model selection. ([#3033](https://github.com/paperclipai/paperclip/pull/3033), [#2793](https://github.com/paperclipai/paperclip/pull/2793), @kimnamu)
|
||||
- **Codex fast mode** — Added fast mode support for the Codex local adapter with env probe safeguards. ([#3383](https://github.com/paperclipai/paperclip/pull/3383))
|
||||
- **Backup improvements** — Gzip-compressed backups with tiered daily/weekly/monthly retention and UI controls in Instance Settings. ([#3015](https://github.com/paperclipai/paperclip/pull/3015), @aronprins)
|
||||
- **GitHub webhook signing modes** — Added `github_hmac` and `none` webhook signing modes with timing-safe HMAC comparison. ([#1961](https://github.com/paperclipai/paperclip/pull/1961), @antonio-mello-ai)
|
||||
- **Sidebar order persistence** — Sidebar project and company ordering preferences now persist per-user.
|
||||
- **Workspace runtime controls** — Start/stop controls, runtime state reconciliation, runtime service improvements, and workspace branch/folder display in the issue properties sidebar. ([#3354](https://github.com/paperclipai/paperclip/pull/3354))
|
||||
- **Attachment improvements** — Arbitrary file attachments (not just images), drag-and-drop non-image files onto markdown editor, and square-cropped image gallery grid. ([#2749](https://github.com/paperclipai/paperclip/pull/2749))
|
||||
- **Image gallery in chat** — Clicking images in chat messages now opens a full gallery viewer.
|
||||
- **Mobile UX** — Gmail-inspired mobile top bar for inbox issue views, responsive execution workspace pages, mobile mention menu placement, and mobile comment copy button feedback.
|
||||
- **Routine improvements** — Draft routine defaults, run-time overrides, routine title variables, and relaxed project/agent requirements for routines. ([#3220](https://github.com/paperclipai/paperclip/pull/3220))
|
||||
- **Project environment variables** — Projects can now define environment variables that are inherited by workspace runs.
|
||||
- **Skill auto-enable** — Mentioned skills are automatically enabled for heartbeat runs.
|
||||
- **Comment wake batching** — Multiple comment wakes are batched into a single inline payload for more efficient agent heartbeats.
|
||||
- **Server-side adapter pause/resume** — Builtin adapter types can now be paused/resumed from the server with `overridePaused`. ([#2542](https://github.com/paperclipai/paperclip/pull/2542), @plind-dm)
|
||||
- **Skill slash-command autocomplete** — Skill names now autocomplete in the editor.
|
||||
- **Worktree reseed command** — New CLI command to reseed worktrees from latest repo state. ([#3353](https://github.com/paperclipai/paperclip/pull/3353))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Assignee name overflow** — Fixed long assignee names overflowing in the issues list grid with proper truncation.
|
||||
- **Company alerts isolation** — Company-level alerts no longer appear in personal inbox.
|
||||
- **Invite state management** — Fixed reused invite refresh pending state, paginated invite history cache isolation, and invite flow state mapping across reloads.
|
||||
- **Issue detail stability** — Fixed visible refreshes during agent updates, comment post resets, ref update loops, split regressions, and main-pane focus on navigation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355))
|
||||
- **Inbox badge count** — Badge now correctly counts only unread Mine issues. ([#2512](https://github.com/paperclipai/paperclip/pull/2512), @AllenHyang)
|
||||
- **Inbox keyboard navigation** — Fixed j/k traversal across groups and nesting column alignment. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz)
|
||||
- **Execution workspaces** — Fixed linked worktree reuse, dev runner isolation, workspace import regressions, and workspace preflight through server toolchain.
|
||||
- **Stale execution locks** — Fixed stale execution lock lifecycle with proper `executionAgentNameKey` clearing. ([#2643](https://github.com/paperclipai/paperclip/pull/2643), @chrisschwer)
|
||||
- **Agent env bindings** — Fixed cleared agent env bindings not persisting on save. ([#3232](https://github.com/paperclipai/paperclip/pull/3232), @officialasishkumar)
|
||||
- **Capabilities field** — Fixed blank screen when clearing the Capabilities field. ([#2442](https://github.com/paperclipai/paperclip/pull/2442), @sparkeros)
|
||||
- **Skill deletion** — Company skills can now be deleted with an agent usage check. ([#2441](https://github.com/paperclipai/paperclip/pull/2441), @DanielSousa)
|
||||
- **Claude session resume** — Fixed `--append-system-prompt-file` being sent on resumed Claude sessions and preserved instructions on resume fallback. ([#2949](https://github.com/paperclipai/paperclip/pull/2949), [#2936](https://github.com/paperclipai/paperclip/pull/2936), [#2937](https://github.com/paperclipai/paperclip/pull/2937), @Lempkey)
|
||||
- **Agent auth JWT** — Fixed agent auth to fall back to `BETTER_AUTH_SECRET` when `PAPERCLIP_AGENT_JWT_SECRET` is absent. ([#2866](https://github.com/paperclipai/paperclip/pull/2866), @ergonaworks)
|
||||
- **Typing lag** — Fixed typing lag in long comment threads. ([#3163](https://github.com/paperclipai/paperclip/pull/3163))
|
||||
- **Shimmer animation** — Fixed shimmer text using invalid `hsl()` wrapper on `oklch` colors, loop jitter, and added pause between repeats.
|
||||
- **Mention selection** — Restored touch mention selection and fixed spaced mention queries.
|
||||
- **Inbox archive** — Fixed archive flashing back after fade-out.
|
||||
- **Goal description** — Made goal description area scrollable in create dialog. ([#2148](https://github.com/paperclipai/paperclip/pull/2148), @shoaib050326)
|
||||
- **Worktree provisioning** — Fixed symlink relinking, fallback seeding, dependency hydration, and validated linked worktrees before reuse. ([#3354](https://github.com/paperclipai/paperclip/pull/3354))
|
||||
- **Node keepAliveTimeout** — Increased timeout behind reverse proxies to prevent 502 errors.
|
||||
- **Codex tool-use transcripts** — Fixed Codex tool-use transcript completion parsing.
|
||||
- **Codex resume error** — Recognize missing-rollout Codex resume error as stale session.
|
||||
- **Pi quota exhaustion** — Treat Pi quota exhaustion as a failed run. ([#2305](https://github.com/paperclipai/paperclip/pull/2305))
|
||||
- **Issue identifier collisions** — Prevented identifier collisions during concurrent issue creation.
|
||||
- **OpenClaw CEO paths** — Fixed `$AGENT_HOME` references in CEO onboarding instructions to use relative paths. ([#3299](https://github.com/paperclipai/paperclip/pull/3299), @aronprins)
|
||||
- **Windows adapter** — Uses `cmd.exe` for `.cmd`/`.bat` wrappers on Windows. ([#2662](https://github.com/paperclipai/paperclip/pull/2662), @wbelt)
|
||||
- **Markdown autoformat** — Fixed autoformat of pasted markdown in inline editor. ([#2733](https://github.com/paperclipai/paperclip/pull/2733), @davison)
|
||||
- **Paused agent dimming** — Correctly dim paused agents in list and org chart views; skip dimming on Paused filter tab. ([#2397](https://github.com/paperclipai/paperclip/pull/2397), @HearthCore)
|
||||
- **Import role fallback** — Import now reads agent role from frontmatter before defaulting to "agent". ([#2594](https://github.com/paperclipai/paperclip/pull/2594), @plind-dm)
|
||||
- **Backup cleanup** — Clean up orphaned `.sql` files on compression failure and fix stale startup log.
|
||||
|
||||
## Upgrade Guide
|
||||
|
||||
Nine new database migrations (`0049`–`0056`) will run automatically on startup. These add:
|
||||
|
||||
- Issue blocker relations table (`0049`)
|
||||
- Project environment variables (`0050`)
|
||||
- Trigram search indexes on issues and comments (`0051` — requires `pg_trgm` extension)
|
||||
- Execution policy decision tracking (`0052`)
|
||||
- Non-issue inbox dismissals (`0053`)
|
||||
- Relaxed routine constraints (`0054`)
|
||||
- Heartbeat run process group tracking (`0055`)
|
||||
- User sidebar preferences (`0056`)
|
||||
|
||||
All migrations are additive — no existing data is modified or removed.
|
||||
|
||||
**`pg_trgm` extension**: Migration `0051` creates the `pg_trgm` PostgreSQL extension for full-text search. If your database user does not have `CREATE EXTENSION` privileges, ask your DBA to run `CREATE EXTENSION IF NOT EXISTS pg_trgm;` before upgrading.
|
||||
|
||||
If you use external adapter plugins, note that built-in adapters can now be overridden by external ones. The `overriddenBuiltin` flag in the adapter API indicates when this is happening.
|
||||
|
||||
## Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release!
|
||||
|
||||
@AllenHyang, @antonio-mello-ai, @aronprins, @chrisschwer, @cleanunicorn, @cryppadotta, @DanielSousa, @davison, @ergonaworks, @HearthCore, @HenkDz, @KhairulA, @kimnamu, @Lempkey, @marysomething99-prog, @mvanhorn, @officialasishkumar, @plind-dm, @shoaib050326, @sparkeros, @wbelt
|
||||
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } f
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs";
|
||||
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.ts";
|
||||
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
||||
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
|
||||
import { bootstrapDevRunnerWorktreeEnv } from "../server/src/dev-runner-worktree.ts";
|
||||
@@ -557,7 +557,7 @@ async function getDevHealthPayload() {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Health request failed (${response.status})`);
|
||||
}
|
||||
return await parseJsonResponseWithLimit<{ devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } }>(response);
|
||||
return await parseJsonResponseWithLimit(response);
|
||||
}
|
||||
|
||||
async function waitForChildExit() {
|
||||
|
||||
@@ -436,9 +436,6 @@ async function searchWindow(
|
||||
|
||||
for (let page = 2; page <= pageCount; page += 1) {
|
||||
const response = await searchPage(client, options, start, end, page, 100);
|
||||
if (response.incomplete_results) {
|
||||
throw new Error(`GitHub returned incomplete search results for window ${windowKey} on page ${page}`);
|
||||
}
|
||||
ingestSearchItems(cache, response.items, shas);
|
||||
}
|
||||
|
||||
@@ -512,7 +509,7 @@ function sortFilteredShas(cache: CacheFile, shas: string[]): string[] {
|
||||
}
|
||||
|
||||
function formatQueryDate(value: Date): string {
|
||||
return new Date(Math.floor(value.getTime() / 1000) * 1000).toISOString().replace(".000Z", "Z");
|
||||
return value.toISOString().replace(".000Z", "Z");
|
||||
}
|
||||
|
||||
function ingestSearchItems(cache: CacheFile, items: SearchCommitItem[], shas: Set<string>) {
|
||||
@@ -850,14 +847,6 @@ class GitHubClient {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
if ((response.status === 403 || response.status === 429) && retryAfter) {
|
||||
const waitMs = Math.max(Number.parseInt(retryAfter, 10) * 1000, 1_000);
|
||||
console.error(`GitHub secondary rate limit hit for ${pathname}; waiting ${Math.ceil(waitMs / 1000)}s...`);
|
||||
await sleep(waitMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const remaining = response.headers.get("x-ratelimit-remaining");
|
||||
const resetAt = response.headers.get("x-ratelimit-reset");
|
||||
if ((response.status === 403 || response.status === 429) && remaining === "0" && resetAt) {
|
||||
|
||||
@@ -31,26 +31,35 @@ source_env_path="$(dirname "$source_config_path")/.env"
|
||||
|
||||
mkdir -p "$paperclip_dir"
|
||||
|
||||
run_paperclipai_command() {
|
||||
local command_args=("$@")
|
||||
if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then
|
||||
pnpm paperclipai "${command_args[@]}"
|
||||
run_isolated_worktree_init() {
|
||||
local base_cli_runner="$base_cwd/cli/node_modules/tsx/dist/cli.mjs"
|
||||
local base_cli_entry="$base_cwd/cli/src/index.ts"
|
||||
|
||||
if [[ -f "$base_cli_runner" && -f "$base_cli_entry" ]]; then
|
||||
(
|
||||
cd "$worktree_cwd"
|
||||
node "$base_cli_runner" "$base_cli_entry" worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
|
||||
)
|
||||
return 0
|
||||
fi
|
||||
|
||||
local base_cli_tsx_path="$base_cwd/cli/node_modules/tsx/dist/cli.mjs"
|
||||
local base_cli_entry_path="$base_cwd/cli/src/index.ts"
|
||||
if command -v node >/dev/null 2>&1 && [[ -f "$base_cli_tsx_path" ]] && [[ -f "$base_cli_entry_path" ]]; then
|
||||
node "$base_cli_tsx_path" "$base_cli_entry_path" "${command_args[@]}"
|
||||
if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$worktree_cwd"
|
||||
pnpm paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
|
||||
)
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v paperclipai >/dev/null 2>&1; then
|
||||
paperclipai "${command_args[@]}"
|
||||
(
|
||||
cd "$worktree_cwd"
|
||||
paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
|
||||
)
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
return 127
|
||||
}
|
||||
|
||||
paperclipai_command_available() {
|
||||
@@ -71,19 +80,6 @@ paperclipai_command_available() {
|
||||
return 1
|
||||
}
|
||||
|
||||
run_isolated_worktree_init() {
|
||||
run_paperclipai_command \
|
||||
worktree \
|
||||
init \
|
||||
--force \
|
||||
--seed-mode \
|
||||
minimal \
|
||||
--name \
|
||||
"$worktree_name" \
|
||||
--from-config \
|
||||
"$source_config_path"
|
||||
}
|
||||
|
||||
write_fallback_worktree_config() {
|
||||
WORKTREE_NAME="$worktree_name" \
|
||||
BASE_CWD="$base_cwd" \
|
||||
|
||||
99
server/src/__tests__/access-service.test.ts
Normal file
99
server/src/__tests__/access-service.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
principalPermissionGrants,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
async function createCompanyWithOwner(db: ReturnType<typeof createDb>) {
|
||||
const company = await db
|
||||
.insert(companies)
|
||||
.values({
|
||||
name: `Access Service ${randomUUID()}`,
|
||||
issuePrefix: `AS${randomUUID().slice(0, 6).toUpperCase()}`,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
|
||||
const owner = await db
|
||||
.insert(companyMemberships)
|
||||
.values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `owner-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "owner",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
|
||||
return { company, owner };
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("access service", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-access-service-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(principalPermissionGrants);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("rejects combined access updates that would demote the last active owner", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const access = accessService(db);
|
||||
|
||||
await expect(
|
||||
access.updateMemberAndPermissions(
|
||||
company.id,
|
||||
owner.id,
|
||||
{ membershipRole: "admin", grants: [] },
|
||||
"admin-user",
|
||||
),
|
||||
).rejects.toThrow("Cannot remove the last active owner");
|
||||
|
||||
const unchanged = await db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(eq(companyMemberships.id, owner.id))
|
||||
.then((rows) => rows[0]!);
|
||||
expect(unchanged.membershipRole).toBe("owner");
|
||||
});
|
||||
|
||||
it("rejects role-only updates that would suspend the last active owner", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const access = accessService(db);
|
||||
|
||||
await expect(
|
||||
access.updateMember(company.id, owner.id, { status: "suspended" }),
|
||||
).rejects.toThrow("Cannot remove the last active owner");
|
||||
|
||||
const unchanged = await db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(eq(companyMemberships.id, owner.id))
|
||||
.then((rows) => rows[0]!);
|
||||
expect(unchanged.status).toBe("active");
|
||||
});
|
||||
});
|
||||
33
server/src/__tests__/access-validators.test.ts
Normal file
33
server/src/__tests__/access-validators.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
updateCompanyMemberWithPermissionsSchema,
|
||||
updateCurrentUserProfileSchema,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
describe("access validators", () => {
|
||||
it("accepts HTTP(S) and Paperclip asset image URLs", () => {
|
||||
expect(updateCurrentUserProfileSchema.safeParse({
|
||||
name: "Ada Lovelace",
|
||||
image: "https://example.com/avatar.png",
|
||||
}).success).toBe(true);
|
||||
expect(updateCurrentUserProfileSchema.safeParse({
|
||||
name: "Ada Lovelace",
|
||||
image: "/api/assets/avatar/content",
|
||||
}).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects data URI profile images", () => {
|
||||
expect(updateCurrentUserProfileSchema.safeParse({
|
||||
name: "Ada Lovelace",
|
||||
image: "data:image/png;base64,AAAA",
|
||||
}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults omitted combined member grants to an empty list", () => {
|
||||
const result = updateCompanyMemberWithPermissionsSchema.parse({
|
||||
membershipRole: "operator",
|
||||
});
|
||||
|
||||
expect(result.grants).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ let setOverridePaused: typeof import("../adapters/registry.js").setOverridePause
|
||||
let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes;
|
||||
let errorHandler: typeof import("../middleware/index.js").errorHandler;
|
||||
|
||||
function createApp() {
|
||||
function createApp(actorOverrides: Partial<Express.Request["actor"]> = {}) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -41,6 +41,7 @@ function createApp() {
|
||||
companyIds: [],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
...actorOverrides,
|
||||
};
|
||||
next();
|
||||
});
|
||||
@@ -166,4 +167,18 @@ describe("adapter routes", () => {
|
||||
fields: [{ key: "mode" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects signed-in users without org access", async () => {
|
||||
const app = createApp({
|
||||
userId: "outsider-1",
|
||||
source: "session",
|
||||
companyIds: [],
|
||||
memberships: [],
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/adapters/claude_local/config-schema");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -395,14 +395,6 @@ describe("agent skill routes", () => {
|
||||
});
|
||||
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
adapterType: "claude_local",
|
||||
}),
|
||||
{ "AGENTS.md": "You are QA." },
|
||||
{ entryFile: "AGENTS.md", replaceExisting: false },
|
||||
);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
|
||||
170
server/src/__tests__/auth-routes.test.ts
Normal file
170
server/src/__tests__/auth-routes.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
function createSelectChain(rows: unknown[]) {
|
||||
return {
|
||||
from() {
|
||||
return {
|
||||
where() {
|
||||
return Promise.resolve(rows);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createUpdateChain(row: unknown) {
|
||||
return {
|
||||
set(values: unknown) {
|
||||
return {
|
||||
where() {
|
||||
return {
|
||||
returning() {
|
||||
return Promise.resolve([{ ...(row as Record<string, unknown>), ...(values as Record<string, unknown>) }]);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDb(row: Record<string, unknown>) {
|
||||
return {
|
||||
select: vi.fn(() => createSelectChain([row])),
|
||||
update: vi.fn(() => createUpdateChain(row)),
|
||||
} as any;
|
||||
}
|
||||
|
||||
async function createApp(actor: Express.Request["actor"], row: Record<string, unknown>) {
|
||||
const [{ authRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/auth.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api/auth", authRoutes(createDb(row)));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("auth routes", () => {
|
||||
const baseUser = {
|
||||
id: "user-1",
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
image: "https://example.com/jane.png",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("returns the persisted user profile in the session payload", async () => {
|
||||
const app = await createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
},
|
||||
baseUser,
|
||||
);
|
||||
|
||||
const res = await request(app).get("/api/auth/get-session");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
session: {
|
||||
id: "paperclip:session:user-1",
|
||||
userId: "user-1",
|
||||
},
|
||||
user: baseUser,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the signed-in profile", async () => {
|
||||
const app = await createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
},
|
||||
baseUser,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/auth/profile")
|
||||
.send({ name: "Board Operator", image: "" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
id: "user-1",
|
||||
name: "Board Operator",
|
||||
email: "jane@example.com",
|
||||
image: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the existing avatar when updating only the profile name", async () => {
|
||||
const app = await createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
},
|
||||
baseUser,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/auth/profile")
|
||||
.send({ name: "Board Operator" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
id: "user-1",
|
||||
name: "Board Operator",
|
||||
email: "jane@example.com",
|
||||
image: "https://example.com/jane.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts Paperclip asset paths for avatars", async () => {
|
||||
const app = await createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
},
|
||||
baseUser,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/auth/profile")
|
||||
.send({ name: "Jane Example", image: "/api/assets/asset-1/content" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.image).toBe("/api/assets/asset-1/content");
|
||||
});
|
||||
|
||||
it("rejects invalid avatar image references", async () => {
|
||||
const app = await createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
},
|
||||
baseUser,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/auth/profile")
|
||||
.send({ name: "Jane Example", image: "not-a-url" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
61
server/src/__tests__/auth-session-route.test.ts
Normal file
61
server/src/__tests__/auth-session-route.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { actorMiddleware } from "../middleware/auth.js";
|
||||
|
||||
function createSelectChain(rows: unknown[]) {
|
||||
return {
|
||||
from() {
|
||||
return {
|
||||
where() {
|
||||
return Promise.resolve(rows);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDb() {
|
||||
return {
|
||||
select: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => createSelectChain([]))
|
||||
.mockImplementationOnce(() => createSelectChain([])),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("actorMiddleware authenticated session profile", () => {
|
||||
it("preserves the signed-in user name and email on the board actor", async () => {
|
||||
const app = express();
|
||||
app.use(
|
||||
actorMiddleware(createDb(), {
|
||||
deploymentMode: "authenticated",
|
||||
resolveSession: async () => ({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "User One",
|
||||
email: "user@example.com",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
app.get("/actor", (req, res) => {
|
||||
res.json(req.actor);
|
||||
});
|
||||
|
||||
const res = await request(app).get("/actor");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
userName: "User One",
|
||||
userEmail: "user@example.com",
|
||||
source: "session",
|
||||
companyIds: [],
|
||||
memberships: [],
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
157
server/src/__tests__/authz-company-access.test.ts
Normal file
157
server/src/__tests__/authz-company-access.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assertBoardOrgAccess, assertCompanyAccess, hasBoardOrgAccess } from "../routes/authz.js";
|
||||
|
||||
function makeReq(input: {
|
||||
method?: string;
|
||||
actor: Express.Request["actor"];
|
||||
}) {
|
||||
return {
|
||||
method: input.method ?? "GET",
|
||||
actor: input.actor,
|
||||
} as Express.Request;
|
||||
}
|
||||
|
||||
describe("assertCompanyAccess", () => {
|
||||
it("allows viewer memberships to read", () => {
|
||||
const req = makeReq({
|
||||
method: "GET",
|
||||
actor: {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
companyIds: ["company-1"],
|
||||
memberships: [
|
||||
{ companyId: "company-1", membershipRole: "viewer", status: "active" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => assertCompanyAccess(req, "company-1")).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects viewer memberships for writes", () => {
|
||||
const req = makeReq({
|
||||
method: "PATCH",
|
||||
actor: {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
companyIds: ["company-1"],
|
||||
memberships: [
|
||||
{ companyId: "company-1", membershipRole: "viewer", status: "active" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => assertCompanyAccess(req, "company-1")).toThrow("Viewer access is read-only");
|
||||
});
|
||||
|
||||
it("rejects writes when membership details are present but omit the target company", () => {
|
||||
const req = makeReq({
|
||||
method: "POST",
|
||||
actor: {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
companyIds: ["company-1"],
|
||||
memberships: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => assertCompanyAccess(req, "company-1")).toThrow("User does not have active company access");
|
||||
});
|
||||
|
||||
it("allows legacy board actors that only provide company ids", () => {
|
||||
const req = makeReq({
|
||||
method: "POST",
|
||||
actor: {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
companyIds: ["company-1"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => assertCompanyAccess(req, "company-1")).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects signed-in instance admins without explicit company access", () => {
|
||||
const req = makeReq({
|
||||
method: "GET",
|
||||
actor: {
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [],
|
||||
memberships: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => assertCompanyAccess(req, "company-1")).toThrow("User does not have access to this company");
|
||||
});
|
||||
|
||||
it("allows local trusted board access without explicit membership", () => {
|
||||
const req = makeReq({
|
||||
method: "GET",
|
||||
actor: {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => assertCompanyAccess(req, "company-1")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertBoardOrgAccess", () => {
|
||||
it("allows signed-in board users with active company access", () => {
|
||||
const req = makeReq({
|
||||
actor: {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
companyIds: ["company-1"],
|
||||
memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }],
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(hasBoardOrgAccess(req)).toBe(true);
|
||||
expect(() => assertBoardOrgAccess(req)).not.toThrow();
|
||||
});
|
||||
|
||||
it("allows instance admins without company memberships", () => {
|
||||
const req = makeReq({
|
||||
actor: {
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "session",
|
||||
companyIds: [],
|
||||
memberships: [],
|
||||
isInstanceAdmin: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(hasBoardOrgAccess(req)).toBe(true);
|
||||
expect(() => assertBoardOrgAccess(req)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects signed-in users without company access or instance admin rights", () => {
|
||||
const req = makeReq({
|
||||
actor: {
|
||||
type: "board",
|
||||
userId: "outsider-1",
|
||||
source: "session",
|
||||
companyIds: [],
|
||||
memberships: [],
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(hasBoardOrgAccess(req)).toBe(false);
|
||||
expect(() => assertBoardOrgAccess(req)).toThrow("Company membership or instance admin access required");
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,6 @@ const addDir = addDirIndex >= 0 ? argv[addDirIndex + 1] : null;
|
||||
const instructionsIndex = argv.indexOf("--append-system-prompt-file");
|
||||
const instructionsFilePath = instructionsIndex >= 0 ? argv[instructionsIndex + 1] : null;
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
const promptFileFlagIndex = process.argv.indexOf("--append-system-prompt-file");
|
||||
const appendedSystemPromptFilePath = promptFileFlagIndex >= 0 ? process.argv[promptFileFlagIndex + 1] : null;
|
||||
const payload = {
|
||||
argv,
|
||||
prompt: fs.readFileSync(0, "utf8"),
|
||||
@@ -25,8 +23,6 @@ const payload = {
|
||||
instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null,
|
||||
skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [],
|
||||
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
|
||||
appendedSystemPromptFilePath,
|
||||
appendedSystemPromptFileContents: appendedSystemPromptFilePath ? fs.readFileSync(appendedSystemPromptFilePath, "utf8") : null,
|
||||
};
|
||||
if (capturePath) {
|
||||
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
||||
|
||||
@@ -729,7 +729,6 @@ describe("codex execute", () => {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
|
||||
132
server/src/__tests__/company-user-directory-route.test.ts
Normal file
132
server/src/__tests__/company-user-directory-route.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
isInstanceAdmin: vi.fn(),
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
boardAuthService: () => ({
|
||||
createChallenge: vi.fn(),
|
||||
resolveBoardAccess: vi.fn(),
|
||||
assertCurrentBoardKey: vi.fn(),
|
||||
revokeBoardApiKey: vi.fn(),
|
||||
}),
|
||||
deduplicateAgentName: vi.fn(),
|
||||
logActivity: vi.fn(),
|
||||
notifyHireApproved: vi.fn(),
|
||||
}));
|
||||
|
||||
function createDbStub() {
|
||||
const activeMemberships = [
|
||||
{ principalId: "user-2", status: "active" as const },
|
||||
{ principalId: "user-1", status: "active" as const },
|
||||
];
|
||||
const users = [
|
||||
{ id: "user-1", name: "Dotta", email: "dotta@example.com", image: "https://example.com/dotta.png" },
|
||||
{ id: "user-2", name: null, email: "alex@example.com", image: null },
|
||||
];
|
||||
|
||||
const isCompanyMembershipsTable = (table: unknown) =>
|
||||
!!table &&
|
||||
typeof table === "object" &&
|
||||
"membershipRole" in table &&
|
||||
"principalType" in table &&
|
||||
"principalId" in table;
|
||||
const isAuthUsersTable = (table: unknown) =>
|
||||
!!table &&
|
||||
typeof table === "object" &&
|
||||
"emailVerified" in table &&
|
||||
"createdAt" in table &&
|
||||
"updatedAt" in table;
|
||||
|
||||
return {
|
||||
select() {
|
||||
return {
|
||||
from(table: unknown) {
|
||||
if (isCompanyMembershipsTable(table)) {
|
||||
const query = {
|
||||
where() {
|
||||
return query;
|
||||
},
|
||||
orderBy() {
|
||||
return Promise.resolve(activeMemberships);
|
||||
},
|
||||
};
|
||||
return query;
|
||||
}
|
||||
if (isAuthUsersTable(table)) {
|
||||
return {
|
||||
where() {
|
||||
return Promise.resolve(users);
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error("Unexpected table");
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(actor: Express.Request["actor"]) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
"/api",
|
||||
accessRoutes(createDbStub() as never, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("GET /companies/:companyId/user-directory", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns active human users for operators without manage-permissions access", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }],
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/companies/company-1/user-directory");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
users: [
|
||||
{
|
||||
principalId: "user-2",
|
||||
status: "active",
|
||||
user: { id: "user-2", name: null, email: "alex@example.com", image: null },
|
||||
},
|
||||
{
|
||||
principalId: "user-1",
|
||||
status: "active",
|
||||
user: { id: "user-1", name: "Dotta", email: "dotta@example.com", image: "https://example.com/dotta.png" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
workspaceRuntimeServices,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
@@ -136,7 +135,6 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issues);
|
||||
await db.delete(workspaceRuntimeServices);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
@@ -326,136 +324,4 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
"git_branch_delete",
|
||||
]));
|
||||
}, 20_000);
|
||||
|
||||
it("shows inherited shared project runtime services on shared execution workspaces without duplicating old history", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
const olderServiceId = randomUUID();
|
||||
const currentServiceId = randomUUID();
|
||||
const reuseKey = `project_workspace:${projectWorkspaceId}:paperclip-dev`;
|
||||
const startedAt = new Date("2026-04-04T17:00:00.000Z");
|
||||
const stoppedAt = new Date("2026-04-04T17:05:00.000Z");
|
||||
const runningAt = new Date("2026-04-04T17:10:00.000Z");
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspaces",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
sourceType: "local_path",
|
||||
isPrimary: true,
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
metadata: {
|
||||
runtimeConfig: {
|
||||
desiredState: "running",
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "paperclip-dev", command: "pnpm dev" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "Shared workspace",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
});
|
||||
await db.insert(workspaceRuntimeServices).values([
|
||||
{
|
||||
id: olderServiceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
executionWorkspaceId: null,
|
||||
issueId: null,
|
||||
scopeType: "project_workspace",
|
||||
scopeId: projectWorkspaceId,
|
||||
serviceName: "paperclip-dev",
|
||||
status: "stopped",
|
||||
lifecycle: "shared",
|
||||
reuseKey,
|
||||
command: "pnpm dev",
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
port: 49195,
|
||||
url: "http://127.0.0.1:49195",
|
||||
provider: "local_process",
|
||||
providerRef: "11111",
|
||||
ownerAgentId: null,
|
||||
startedByRunId: null,
|
||||
lastUsedAt: stoppedAt,
|
||||
startedAt,
|
||||
stoppedAt,
|
||||
stopPolicy: { type: "manual" },
|
||||
healthStatus: "unknown",
|
||||
createdAt: startedAt,
|
||||
updatedAt: stoppedAt,
|
||||
},
|
||||
{
|
||||
id: currentServiceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
executionWorkspaceId: null,
|
||||
issueId: null,
|
||||
scopeType: "project_workspace",
|
||||
scopeId: projectWorkspaceId,
|
||||
serviceName: "paperclip-dev",
|
||||
status: "running",
|
||||
lifecycle: "shared",
|
||||
reuseKey,
|
||||
command: "pnpm dev",
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
port: 49222,
|
||||
url: "http://127.0.0.1:49222",
|
||||
provider: "local_process",
|
||||
providerRef: "22222",
|
||||
ownerAgentId: null,
|
||||
startedByRunId: null,
|
||||
lastUsedAt: runningAt,
|
||||
startedAt: runningAt,
|
||||
stoppedAt: null,
|
||||
stopPolicy: { type: "manual" },
|
||||
healthStatus: "healthy",
|
||||
createdAt: runningAt,
|
||||
updatedAt: runningAt,
|
||||
},
|
||||
]);
|
||||
|
||||
const workspace = await svc.getById(executionWorkspaceId);
|
||||
const listed = await svc.list(companyId, { projectId });
|
||||
|
||||
expect(workspace?.runtimeServices).toHaveLength(1);
|
||||
expect(workspace?.runtimeServices?.[0]).toMatchObject({
|
||||
id: currentServiceId,
|
||||
status: "running",
|
||||
projectWorkspaceId,
|
||||
executionWorkspaceId: null,
|
||||
url: "http://127.0.0.1:49222",
|
||||
});
|
||||
expect(listed[0]?.runtimeServices).toHaveLength(1);
|
||||
expect(listed[0]?.runtimeServices?.[0]?.id).toBe(currentServiceId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { serverVersion } from "../version.js";
|
||||
import { healthRoutes } from "../routes/health.js";
|
||||
import * as devServerStatus from "../dev-server-status.js";
|
||||
import { serverVersion } from "../version.js";
|
||||
|
||||
const mockReadPersistedDevServerStatus = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -27,10 +28,8 @@ describe("GET /health", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns 200 with status ok", async () => {
|
||||
const app = createApp();
|
||||
|
||||
const res = await request(app).get("/health");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ status: "ok", version: serverVersion });
|
||||
@@ -45,6 +44,7 @@ describe("GET /health", () => {
|
||||
const res = await request(app).get("/health");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(db.execute).toHaveBeenCalledTimes(1);
|
||||
expect(res.body).toMatchObject({ status: "ok", version: serverVersion });
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("GET /health", () => {
|
||||
expect(res.body).toEqual({
|
||||
status: "unhealthy",
|
||||
version: serverVersion,
|
||||
error: "database_unreachable",
|
||||
error: "database_unreachable"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -536,7 +536,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
});
|
||||
|
||||
it("tracks the first heartbeat with the agent role instead of adapter type", async () => {
|
||||
const { runId } = await seedRunFixture({
|
||||
const { agentId, runId } = await seedRunFixture({
|
||||
agentStatus: "running",
|
||||
includeIssue: false,
|
||||
});
|
||||
@@ -548,6 +548,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
mockTelemetryClient,
|
||||
expect.objectContaining({
|
||||
agentRole: "engineer",
|
||||
agentId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -201,44 +201,6 @@ describe("buildRealizedExecutionWorkspaceFromPersisted", () => {
|
||||
expect(result.branchName).toBe("PAP-880-thumbs-capture-for-evals-feature");
|
||||
expect(result.source).toBe("task_session");
|
||||
});
|
||||
|
||||
it("falls back to realization when the persisted workspace has no local path yet", () => {
|
||||
const result = buildRealizedExecutionWorkspaceFromPersisted({
|
||||
base: buildResolvedWorkspace({
|
||||
cwd: "/tmp/project-primary",
|
||||
repoRef: "main",
|
||||
}),
|
||||
workspace: {
|
||||
id: "execution-workspace-2",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
sourceIssueId: "issue-2",
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "PAP-999-missing-provider-ref",
|
||||
status: "active",
|
||||
cwd: null,
|
||||
repoUrl: "https://example.com/paperclip.git",
|
||||
baseRef: "main",
|
||||
branchName: "feature/PAP-999-missing-provider-ref",
|
||||
providerType: "git_worktree",
|
||||
providerRef: null,
|
||||
derivedFromExecutionWorkspaceId: null,
|
||||
lastUsedAt: new Date(),
|
||||
openedAt: new Date(),
|
||||
closedAt: null,
|
||||
cleanupEligibleAt: null,
|
||||
cleanupReason: null,
|
||||
config: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => {
|
||||
|
||||
@@ -176,6 +176,22 @@ describe("instance settings routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects signed-in users without company access from reading general settings", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "user-2",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [],
|
||||
memberships: [],
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/instance/settings/general");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-admin board users from updating general settings", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
|
||||
126
server/src/__tests__/invite-accept-existing-member.test.ts
Normal file
126
server/src/__tests__/invite-accept-existing-member.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
isInstanceAdmin: vi.fn(),
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
boardAuthService: () => ({
|
||||
createChallenge: vi.fn(),
|
||||
resolveBoardAccess: vi.fn(),
|
||||
assertCurrentBoardKey: vi.fn(),
|
||||
revokeBoardApiKey: vi.fn(),
|
||||
}),
|
||||
deduplicateAgentName: vi.fn(),
|
||||
logActivity: vi.fn(),
|
||||
notifyHireApproved: vi.fn(),
|
||||
}));
|
||||
|
||||
function createDbStub() {
|
||||
const updateMock = vi.fn();
|
||||
const invite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "human",
|
||||
tokenHash: "hash",
|
||||
defaultsPayload: { humanRole: "viewer" },
|
||||
expiresAt: new Date("2027-03-10T00:00:00.000Z"),
|
||||
invitedByUserId: "user-1",
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
const db = {
|
||||
select() {
|
||||
return {
|
||||
from() {
|
||||
return {
|
||||
where() {
|
||||
return Promise.resolve([invite]);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
update(...args: unknown[]) {
|
||||
updateMock(...args);
|
||||
return {
|
||||
set() {
|
||||
return {
|
||||
where() {
|
||||
return {
|
||||
returning() {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return { db, updateMock };
|
||||
}
|
||||
|
||||
function createApp(db: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
source: "session",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
memberships: [
|
||||
{
|
||||
companyId: "company-1",
|
||||
membershipRole: "owner",
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
"/api",
|
||||
accessRoutes(db as any, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("POST /invites/:token/accept", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not consume a human invite when the signed-in user is already a company member", async () => {
|
||||
const { db, updateMock } = createDbStub();
|
||||
const app = createApp(db);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/invites/pcp_invite_test/accept")
|
||||
.send({ requestType: "human" });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toBe("You already belong to this company");
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
126
server/src/__tests__/invite-create-route.test.ts
Normal file
126
server/src/__tests__/invite-create-route.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const logActivityMock = vi.fn();
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
isInstanceAdmin: vi.fn(),
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
boardAuthService: () => ({
|
||||
createChallenge: vi.fn(),
|
||||
resolveBoardAccess: vi.fn(),
|
||||
assertCurrentBoardKey: vi.fn(),
|
||||
revokeBoardApiKey: vi.fn(),
|
||||
}),
|
||||
deduplicateAgentName: vi.fn(),
|
||||
logActivity: (...args: unknown[]) => logActivityMock(...args),
|
||||
notifyHireApproved: vi.fn(),
|
||||
}));
|
||||
|
||||
function createDbStub() {
|
||||
const createdInvite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "human",
|
||||
tokenHash: "hash",
|
||||
defaultsPayload: { humanRole: "viewer" },
|
||||
expiresAt: new Date("2027-03-10T00:00:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
return {
|
||||
insert() {
|
||||
return {
|
||||
values() {
|
||||
return {
|
||||
returning() {
|
||||
return Promise.resolve([createdInvite]);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
select(_shape?: unknown) {
|
||||
return {
|
||||
from() {
|
||||
const query = {
|
||||
leftJoin() {
|
||||
return query;
|
||||
},
|
||||
where() {
|
||||
return Promise.resolve([{
|
||||
name: "Acme Robotics",
|
||||
brandColor: "#114488",
|
||||
logoAssetId: "logo-1",
|
||||
}]);
|
||||
},
|
||||
};
|
||||
return query;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
source: "local_implicit",
|
||||
userId: null,
|
||||
companyIds: ["company-1"],
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
"/api",
|
||||
accessRoutes(createDbStub() as any, {
|
||||
deploymentMode: "local_trusted",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("POST /companies/:companyId/invites", () => {
|
||||
beforeEach(() => {
|
||||
logActivityMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns an absolute invite URL using the request base URL", async () => {
|
||||
const app = createApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/invites")
|
||||
.set("host", "paperclip.example")
|
||||
.set("x-forwarded-proto", "https")
|
||||
.send({
|
||||
allowedJoinTypes: "human",
|
||||
humanRole: "viewer",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.companyName).toBe("Acme Robotics");
|
||||
expect(res.body.invitePath).toMatch(/^\/invite\/pcp_invite_/);
|
||||
expect(res.body.inviteUrl).toMatch(/^https:\/\/paperclip\.example\/invite\/pcp_invite_/);
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
|
||||
import { companyInviteExpiresAt } from "../routes/access.js";
|
||||
|
||||
describe("companyInviteExpiresAt", () => {
|
||||
it("sets invite expiration to 10 minutes after invite creation time", () => {
|
||||
it("sets invite expiration to 72 hours after invite creation time", () => {
|
||||
const createdAtMs = Date.parse("2026-03-06T00:00:00.000Z");
|
||||
const expiresAt = companyInviteExpiresAt(createdAtMs);
|
||||
expect(expiresAt.toISOString()).toBe("2026-03-06T00:10:00.000Z");
|
||||
expect(expiresAt.toISOString()).toBe("2026-03-09T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { agentJoinGrantsFromDefaults } from "../routes/access.js";
|
||||
import {
|
||||
agentJoinGrantsFromDefaults,
|
||||
humanJoinGrantsFromDefaults,
|
||||
} from "../services/invite-grants.js";
|
||||
import {
|
||||
grantsForHumanRole,
|
||||
normalizeHumanRole,
|
||||
resolveHumanInviteRole,
|
||||
} from "../services/company-member-roles.js";
|
||||
|
||||
describe("agentJoinGrantsFromDefaults", () => {
|
||||
it("adds tasks:assign when invite defaults do not specify agent grants", () => {
|
||||
@@ -55,3 +63,59 @@ describe("agentJoinGrantsFromDefaults", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("human invite roles", () => {
|
||||
it("maps owner to the full management grant set", () => {
|
||||
expect(grantsForHumanRole("owner")).toEqual([
|
||||
{ permissionKey: "agents:create", scope: null },
|
||||
{ permissionKey: "users:invite", scope: null },
|
||||
{ permissionKey: "users:manage_permissions", scope: null },
|
||||
{ permissionKey: "tasks:assign", scope: null },
|
||||
{ permissionKey: "joins:approve", scope: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults legacy or missing roles to operator", () => {
|
||||
expect(normalizeHumanRole("member")).toBe("operator");
|
||||
expect(resolveHumanInviteRole(null)).toBe("operator");
|
||||
});
|
||||
|
||||
it("reads the configured human invite role from defaults", () => {
|
||||
expect(
|
||||
resolveHumanInviteRole({
|
||||
human: {
|
||||
role: "viewer",
|
||||
},
|
||||
}),
|
||||
).toBe("viewer");
|
||||
});
|
||||
|
||||
it("falls back to role grants when human invite defaults omit explicit grants", () => {
|
||||
expect(humanJoinGrantsFromDefaults(null, "operator")).toEqual([
|
||||
{ permissionKey: "tasks:assign", scope: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves explicit human invite grants", () => {
|
||||
expect(
|
||||
humanJoinGrantsFromDefaults(
|
||||
{
|
||||
human: {
|
||||
grants: [
|
||||
{
|
||||
permissionKey: "users:invite",
|
||||
scope: { companyId: "company-1" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"operator",
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
permissionKey: "users:invite",
|
||||
scope: { companyId: "company-1" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
164
server/src/__tests__/invite-list-route.test.ts
Normal file
164
server/src/__tests__/invite-list-route.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { companies, createDb, invites, joinRequests } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
isInstanceAdmin: vi.fn(),
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
boardAuthService: () => ({
|
||||
createChallenge: vi.fn(),
|
||||
resolveBoardAccess: vi.fn(),
|
||||
assertCurrentBoardKey: vi.fn(),
|
||||
revokeBoardApiKey: vi.fn(),
|
||||
}),
|
||||
deduplicateAgentName: vi.fn(),
|
||||
logActivity: vi.fn(),
|
||||
notifyHireApproved: vi.fn(),
|
||||
}));
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres invite list route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("GET /companies/:companyId/invites", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let companyId!: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-invite-list-route-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(joinRequests);
|
||||
await db.delete(invites);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
function createApp(currentCompanyId: string) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
source: "local_implicit",
|
||||
userId: null,
|
||||
companyIds: [currentCompanyId],
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
"/api",
|
||||
accessRoutes(db, {
|
||||
deploymentMode: "local_trusted",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns invite history in descending pages with nextOffset", async () => {
|
||||
const inviteOneId = randomUUID();
|
||||
const inviteTwoId = randomUUID();
|
||||
const inviteThreeId = randomUUID();
|
||||
|
||||
await db.insert(invites).values([
|
||||
{
|
||||
id: inviteOneId,
|
||||
companyId,
|
||||
inviteType: "company_join",
|
||||
tokenHash: "invite-token-1",
|
||||
allowedJoinTypes: "human",
|
||||
defaultsPayload: { humanRole: "viewer" },
|
||||
expiresAt: new Date("2026-04-20T00:00:00.000Z"),
|
||||
createdAt: new Date("2026-04-10T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-10T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: inviteTwoId,
|
||||
companyId,
|
||||
inviteType: "company_join",
|
||||
tokenHash: "invite-token-2",
|
||||
allowedJoinTypes: "human",
|
||||
defaultsPayload: { humanRole: "operator" },
|
||||
expiresAt: new Date("2026-04-21T00:00:00.000Z"),
|
||||
createdAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: inviteThreeId,
|
||||
companyId,
|
||||
inviteType: "company_join",
|
||||
tokenHash: "invite-token-3",
|
||||
allowedJoinTypes: "human",
|
||||
defaultsPayload: { humanRole: "admin" },
|
||||
expiresAt: new Date("2026-04-22T00:00:00.000Z"),
|
||||
createdAt: new Date("2026-04-12T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-12T00:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(joinRequests).values({
|
||||
id: randomUUID(),
|
||||
inviteId: inviteThreeId,
|
||||
companyId,
|
||||
requestType: "human",
|
||||
status: "pending_approval",
|
||||
requestIp: "127.0.0.1",
|
||||
requestEmailSnapshot: "person@example.com",
|
||||
createdAt: new Date("2026-04-12T00:05:00.000Z"),
|
||||
updatedAt: new Date("2026-04-12T00:05:00.000Z"),
|
||||
});
|
||||
|
||||
const app = createApp(companyId);
|
||||
|
||||
const firstPage = await request(app).get(`/api/companies/${companyId}/invites?limit=2`);
|
||||
|
||||
expect(firstPage.status).toBe(200);
|
||||
expect(firstPage.body.invites).toHaveLength(2);
|
||||
expect(firstPage.body.invites.map((invite: { id: string }) => invite.id)).toEqual([inviteThreeId, inviteTwoId]);
|
||||
expect(firstPage.body.invites[0].relatedJoinRequestId).toBeTruthy();
|
||||
expect(firstPage.body.nextOffset).toBe(2);
|
||||
|
||||
const secondPage = await request(app).get(`/api/companies/${companyId}/invites?limit=2&offset=2`);
|
||||
|
||||
expect(secondPage.status).toBe(200);
|
||||
expect(secondPage.body.invites).toHaveLength(1);
|
||||
expect(secondPage.body.invites[0].id).toBe(inviteOneId);
|
||||
expect(secondPage.body.nextOffset).toBeNull();
|
||||
});
|
||||
});
|
||||
146
server/src/__tests__/invite-logo-route.test.ts
Normal file
146
server/src/__tests__/invite-logo-route.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Readable } from "node:stream";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockStorage = vi.hoisted(() => ({
|
||||
getObject: vi.fn(),
|
||||
headObject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../storage/index.js", () => ({
|
||||
getStorageService: () => mockStorage,
|
||||
}));
|
||||
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
function createSelectChain(rows: unknown[]) {
|
||||
const query = {
|
||||
leftJoin() {
|
||||
return query;
|
||||
},
|
||||
where() {
|
||||
return Promise.resolve(rows);
|
||||
},
|
||||
};
|
||||
return {
|
||||
from() {
|
||||
return query;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDbStub(inviteRows: unknown[], companyRows: unknown[]) {
|
||||
let selectCall = 0;
|
||||
return {
|
||||
select() {
|
||||
selectCall += 1;
|
||||
return selectCall === 1
|
||||
? createSelectChain(inviteRows)
|
||||
: createSelectChain(companyRows);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(db: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = { type: "anon" };
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
"/api",
|
||||
accessRoutes(db as any, {
|
||||
deploymentMode: "local_trusted",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("GET /invites/:token/logo", () => {
|
||||
beforeEach(() => {
|
||||
mockStorage.getObject.mockReset();
|
||||
mockStorage.headObject.mockReset();
|
||||
});
|
||||
|
||||
it("serves the company logo for an active invite without company auth", async () => {
|
||||
const invite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "human",
|
||||
tokenHash: "hash",
|
||||
defaultsPayload: null,
|
||||
expiresAt: new Date("2027-03-07T00:10:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
};
|
||||
mockStorage.headObject.mockResolvedValue({
|
||||
exists: true,
|
||||
contentType: "image/png",
|
||||
contentLength: 3,
|
||||
});
|
||||
mockStorage.getObject.mockResolvedValue({
|
||||
contentType: "image/png",
|
||||
contentLength: 3,
|
||||
stream: Readable.from([Buffer.from("png")]),
|
||||
});
|
||||
const app = createApp(
|
||||
createDbStub([invite], [{
|
||||
companyId: "company-1",
|
||||
objectKey: "assets/companies/logo-1",
|
||||
contentType: "image/png",
|
||||
byteSize: 3,
|
||||
originalFilename: "logo.png",
|
||||
}]),
|
||||
);
|
||||
|
||||
const res = await request(app).get("/api/invites/pcp_invite_test/logo");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toContain("image/png");
|
||||
expect(mockStorage.headObject).toHaveBeenCalledWith("company-1", "assets/companies/logo-1");
|
||||
expect(mockStorage.getObject).toHaveBeenCalledWith("company-1", "assets/companies/logo-1");
|
||||
});
|
||||
|
||||
it("returns 404 when the logo asset record exists but storage does not", async () => {
|
||||
const invite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "human",
|
||||
tokenHash: "hash",
|
||||
defaultsPayload: null,
|
||||
expiresAt: new Date("2027-03-07T00:10:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
};
|
||||
mockStorage.headObject.mockResolvedValue({ exists: false });
|
||||
const app = createApp(
|
||||
createDbStub([invite], [{
|
||||
companyId: "company-1",
|
||||
objectKey: "assets/companies/logo-1",
|
||||
contentType: "image/png",
|
||||
byteSize: 3,
|
||||
originalFilename: "logo.png",
|
||||
}]),
|
||||
);
|
||||
|
||||
const res = await request(app).get("/api/invites/pcp_invite_test/logo");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe("Invite logo not found");
|
||||
expect(mockStorage.getObject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
270
server/src/__tests__/invite-summary-route.test.ts
Normal file
270
server/src/__tests__/invite-summary-route.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockStorage = vi.hoisted(() => ({
|
||||
headObject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../storage/index.js", () => ({
|
||||
getStorageService: () => mockStorage,
|
||||
}));
|
||||
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
function createSelectChain(rows: unknown[]) {
|
||||
const query = {
|
||||
then(resolve: (value: unknown[]) => unknown) {
|
||||
return Promise.resolve(rows).then(resolve);
|
||||
},
|
||||
leftJoin() {
|
||||
return query;
|
||||
},
|
||||
orderBy() {
|
||||
return query;
|
||||
},
|
||||
where() {
|
||||
return query;
|
||||
},
|
||||
};
|
||||
return {
|
||||
from() {
|
||||
return query;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDbStub(...selectResponses: unknown[][]) {
|
||||
let selectCall = 0;
|
||||
return {
|
||||
select() {
|
||||
const rows = selectResponses[selectCall] ?? [];
|
||||
selectCall += 1;
|
||||
return createSelectChain(rows);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(
|
||||
db: Record<string, unknown>,
|
||||
actor: Record<string, unknown> = { type: "anon" },
|
||||
) {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
"/api",
|
||||
accessRoutes(db as any, {
|
||||
deploymentMode: "local_trusted",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("GET /invites/:token", () => {
|
||||
beforeEach(() => {
|
||||
mockStorage.headObject.mockReset();
|
||||
mockStorage.headObject.mockResolvedValue({ exists: true, contentLength: 3, contentType: "image/png" });
|
||||
});
|
||||
|
||||
it("returns company branding in the invite summary response", async () => {
|
||||
const invite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "human",
|
||||
tokenHash: "hash",
|
||||
defaultsPayload: null,
|
||||
expiresAt: new Date("2027-03-07T00:10:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
};
|
||||
const app = createApp(
|
||||
createDbStub(
|
||||
[invite],
|
||||
[
|
||||
{
|
||||
name: "Acme Robotics",
|
||||
brandColor: "#114488",
|
||||
logoAssetId: "logo-1",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
companyId: "company-1",
|
||||
objectKey: "company-1/assets/companies/logo-1",
|
||||
contentType: "image/png",
|
||||
byteSize: 3,
|
||||
originalFilename: "logo.png",
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
const res = await request(app).get("/api/invites/pcp_invite_test");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.companyId).toBe("company-1");
|
||||
expect(res.body.companyName).toBe("Acme Robotics");
|
||||
expect(res.body.companyBrandColor).toBe("#114488");
|
||||
expect(res.body.companyLogoUrl).toBe("/api/invites/pcp_invite_test/logo");
|
||||
expect(res.body.inviteType).toBe("company_join");
|
||||
});
|
||||
|
||||
it("omits companyLogoUrl when the stored logo object is missing", async () => {
|
||||
mockStorage.headObject.mockResolvedValue({ exists: false });
|
||||
|
||||
const invite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "human",
|
||||
tokenHash: "hash",
|
||||
defaultsPayload: null,
|
||||
expiresAt: new Date("2027-03-07T00:10:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
};
|
||||
const app = createApp(
|
||||
createDbStub(
|
||||
[invite],
|
||||
[
|
||||
{
|
||||
name: "Acme Robotics",
|
||||
brandColor: "#114488",
|
||||
logoAssetId: "logo-1",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
companyId: "company-1",
|
||||
objectKey: "company-1/assets/companies/logo-1",
|
||||
contentType: "image/png",
|
||||
byteSize: 3,
|
||||
originalFilename: "logo.png",
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
const res = await request(app).get("/api/invites/pcp_invite_test");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.companyLogoUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("returns pending join-request status for an already-accepted invite", async () => {
|
||||
const invite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "human",
|
||||
tokenHash: "hash",
|
||||
defaultsPayload: null,
|
||||
expiresAt: new Date("2027-03-07T00:10:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
revokedAt: null,
|
||||
acceptedAt: new Date("2026-03-07T00:05:00.000Z"),
|
||||
createdAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-07T00:05:00.000Z"),
|
||||
};
|
||||
const app = createApp(
|
||||
createDbStub(
|
||||
[invite],
|
||||
[{ requestType: "human", status: "pending_approval" }],
|
||||
[
|
||||
{
|
||||
name: "Acme Robotics",
|
||||
brandColor: "#114488",
|
||||
logoAssetId: "logo-1",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
companyId: "company-1",
|
||||
objectKey: "company-1/assets/companies/logo-1",
|
||||
contentType: "image/png",
|
||||
byteSize: 3,
|
||||
originalFilename: "logo.png",
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
const res = await request(app).get("/api/invites/pcp_invite_test");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.joinRequestStatus).toBe("pending_approval");
|
||||
expect(res.body.joinRequestType).toBe("human");
|
||||
expect(res.body.companyName).toBe("Acme Robotics");
|
||||
});
|
||||
|
||||
it("falls back to a reusable human join request when the accepted invite reused an existing queue entry", async () => {
|
||||
const invite = {
|
||||
id: "invite-2",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "human",
|
||||
tokenHash: "hash",
|
||||
defaultsPayload: null,
|
||||
expiresAt: new Date("2027-03-07T00:10:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
revokedAt: null,
|
||||
acceptedAt: new Date("2026-03-07T00:05:00.000Z"),
|
||||
createdAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-07T00:05:00.000Z"),
|
||||
};
|
||||
const app = createApp(
|
||||
createDbStub(
|
||||
[invite],
|
||||
[],
|
||||
[{ email: "jane@example.com" }],
|
||||
[
|
||||
{
|
||||
id: "join-1",
|
||||
requestType: "human",
|
||||
status: "pending_approval",
|
||||
requestingUserId: "user-1",
|
||||
requestEmailSnapshot: "jane@example.com",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: "Acme Robotics",
|
||||
brandColor: "#114488",
|
||||
logoAssetId: "logo-1",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
companyId: "company-1",
|
||||
objectKey: "company-1/assets/companies/logo-1",
|
||||
contentType: "image/png",
|
||||
byteSize: 3,
|
||||
originalFilename: "logo.png",
|
||||
},
|
||||
],
|
||||
),
|
||||
{ type: "board", userId: "user-1", source: "session" },
|
||||
);
|
||||
|
||||
const res = await request(app).get("/api/invites/pcp_invite_test");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.joinRequestStatus).toBe("pending_approval");
|
||||
expect(res.body.joinRequestType).toBe("human");
|
||||
});
|
||||
});
|
||||
@@ -180,7 +180,6 @@ describe("issue feedback trace routes", () => {
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/feedback-traces/trace-1");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockFeedbackService.getFeedbackTraceById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -379,7 +379,6 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
it("returns null instead of throwing for malformed non-uuid issue refs", async () => {
|
||||
await expect(svc.getById("not-a-uuid")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("filters issues by execution workspace id", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
@@ -1217,6 +1216,283 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof issueService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
svc = issueService(db);
|
||||
await ensureIssueRelationsTable(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issueInboxArchives);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(instanceSettings);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const parentIssueId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary workspace",
|
||||
isPrimary: true,
|
||||
sharedWorkspaceKey: "workspace-key",
|
||||
});
|
||||
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Issue worktree",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
providerRef: `/tmp/${executionWorkspaceId}`,
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: parentIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "isolated_workspace",
|
||||
workspaceRuntime: { profile: "agent" },
|
||||
},
|
||||
});
|
||||
|
||||
const child = await svc.create(companyId, {
|
||||
parentId: parentIssueId,
|
||||
projectId,
|
||||
title: "Child issue",
|
||||
});
|
||||
|
||||
expect(child.parentId).toBe(parentIssueId);
|
||||
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
|
||||
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
|
||||
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
||||
expect(child.executionWorkspaceSettings).toEqual({
|
||||
mode: "isolated_workspace",
|
||||
workspaceRuntime: { profile: "agent" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const parentIssueId = randomUUID();
|
||||
const parentProjectWorkspaceId = randomUUID();
|
||||
const parentExecutionWorkspaceId = randomUUID();
|
||||
const explicitProjectWorkspaceId = randomUUID();
|
||||
const explicitExecutionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values([
|
||||
{
|
||||
id: parentProjectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Parent workspace",
|
||||
},
|
||||
{
|
||||
id: explicitProjectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Explicit workspace",
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(executionWorkspaces).values([
|
||||
{
|
||||
id: parentExecutionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId: parentProjectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Parent worktree",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
},
|
||||
{
|
||||
id: explicitExecutionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId: explicitProjectWorkspaceId,
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "Explicit shared workspace",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: parentIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId: parentProjectWorkspaceId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
executionWorkspaceId: parentExecutionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "isolated_workspace",
|
||||
},
|
||||
});
|
||||
|
||||
const child = await svc.create(companyId, {
|
||||
parentId: parentIssueId,
|
||||
projectId,
|
||||
title: "Child issue",
|
||||
projectWorkspaceId: explicitProjectWorkspaceId,
|
||||
executionWorkspaceId: explicitExecutionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "shared_workspace",
|
||||
},
|
||||
});
|
||||
|
||||
expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId);
|
||||
expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId);
|
||||
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
||||
expect(child.executionWorkspaceSettings).toEqual({
|
||||
mode: "shared_workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const sourceIssueId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary workspace",
|
||||
});
|
||||
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "operator_branch",
|
||||
strategyType: "git_worktree",
|
||||
name: "Operator branch",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: sourceIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
title: "Source issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "operator_branch",
|
||||
},
|
||||
});
|
||||
|
||||
const followUp = await svc.create(companyId, {
|
||||
projectId,
|
||||
title: "Follow-up issue",
|
||||
inheritExecutionWorkspaceFromIssueId: sourceIssueId,
|
||||
});
|
||||
|
||||
expect(followUp.parentId).toBeNull();
|
||||
expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId);
|
||||
expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId);
|
||||
expect(followUp.executionWorkspacePreference).toBe("reuse_existing");
|
||||
expect(followUp.executionWorkspaceSettings).toEqual({
|
||||
mode: "operator_branch",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof issueService>;
|
||||
|
||||
104
server/src/__tests__/join-request-dedupe.test.ts
Normal file
104
server/src/__tests__/join-request-dedupe.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collapseDuplicatePendingHumanJoinRequests,
|
||||
findReusableHumanJoinRequest,
|
||||
} from "../lib/join-request-dedupe.js";
|
||||
|
||||
describe("findReusableHumanJoinRequest", () => {
|
||||
it("reuses the newest pending request for the same user", () => {
|
||||
const rows = [
|
||||
{
|
||||
id: "pending-new",
|
||||
requestType: "human",
|
||||
status: "pending_approval",
|
||||
requestingUserId: "user-1",
|
||||
requestEmailSnapshot: "person@example.com",
|
||||
},
|
||||
{
|
||||
id: "pending-old",
|
||||
requestType: "human",
|
||||
status: "pending_approval",
|
||||
requestingUserId: "user-1",
|
||||
requestEmailSnapshot: "person@example.com",
|
||||
},
|
||||
{
|
||||
id: "other-user",
|
||||
requestType: "human",
|
||||
status: "pending_approval",
|
||||
requestingUserId: "user-2",
|
||||
requestEmailSnapshot: "other@example.com",
|
||||
},
|
||||
] as const;
|
||||
|
||||
expect(
|
||||
findReusableHumanJoinRequest(rows, {
|
||||
requestingUserId: "user-1",
|
||||
requestEmailSnapshot: "person@example.com",
|
||||
})?.id
|
||||
).toBe("pending-new");
|
||||
});
|
||||
|
||||
it("falls back to email matching when the user id is unavailable", () => {
|
||||
const rows = [
|
||||
{
|
||||
id: "approved-existing",
|
||||
requestType: "human",
|
||||
status: "approved",
|
||||
requestingUserId: null,
|
||||
requestEmailSnapshot: "Person@Example.com",
|
||||
},
|
||||
{
|
||||
id: "agent-request",
|
||||
requestType: "agent",
|
||||
status: "pending_approval",
|
||||
requestingUserId: null,
|
||||
requestEmailSnapshot: null,
|
||||
},
|
||||
] as const;
|
||||
|
||||
expect(
|
||||
findReusableHumanJoinRequest(rows, {
|
||||
requestingUserId: null,
|
||||
requestEmailSnapshot: "person@example.com",
|
||||
})?.id
|
||||
).toBe("approved-existing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collapseDuplicatePendingHumanJoinRequests", () => {
|
||||
it("keeps only the newest pending human row per requester", () => {
|
||||
const rows = [
|
||||
{
|
||||
id: "human-new",
|
||||
requestType: "human",
|
||||
status: "pending_approval",
|
||||
requestingUserId: "user-1",
|
||||
requestEmailSnapshot: "person@example.com",
|
||||
},
|
||||
{
|
||||
id: "human-old",
|
||||
requestType: "human",
|
||||
status: "pending_approval",
|
||||
requestingUserId: "user-1",
|
||||
requestEmailSnapshot: "person@example.com",
|
||||
},
|
||||
{
|
||||
id: "approved-history",
|
||||
requestType: "human",
|
||||
status: "approved",
|
||||
requestingUserId: "user-1",
|
||||
requestEmailSnapshot: "person@example.com",
|
||||
},
|
||||
{
|
||||
id: "agent-pending",
|
||||
requestType: "agent",
|
||||
status: "pending_approval",
|
||||
requestingUserId: null,
|
||||
requestEmailSnapshot: null,
|
||||
},
|
||||
] as const;
|
||||
|
||||
expect(collapseDuplicatePendingHumanJoinRequests(rows).map((row) => row.id))
|
||||
.toEqual(["human-new", "approved-history", "agent-pending"]);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,9 @@ const mockBoardAuthService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockStorage = vi.hoisted(() => ({
|
||||
headObject: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
@@ -44,6 +47,10 @@ function registerModuleMocks() {
|
||||
}));
|
||||
}
|
||||
|
||||
vi.mock("../storage/index.js", () => ({
|
||||
getStorageService: () => mockStorage,
|
||||
}));
|
||||
|
||||
function createDbStub() {
|
||||
const createdInvite = {
|
||||
id: "invite-1",
|
||||
@@ -76,20 +83,35 @@ function createDbStub() {
|
||||
"feedbackDataSharingEnabled" in table;
|
||||
const select = vi.fn((selection?: unknown) => ({
|
||||
from(table: unknown) {
|
||||
return {
|
||||
const query = {
|
||||
leftJoin: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockImplementation(() => {
|
||||
if (isInvitesTable(table)) {
|
||||
return Promise.resolve([createdInvite]);
|
||||
}
|
||||
if (selection && typeof selection === "object" && "objectKey" in selection) {
|
||||
return Promise.resolve([{
|
||||
companyId: "company-1",
|
||||
objectKey: "company-1/assets/companies/logo-1",
|
||||
contentType: "image/png",
|
||||
byteSize: 3,
|
||||
originalFilename: "logo.png",
|
||||
}]);
|
||||
}
|
||||
if (
|
||||
(selection && typeof selection === "object" && "name" in selection) ||
|
||||
isCompaniesTable(table)
|
||||
) {
|
||||
return Promise.resolve([{ name: "Acme AI" }]);
|
||||
return Promise.resolve([{
|
||||
name: "Acme AI",
|
||||
brandColor: "#225577",
|
||||
logoAssetId: "logo-1",
|
||||
}]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}),
|
||||
};
|
||||
return query;
|
||||
},
|
||||
}));
|
||||
return {
|
||||
@@ -135,6 +157,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAgentService.getById.mockReset();
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockStorage.headObject.mockResolvedValue({ exists: true, contentLength: 3, contentType: "image/png" });
|
||||
});
|
||||
|
||||
it("rejects non-CEO agent callers", async () => {
|
||||
@@ -212,6 +235,8 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.companyName).toBe("Acme AI");
|
||||
expect(res.body.companyBrandColor).toBe("#225577");
|
||||
expect(res.body.companyLogoUrl).toBe("/api/invites/pcp_invite_test/logo");
|
||||
expect(res.body.inviteType).toBe("company_join");
|
||||
expect(res.body.allowedJoinTypes).toBe("agent");
|
||||
});
|
||||
|
||||
@@ -5,11 +5,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const mockRegistry = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getByKey: vi.fn(),
|
||||
upsertConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLifecycle = vi.hoisted(() => ({
|
||||
load: vi.fn(),
|
||||
upgrade: vi.fn(),
|
||||
unload: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/plugin-registry.js", () => ({
|
||||
@@ -138,6 +142,31 @@ describe("plugin install and upgrade authz", () => {
|
||||
expect(mockLifecycle.upgrade).not.toHaveBeenCalled();
|
||||
}, 20_000);
|
||||
|
||||
it.each([
|
||||
["delete", "delete", "/api/plugins/11111111-1111-4111-8111-111111111111", undefined],
|
||||
["enable", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/enable", {}],
|
||||
["disable", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/disable", {}],
|
||||
["config", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/config", { configJson: {} }],
|
||||
] as const)("rejects plugin %s for non-admin board users", async (_name, method, path, body) => {
|
||||
const { app } = await createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
|
||||
const req = method === "delete" ? request(app).delete(path) : request(app).post(path).send(body);
|
||||
const res = await req;
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockRegistry.getById).not.toHaveBeenCalled();
|
||||
expect(mockRegistry.upsertConfig).not.toHaveBeenCalled();
|
||||
expect(mockLifecycle.unload).not.toHaveBeenCalled();
|
||||
expect(mockLifecycle.enable).not.toHaveBeenCalled();
|
||||
expect(mockLifecycle.disable).not.toHaveBeenCalled();
|
||||
}, 20_000);
|
||||
|
||||
it("allows instance admins to upgrade plugins", async () => {
|
||||
const pluginId = "11111111-1111-4111-8111-111111111111";
|
||||
mockRegistry.getById.mockResolvedValue({
|
||||
|
||||
73
server/src/__tests__/shared-telemetry-events.test.ts
Normal file
73
server/src/__tests__/shared-telemetry-events.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
trackAgentCreated,
|
||||
trackAgentFirstHeartbeat,
|
||||
trackAgentTaskCompleted,
|
||||
trackInstallCompleted,
|
||||
} from "@paperclipai/shared/telemetry";
|
||||
import type { TelemetryClient } from "@paperclipai/shared/telemetry";
|
||||
|
||||
function createClient(): TelemetryClient {
|
||||
return {
|
||||
track: vi.fn(),
|
||||
hashPrivateRef: vi.fn((value: string) => `hashed:${value}`),
|
||||
} as unknown as TelemetryClient;
|
||||
}
|
||||
|
||||
describe("shared telemetry agent events", () => {
|
||||
it("includes agent_id for agent.created", () => {
|
||||
const client = createClient();
|
||||
|
||||
trackAgentCreated(client, {
|
||||
agentRole: "engineer",
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
|
||||
expect(client.track).toHaveBeenCalledWith("agent.created", {
|
||||
agent_role: "engineer",
|
||||
agent_id: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes agent_id for agent.first_heartbeat", () => {
|
||||
const client = createClient();
|
||||
|
||||
trackAgentFirstHeartbeat(client, {
|
||||
agentRole: "coder",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
});
|
||||
|
||||
expect(client.track).toHaveBeenCalledWith("agent.first_heartbeat", {
|
||||
agent_role: "coder",
|
||||
agent_id: "22222222-2222-4222-8222-222222222222",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes agent_id for agent.task_completed", () => {
|
||||
const client = createClient();
|
||||
|
||||
trackAgentTaskCompleted(client, {
|
||||
agentRole: "qa",
|
||||
agentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(client.track).toHaveBeenCalledWith("agent.task_completed", {
|
||||
agent_role: "qa",
|
||||
agent_id: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-agent event dimensions unchanged", () => {
|
||||
const client = createClient();
|
||||
|
||||
trackInstallCompleted(client, { adapterType: "codex_local" });
|
||||
|
||||
expect(client.track).toHaveBeenCalledWith("install.completed", {
|
||||
adapter_type: "codex_local",
|
||||
});
|
||||
expect(client.track).not.toHaveBeenCalledWith(
|
||||
"install.completed",
|
||||
expect.objectContaining({ agent_id: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -206,6 +206,113 @@ describe("worktree config repair", () => {
|
||||
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
|
||||
});
|
||||
|
||||
it("does not persist transient runtime home overrides over repo-local worktree env", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-runtime-override-"));
|
||||
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const transientHome = path.join(tempRoot, "tests", "e2e", ".tmp", "multiuser-authenticated");
|
||||
const worktreeRoot = path.join(tempRoot, "PAP-989-multi-user-implementation-using-plan-from-pap-958");
|
||||
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
||||
const configPath = path.join(paperclipDir, "config.json");
|
||||
const envPath = path.join(paperclipDir, ".env");
|
||||
const instanceId = "pap-989-multi-user-implementation-using-plan-from-pap-958";
|
||||
const stableInstanceRoot = path.join(isolatedHome, "instances", instanceId);
|
||||
|
||||
await fs.mkdir(paperclipDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...buildLegacyConfig(transientHome),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(transientHome, "instances", instanceId, "db"),
|
||||
embeddedPostgresPort: 54334,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(transientHome, "instances", instanceId, "data", "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(transientHome, "instances", instanceId, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3104,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(transientHome, "instances", instanceId, "data", "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(transientHome, "instances", instanceId, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
envPath,
|
||||
[
|
||||
"# Paperclip environment variables",
|
||||
`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`,
|
||||
`PAPERCLIP_INSTANCE_ID=${JSON.stringify(instanceId)}`,
|
||||
`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`,
|
||||
`PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`,
|
||||
'PAPERCLIP_IN_WORKTREE="true"',
|
||||
'PAPERCLIP_WORKTREE_NAME="PAP-989-multi-user-implementation-using-plan-from-pap-958"',
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.chdir(worktreeRoot);
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-989-multi-user-implementation-using-plan-from-pap-958";
|
||||
process.env.PAPERCLIP_HOME = transientHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
|
||||
process.env.PAPERCLIP_CONFIG = configPath;
|
||||
|
||||
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
||||
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
const repairedEnv = await fs.readFile(envPath, "utf8");
|
||||
|
||||
expect(result).toEqual({
|
||||
repairedConfig: true,
|
||||
repairedEnv: false,
|
||||
});
|
||||
expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(stableInstanceRoot, "db"));
|
||||
expect(repairedConfig.database.backup.dir).toBe(path.join(stableInstanceRoot, "data", "backups"));
|
||||
expect(repairedConfig.logging.logDir).toBe(path.join(stableInstanceRoot, "logs"));
|
||||
expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(stableInstanceRoot, "data", "storage"));
|
||||
expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(
|
||||
path.join(stableInstanceRoot, "secrets", "master.key"),
|
||||
);
|
||||
expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`);
|
||||
expect(repairedEnv).not.toContain(`PAPERCLIP_HOME=${JSON.stringify(transientHome)}`);
|
||||
expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome);
|
||||
});
|
||||
|
||||
it("rebalances duplicate ports for already isolated worktree configs", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-rebalance-"));
|
||||
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
|
||||
@@ -38,7 +38,6 @@ export function buildInvocationEnvForLogs(
|
||||
env: Record<string, string>,
|
||||
options: BuildInvocationEnvForLogsOptions = {},
|
||||
): Record<string, string> {
|
||||
// TODO: Remove this fallback once @paperclipai/adapter-utils exports buildInvocationEnvForLogs everywhere we consume it.
|
||||
const maybeBuildInvocationEnvForLogs = (
|
||||
serverUtils as typeof serverUtils & {
|
||||
buildInvocationEnvForLogs?: (
|
||||
|
||||
@@ -28,6 +28,7 @@ import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
|
||||
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
|
||||
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
||||
import { llmRoutes } from "./routes/llms.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { assetRoutes } from "./routes/assets.js";
|
||||
import { accessRoutes } from "./routes/access.js";
|
||||
import { pluginRoutes } from "./routes/plugins.js";
|
||||
@@ -155,23 +156,7 @@ export async function createApp(
|
||||
resolveSession: opts.resolveSession,
|
||||
}),
|
||||
);
|
||||
app.get("/api/auth/get-session", (req, res) => {
|
||||
if (req.actor.type !== "board" || !req.actor.userId) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
session: {
|
||||
id: `paperclip:${req.actor.source}:${req.actor.userId}`,
|
||||
userId: req.actor.userId,
|
||||
},
|
||||
user: {
|
||||
id: req.actor.userId,
|
||||
email: null,
|
||||
name: req.actor.source === "local_implicit" ? "Local Board" : null,
|
||||
},
|
||||
});
|
||||
});
|
||||
app.use("/api/auth", authRoutes(db));
|
||||
if (opts.betterAuthHandler) {
|
||||
app.all("/api/auth/{*authPath}", opts.betterAuthHandler);
|
||||
}
|
||||
|
||||
88
server/src/lib/join-request-dedupe.ts
Normal file
88
server/src/lib/join-request-dedupe.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { joinRequests } from "@paperclipai/db";
|
||||
|
||||
type JoinRequestLike = Pick<
|
||||
typeof joinRequests.$inferSelect,
|
||||
| "id"
|
||||
| "requestType"
|
||||
| "status"
|
||||
| "requestingUserId"
|
||||
| "requestEmailSnapshot"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
>;
|
||||
|
||||
function nonEmptyTrimmed(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
export function normalizeJoinRequestEmail(
|
||||
email: string | null | undefined
|
||||
): string | null {
|
||||
const trimmed = nonEmptyTrimmed(email);
|
||||
return trimmed ? trimmed.toLowerCase() : null;
|
||||
}
|
||||
|
||||
export function humanJoinRequestIdentity(
|
||||
row: Pick<
|
||||
JoinRequestLike,
|
||||
"requestType" | "requestingUserId" | "requestEmailSnapshot"
|
||||
>
|
||||
): string | null {
|
||||
if (row.requestType !== "human") return null;
|
||||
const requestingUserId = nonEmptyTrimmed(row.requestingUserId);
|
||||
if (requestingUserId) return `user:${requestingUserId}`;
|
||||
const email = normalizeJoinRequestEmail(row.requestEmailSnapshot);
|
||||
return email ? `email:${email}` : null;
|
||||
}
|
||||
|
||||
export function findReusableHumanJoinRequest<
|
||||
T extends Pick<
|
||||
JoinRequestLike,
|
||||
"id" | "requestType" | "status" | "requestingUserId" | "requestEmailSnapshot"
|
||||
>,
|
||||
>(
|
||||
rows: T[],
|
||||
actor: { requestingUserId?: string | null; requestEmailSnapshot?: string | null }
|
||||
): T | null {
|
||||
const actorUserId = nonEmptyTrimmed(actor.requestingUserId);
|
||||
if (actorUserId) {
|
||||
const sameUser = rows.find(
|
||||
(row) =>
|
||||
row.requestType === "human" &&
|
||||
(row.status === "pending_approval" || row.status === "approved") &&
|
||||
row.requestingUserId === actorUserId
|
||||
);
|
||||
if (sameUser) return sameUser;
|
||||
}
|
||||
|
||||
const actorEmail = normalizeJoinRequestEmail(actor.requestEmailSnapshot);
|
||||
if (!actorEmail) return null;
|
||||
return (
|
||||
rows.find(
|
||||
(row) =>
|
||||
row.requestType === "human" &&
|
||||
(row.status === "pending_approval" || row.status === "approved") &&
|
||||
normalizeJoinRequestEmail(row.requestEmailSnapshot) === actorEmail
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function collapseDuplicatePendingHumanJoinRequests<
|
||||
T extends Pick<
|
||||
JoinRequestLike,
|
||||
"id" | "requestType" | "status" | "requestingUserId" | "requestEmailSnapshot"
|
||||
>,
|
||||
>(rows: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return rows.filter((row) => {
|
||||
if (row.requestType !== "human" || row.status !== "pending_approval") {
|
||||
return true;
|
||||
}
|
||||
const identity = humanJoinRequestIdentity(row);
|
||||
if (!identity) return true;
|
||||
if (seen.has(identity)) return false;
|
||||
seen.add(identity);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -23,7 +23,14 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||
return async (req, _res, next) => {
|
||||
req.actor =
|
||||
opts.deploymentMode === "local_trusted"
|
||||
? { type: "board", userId: "local-board", isInstanceAdmin: true, source: "local_implicit" }
|
||||
? {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
userName: "Local Board",
|
||||
userEmail: null,
|
||||
isInstanceAdmin: true,
|
||||
source: "local_implicit",
|
||||
}
|
||||
: { type: "none", source: "none" };
|
||||
|
||||
const runIdHeader = req.header("x-paperclip-run-id");
|
||||
@@ -49,7 +56,11 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({ companyId: companyMemberships.companyId })
|
||||
.select({
|
||||
companyId: companyMemberships.companyId,
|
||||
membershipRole: companyMemberships.membershipRole,
|
||||
status: companyMemberships.status,
|
||||
})
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
@@ -62,7 +73,10 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||
req.actor = {
|
||||
type: "board",
|
||||
userId,
|
||||
userName: session.user.name ?? null,
|
||||
userEmail: session.user.email ?? null,
|
||||
companyIds: memberships.map((row) => row.companyId),
|
||||
memberships,
|
||||
isInstanceAdmin: Boolean(roleRow),
|
||||
runId: runIdHeader ?? undefined,
|
||||
source: "session",
|
||||
@@ -90,7 +104,10 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||
req.actor = {
|
||||
type: "board",
|
||||
userId: boardKey.userId,
|
||||
userName: access.user?.name ?? null,
|
||||
userEmail: access.user?.email ?? null,
|
||||
companyIds: access.companyIds,
|
||||
memberships: access.memberships,
|
||||
isInstanceAdmin: access.isInstanceAdmin,
|
||||
keyId: boardKey.id,
|
||||
runId: runIdHeader || undefined,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js";
|
||||
import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js";
|
||||
import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { assertBoard } from "./authz.js";
|
||||
import { assertBoardOrgAccess } from "./authz.js";
|
||||
import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -192,7 +192,7 @@ export function adapterRoutes() {
|
||||
* its model count, and load status.
|
||||
*/
|
||||
router.get("/adapters", async (_req, res) => {
|
||||
assertBoard(_req);
|
||||
assertBoardOrgAccess(_req);
|
||||
|
||||
const registeredAdapters = listServerAdapters();
|
||||
const externalRecords = new Map(
|
||||
@@ -218,7 +218,7 @@ export function adapterRoutes() {
|
||||
* - version?: string — target version for npm packages
|
||||
*/
|
||||
router.post("/adapters/install", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
|
||||
const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest;
|
||||
|
||||
@@ -350,7 +350,7 @@ export function adapterRoutes() {
|
||||
* Request body: { "disabled": boolean }
|
||||
*/
|
||||
router.patch("/adapters/:type", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
|
||||
const adapterType = req.params.type;
|
||||
const { disabled } = req.body as { disabled?: boolean };
|
||||
@@ -385,7 +385,7 @@ export function adapterRoutes() {
|
||||
* keep the adapter they started with.
|
||||
*/
|
||||
router.patch("/adapters/:type/override", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
|
||||
const adapterType = req.params.type;
|
||||
const { paused } = req.body as { paused?: boolean };
|
||||
@@ -413,7 +413,7 @@ export function adapterRoutes() {
|
||||
* Unregister an external adapter. Built-in adapters cannot be removed.
|
||||
*/
|
||||
router.delete("/adapters/:type", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
|
||||
const adapterType = req.params.type;
|
||||
|
||||
@@ -488,7 +488,7 @@ export function adapterRoutes() {
|
||||
* Cannot be used on built-in adapter types.
|
||||
*/
|
||||
router.post("/adapters/:type/reload", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
|
||||
const type = req.params.type;
|
||||
|
||||
@@ -540,7 +540,7 @@ export function adapterRoutes() {
|
||||
// This is a convenience shortcut for remove + install with the same
|
||||
// package name, but without the risk of losing the store record.
|
||||
router.post("/adapters/:type/reinstall", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
|
||||
const type = req.params.type;
|
||||
|
||||
@@ -613,7 +613,7 @@ export function adapterRoutes() {
|
||||
const CONFIG_SCHEMA_TTL_MS = 30_000;
|
||||
|
||||
router.get("/adapters/:type/config-schema", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
const { type } = req.params;
|
||||
|
||||
const adapter = findActiveServerAdapter(type);
|
||||
@@ -651,7 +651,7 @@ export function adapterRoutes() {
|
||||
// The adapter package must export a "./ui-parser" entry in package.json
|
||||
// pointing to a self-contained ESM module with zero runtime dependencies.
|
||||
router.get("/adapters/:type/ui-parser.js", (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
const { type } = req.params;
|
||||
const source = getOrExtractUiParserSource(type);
|
||||
if (!source) {
|
||||
|
||||
100
server/src/routes/auth.ts
Normal file
100
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { authUsers } from "@paperclipai/db";
|
||||
import {
|
||||
authSessionSchema,
|
||||
currentUserProfileSchema,
|
||||
updateCurrentUserProfileSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { unauthorized } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
|
||||
async function loadCurrentUserProfile(db: Db, userId: string) {
|
||||
const user = await db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
name: authUsers.name,
|
||||
image: authUsers.image,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, userId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!user) {
|
||||
throw unauthorized("Signed-in user not found");
|
||||
}
|
||||
|
||||
return currentUserProfileSchema.parse({
|
||||
id: user.id,
|
||||
email: user.email ?? null,
|
||||
name: user.name ?? null,
|
||||
image: user.image ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function authRoutes(db: Db) {
|
||||
const router = Router();
|
||||
|
||||
router.get("/get-session", async (req, res) => {
|
||||
if (req.actor.type !== "board" || !req.actor.userId) {
|
||||
throw unauthorized("Board authentication required");
|
||||
}
|
||||
|
||||
const user = await loadCurrentUserProfile(db, req.actor.userId);
|
||||
res.json(authSessionSchema.parse({
|
||||
session: {
|
||||
id: `paperclip:${req.actor.source ?? "none"}:${req.actor.userId}`,
|
||||
userId: req.actor.userId,
|
||||
},
|
||||
user,
|
||||
}));
|
||||
});
|
||||
|
||||
router.get("/profile", async (req, res) => {
|
||||
if (req.actor.type !== "board" || !req.actor.userId) {
|
||||
throw unauthorized("Board authentication required");
|
||||
}
|
||||
|
||||
res.json(await loadCurrentUserProfile(db, req.actor.userId));
|
||||
});
|
||||
|
||||
router.patch("/profile", validate(updateCurrentUserProfileSchema), async (req, res) => {
|
||||
if (req.actor.type !== "board" || !req.actor.userId) {
|
||||
throw unauthorized("Board authentication required");
|
||||
}
|
||||
|
||||
const patch = updateCurrentUserProfileSchema.parse(req.body);
|
||||
const now = new Date();
|
||||
|
||||
const updated = await db
|
||||
.update(authUsers)
|
||||
.set({
|
||||
name: patch.name,
|
||||
...(patch.image !== undefined ? { image: patch.image } : {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(authUsers.id, req.actor.userId))
|
||||
.returning({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
name: authUsers.name,
|
||||
image: authUsers.image,
|
||||
})
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!updated) {
|
||||
throw unauthorized("Signed-in user not found");
|
||||
}
|
||||
|
||||
res.json(currentUserProfileSchema.parse({
|
||||
id: updated.id,
|
||||
email: updated.email ?? null,
|
||||
name: updated.name ?? null,
|
||||
image: updated.image ?? null,
|
||||
}));
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -13,6 +13,24 @@ export function assertBoard(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
export function hasBoardOrgAccess(req: Request) {
|
||||
if (req.actor.type !== "board") {
|
||||
return false;
|
||||
}
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
|
||||
return true;
|
||||
}
|
||||
return Array.isArray(req.actor.companyIds) && req.actor.companyIds.length > 0;
|
||||
}
|
||||
|
||||
export function assertBoardOrgAccess(req: Request) {
|
||||
assertBoard(req);
|
||||
if (hasBoardOrgAccess(req)) {
|
||||
return;
|
||||
}
|
||||
throw forbidden("Company membership or instance admin access required");
|
||||
}
|
||||
|
||||
export function assertInstanceAdmin(req: Request) {
|
||||
assertBoard(req);
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
|
||||
@@ -26,11 +44,22 @@ export function assertCompanyAccess(req: Request, companyId: string) {
|
||||
if (req.actor.type === "agent" && req.actor.companyId !== companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
if (req.actor.type === "board" && req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) {
|
||||
if (req.actor.type === "board" && req.actor.source !== "local_implicit") {
|
||||
const allowedCompanies = req.actor.companyIds ?? [];
|
||||
if (!allowedCompanies.includes(companyId)) {
|
||||
throw forbidden("User does not have access to this company");
|
||||
}
|
||||
const method = typeof req.method === "string" ? req.method.toUpperCase() : "GET";
|
||||
const isSafeMethod = ["GET", "HEAD", "OPTIONS"].includes(method);
|
||||
if (!isSafeMethod && !req.actor.isInstanceAdmin && Array.isArray(req.actor.memberships)) {
|
||||
const membership = req.actor.memberships.find((item) => item.companyId === companyId);
|
||||
if (!membership || membership.status !== "active") {
|
||||
throw forbidden("User does not have active company access");
|
||||
}
|
||||
if (membership.membershipRole === "viewer") {
|
||||
throw forbidden("Viewer access is read-only");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db";
|
||||
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
|
||||
import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { serverVersion } from "../version.js";
|
||||
|
||||
@@ -49,11 +50,12 @@ export function healthRoutes(
|
||||
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, "Health check database probe failed");
|
||||
res.status(503).json({
|
||||
status: "unhealthy",
|
||||
version: serverVersion,
|
||||
error: "database_unreachable",
|
||||
error: "database_unreachable"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { patchInstanceExperimentalSettingsSchema, patchInstanceGeneralSettingsSc
|
||||
import { forbidden } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { instanceSettingsService, logActivity } from "../services/index.js";
|
||||
import { getActorInfo } from "./authz.js";
|
||||
import { assertBoardOrgAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
function assertCanManageInstanceSettings(req: Request) {
|
||||
if (req.actor.type !== "board") {
|
||||
@@ -22,10 +22,8 @@ export function instanceSettingsRoutes(db: Db) {
|
||||
|
||||
router.get("/instance/settings/general", async (req, res) => {
|
||||
// General settings (e.g. keyboardShortcuts) are readable by any
|
||||
// authenticated board user. Only PATCH requires instance-admin.
|
||||
if (req.actor.type !== "board") {
|
||||
throw forbidden("Board access required");
|
||||
}
|
||||
// authenticated org member or instance admin. Only PATCH requires instance-admin.
|
||||
assertBoardOrgAccess(req);
|
||||
res.json(await svc.getGeneral());
|
||||
});
|
||||
|
||||
@@ -60,11 +58,9 @@ export function instanceSettingsRoutes(db: Db) {
|
||||
);
|
||||
|
||||
router.get("/instance/settings/experimental", async (req, res) => {
|
||||
// Experimental settings are readable by any authenticated board user.
|
||||
// Only PATCH requires instance-admin.
|
||||
if (req.actor.type !== "board") {
|
||||
throw forbidden("Board access required");
|
||||
}
|
||||
// Experimental settings are readable by any authenticated org member
|
||||
// or instance admin. Only PATCH requires instance-admin.
|
||||
assertBoardOrgAccess(req);
|
||||
res.json(await svc.getExperimental());
|
||||
});
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ import type { PluginStreamBus } from "../services/plugin-stream-bus.js";
|
||||
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
|
||||
import type { ToolRunContext } from "@paperclipai/plugin-sdk";
|
||||
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
|
||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
||||
import { assertBoardOrgAccess, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
||||
import { validateInstanceConfig } from "../services/plugin-config-validator.js";
|
||||
|
||||
/** UI slot declaration extracted from plugin manifest */
|
||||
@@ -372,7 +372,7 @@ export function pluginRoutes(
|
||||
* Response: `PluginRecord[]`
|
||||
*/
|
||||
router.get("/plugins", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
const rawStatus = req.query.status;
|
||||
if (rawStatus !== undefined) {
|
||||
if (typeof rawStatus !== "string" || !(PLUGIN_STATUSES as readonly string[]).includes(rawStatus)) {
|
||||
@@ -396,7 +396,7 @@ export function pluginRoutes(
|
||||
* These can be installed through the normal local-path install flow.
|
||||
*/
|
||||
router.get("/plugins/examples", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
res.json(listBundledPluginExamples());
|
||||
});
|
||||
|
||||
@@ -441,7 +441,7 @@ export function pluginRoutes(
|
||||
* Response: PluginUiContribution[]
|
||||
*/
|
||||
router.get("/plugins/ui-contributions", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
const plugins = await registry.listByStatus("ready");
|
||||
|
||||
const contributions: PluginUiContribution[] = plugins
|
||||
@@ -484,7 +484,7 @@ export function pluginRoutes(
|
||||
* Errors: 501 if tool dispatcher is not configured
|
||||
*/
|
||||
router.get("/plugins/tools", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
|
||||
if (!toolDeps) {
|
||||
res.status(501).json({ error: "Plugin tool dispatch is not enabled" });
|
||||
@@ -518,7 +518,7 @@ export function pluginRoutes(
|
||||
* - 502 if the plugin worker is unavailable or the RPC call fails
|
||||
*/
|
||||
router.post("/plugins/tools/execute", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
|
||||
if (!toolDeps) {
|
||||
res.status(501).json({ error: "Plugin tool dispatch is not enabled" });
|
||||
@@ -797,7 +797,7 @@ export function pluginRoutes(
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
router.post("/plugins/:pluginId/bridge/data", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
|
||||
if (!bridgeDeps) {
|
||||
res.status(501).json({ error: "Plugin bridge is not enabled" });
|
||||
@@ -880,7 +880,7 @@ export function pluginRoutes(
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
router.post("/plugins/:pluginId/bridge/action", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
|
||||
if (!bridgeDeps) {
|
||||
res.status(501).json({ error: "Plugin bridge is not enabled" });
|
||||
@@ -964,7 +964,7 @@ export function pluginRoutes(
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
router.post("/plugins/:pluginId/data/:key", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
|
||||
if (!bridgeDeps) {
|
||||
res.status(501).json({ error: "Plugin bridge is not enabled" });
|
||||
@@ -1043,7 +1043,7 @@ export function pluginRoutes(
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
router.post("/plugins/:pluginId/actions/:key", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
|
||||
if (!bridgeDeps) {
|
||||
res.status(501).json({ error: "Plugin bridge is not enabled" });
|
||||
@@ -1124,7 +1124,7 @@ export function pluginRoutes(
|
||||
* - 501 if bridge deps or stream bus are not configured
|
||||
*/
|
||||
router.get("/plugins/:pluginId/bridge/stream/:channel", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
|
||||
if (!bridgeDeps?.streamBus) {
|
||||
res.status(501).json({ error: "Plugin stream bridge is not enabled" });
|
||||
@@ -1202,7 +1202,7 @@ export function pluginRoutes(
|
||||
* Errors: 404 if plugin not found
|
||||
*/
|
||||
router.get("/plugins/:pluginId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId } = req.params;
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
if (!plugin) {
|
||||
@@ -1232,7 +1232,7 @@ export function pluginRoutes(
|
||||
* Errors: 404 if plugin not found, 400 for lifecycle errors
|
||||
*/
|
||||
router.delete("/plugins/:pluginId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertInstanceAdmin(req);
|
||||
const { pluginId } = req.params;
|
||||
const purge = req.query.purge === "true";
|
||||
|
||||
@@ -1268,7 +1268,7 @@ export function pluginRoutes(
|
||||
* Errors: 404 if plugin not found, 400 for lifecycle errors
|
||||
*/
|
||||
router.post("/plugins/:pluginId/enable", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertInstanceAdmin(req);
|
||||
const { pluginId } = req.params;
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
@@ -1306,7 +1306,7 @@ export function pluginRoutes(
|
||||
* Errors: 404 if plugin not found, 400 for lifecycle errors
|
||||
*/
|
||||
router.post("/plugins/:pluginId/disable", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertInstanceAdmin(req);
|
||||
const { pluginId } = req.params;
|
||||
const body = req.body as { reason?: string } | undefined;
|
||||
const reason = body?.reason;
|
||||
@@ -1347,7 +1347,7 @@ export function pluginRoutes(
|
||||
* Errors: 404 if plugin not found
|
||||
*/
|
||||
router.get("/plugins/:pluginId/health", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId } = req.params;
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
@@ -1415,7 +1415,7 @@ export function pluginRoutes(
|
||||
* Response: Array of log entries, newest first.
|
||||
*/
|
||||
router.get("/plugins/:pluginId/logs", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId } = req.params;
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
@@ -1517,7 +1517,7 @@ export function pluginRoutes(
|
||||
* Errors: 404 if plugin not found
|
||||
*/
|
||||
router.get("/plugins/:pluginId/config", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId } = req.params;
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
@@ -1547,7 +1547,7 @@ export function pluginRoutes(
|
||||
* - 404 if plugin not found
|
||||
*/
|
||||
router.post("/plugins/:pluginId/config", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertInstanceAdmin(req);
|
||||
const { pluginId } = req.params;
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
@@ -1652,7 +1652,7 @@ export function pluginRoutes(
|
||||
* - 502 if the worker is unavailable
|
||||
*/
|
||||
router.post("/plugins/:pluginId/config/test", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
|
||||
if (!bridgeDeps) {
|
||||
res.status(501).json({ error: "Plugin bridge is not enabled" });
|
||||
@@ -1749,7 +1749,7 @@ export function pluginRoutes(
|
||||
* Errors: 404 if plugin not found
|
||||
*/
|
||||
router.get("/plugins/:pluginId/jobs", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
if (!jobDeps) {
|
||||
res.status(501).json({ error: "Job scheduling is not enabled" });
|
||||
return;
|
||||
@@ -1795,7 +1795,7 @@ export function pluginRoutes(
|
||||
* Errors: 404 if plugin not found
|
||||
*/
|
||||
router.get("/plugins/:pluginId/jobs/:jobId/runs", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
if (!jobDeps) {
|
||||
res.status(501).json({ error: "Job scheduling is not enabled" });
|
||||
return;
|
||||
@@ -1843,7 +1843,7 @@ export function pluginRoutes(
|
||||
* - 400 if job not found, not active, already running, or worker unavailable
|
||||
*/
|
||||
router.post("/plugins/:pluginId/jobs/:jobId/trigger", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
if (!jobDeps) {
|
||||
res.status(501).json({ error: "Job scheduling is not enabled" });
|
||||
return;
|
||||
@@ -2049,7 +2049,7 @@ export function pluginRoutes(
|
||||
* Errors: 404 if plugin not found
|
||||
*/
|
||||
router.get("/plugins/:pluginId/dashboard", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId } = req.params;
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { inboxDismissals, joinRequests } from "@paperclipai/db";
|
||||
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { dashboardService } from "../services/dashboard.js";
|
||||
import { collapseDuplicatePendingHumanJoinRequests } from "../lib/join-request-dedupe.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
|
||||
function buildDismissedAtByKey(
|
||||
@@ -35,14 +36,24 @@ export function sidebarBadgeRoutes(db: Db) {
|
||||
}
|
||||
|
||||
const visibleJoinRequests = canApproveJoins
|
||||
? await db
|
||||
.select({
|
||||
id: joinRequests.id,
|
||||
updatedAt: joinRequests.updatedAt,
|
||||
createdAt: joinRequests.createdAt,
|
||||
})
|
||||
.from(joinRequests)
|
||||
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval")))
|
||||
? collapseDuplicatePendingHumanJoinRequests(
|
||||
await db
|
||||
.select({
|
||||
id: joinRequests.id,
|
||||
requestType: joinRequests.requestType,
|
||||
status: joinRequests.status,
|
||||
requestingUserId: joinRequests.requestingUserId,
|
||||
requestEmailSnapshot: joinRequests.requestEmailSnapshot,
|
||||
updatedAt: joinRequests.updatedAt,
|
||||
createdAt: joinRequests.createdAt,
|
||||
})
|
||||
.from(joinRequests)
|
||||
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval")))
|
||||
).map(({ id, updatedAt, createdAt }) => ({
|
||||
id,
|
||||
updatedAt,
|
||||
createdAt,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const dismissedAtByKey =
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
principalPermissionGrants,
|
||||
} from "@paperclipai/db";
|
||||
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||
import { conflict } from "../errors.js";
|
||||
|
||||
type MembershipRow = typeof companyMemberships.$inferSelect;
|
||||
type GrantInput = {
|
||||
@@ -83,6 +84,14 @@ export function accessService(db: Db) {
|
||||
.orderBy(sql`${companyMemberships.createdAt} desc`);
|
||||
}
|
||||
|
||||
async function getMemberById(companyId: string, memberId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function listActiveUserMemberships(companyId: string) {
|
||||
return db
|
||||
.select()
|
||||
@@ -103,11 +112,7 @@ export function accessService(db: Db) {
|
||||
grants: GrantInput[],
|
||||
grantedByUserId: string | null,
|
||||
) {
|
||||
const member = await db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const member = await getMemberById(companyId, memberId);
|
||||
if (!member) return null;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
@@ -139,6 +144,101 @@ export function accessService(db: Db) {
|
||||
return member;
|
||||
}
|
||||
|
||||
async function updateMemberAndPermissions(
|
||||
companyId: string,
|
||||
memberId: string,
|
||||
data: {
|
||||
membershipRole?: string | null;
|
||||
status?: "pending" | "active" | "suspended";
|
||||
grants: GrantInput[];
|
||||
},
|
||||
grantedByUserId: string | null,
|
||||
) {
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
select ${companyMemberships.id}
|
||||
from ${companyMemberships}
|
||||
where ${companyMemberships.companyId} = ${companyId}
|
||||
and ${companyMemberships.principalType} = 'user'
|
||||
and ${companyMemberships.status} = 'active'
|
||||
and ${companyMemberships.membershipRole} = 'owner'
|
||||
for update
|
||||
`);
|
||||
|
||||
const existing = await tx
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
const nextMembershipRole =
|
||||
data.membershipRole !== undefined ? data.membershipRole : existing.membershipRole;
|
||||
const nextStatus = data.status ?? existing.status;
|
||||
|
||||
if (
|
||||
existing.principalType === "user" &&
|
||||
existing.status === "active" &&
|
||||
existing.membershipRole === "owner" &&
|
||||
(nextStatus !== "active" || nextMembershipRole !== "owner")
|
||||
) {
|
||||
const activeOwnerCount = await tx
|
||||
.select({ id: companyMemberships.id })
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.status, "active"),
|
||||
eq(companyMemberships.membershipRole, "owner"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.length);
|
||||
if (activeOwnerCount <= 1) {
|
||||
throw conflict("Cannot remove the last active owner");
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updated = await tx
|
||||
.update(companyMemberships)
|
||||
.set({
|
||||
membershipRole: nextMembershipRole,
|
||||
status: nextStatus,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(companyMemberships.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? existing);
|
||||
|
||||
await tx
|
||||
.delete(principalPermissionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(principalPermissionGrants.companyId, companyId),
|
||||
eq(principalPermissionGrants.principalType, existing.principalType),
|
||||
eq(principalPermissionGrants.principalId, existing.principalId),
|
||||
),
|
||||
);
|
||||
if (data.grants.length > 0) {
|
||||
await tx.insert(principalPermissionGrants).values(
|
||||
data.grants.map((grant) => ({
|
||||
companyId,
|
||||
principalType: existing.principalType,
|
||||
principalId: existing.principalId,
|
||||
permissionKey: grant.permissionKey,
|
||||
scope: grant.scope ?? null,
|
||||
grantedByUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async function promoteInstanceAdmin(userId: string) {
|
||||
const existing = await db
|
||||
.select()
|
||||
@@ -190,7 +290,7 @@ export function accessService(db: Db) {
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
membershipRole: "operator",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -359,16 +459,84 @@ export function accessService(db: Db) {
|
||||
});
|
||||
}
|
||||
|
||||
async function updateMember(
|
||||
companyId: string,
|
||||
memberId: string,
|
||||
data: {
|
||||
membershipRole?: string | null;
|
||||
status?: "pending" | "active" | "suspended";
|
||||
},
|
||||
) {
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
select ${companyMemberships.id}
|
||||
from ${companyMemberships}
|
||||
where ${companyMemberships.companyId} = ${companyId}
|
||||
and ${companyMemberships.principalType} = 'user'
|
||||
and ${companyMemberships.status} = 'active'
|
||||
and ${companyMemberships.membershipRole} = 'owner'
|
||||
for update
|
||||
`);
|
||||
|
||||
const existing = await tx
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
const nextMembershipRole =
|
||||
data.membershipRole !== undefined ? data.membershipRole : existing.membershipRole;
|
||||
const nextStatus = data.status ?? existing.status;
|
||||
|
||||
if (
|
||||
existing.principalType === "user" &&
|
||||
existing.status === "active" &&
|
||||
existing.membershipRole === "owner" &&
|
||||
(nextStatus !== "active" || nextMembershipRole !== "owner")
|
||||
) {
|
||||
const activeOwnerCount = await tx
|
||||
.select({ id: companyMemberships.id })
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.status, "active"),
|
||||
eq(companyMemberships.membershipRole, "owner"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.length);
|
||||
if (activeOwnerCount <= 1) {
|
||||
throw conflict("Cannot remove the last active owner");
|
||||
}
|
||||
}
|
||||
|
||||
return tx
|
||||
.update(companyMemberships)
|
||||
.set({
|
||||
membershipRole: nextMembershipRole,
|
||||
status: nextStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companyMemberships.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? existing);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isInstanceAdmin,
|
||||
canUser,
|
||||
hasPermission,
|
||||
getMembership,
|
||||
getMemberById,
|
||||
ensureMembership,
|
||||
listMembers,
|
||||
listActiveUserMemberships,
|
||||
copyActiveUserMemberships,
|
||||
setMemberPermissions,
|
||||
updateMemberAndPermissions,
|
||||
promoteInstanceAdmin,
|
||||
demoteInstanceAdmin,
|
||||
listUserCompanyAccess,
|
||||
@@ -376,5 +544,6 @@ export function accessService(db: Db) {
|
||||
setPrincipalGrants,
|
||||
listPrincipalGrants,
|
||||
setPrincipalPermission,
|
||||
updateMember,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +62,11 @@ export function boardAuthService(db: Db) {
|
||||
.where(eq(authUsers.id, userId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({ companyId: companyMemberships.companyId })
|
||||
.select({
|
||||
companyId: companyMemberships.companyId,
|
||||
membershipRole: companyMemberships.membershipRole,
|
||||
status: companyMemberships.status,
|
||||
})
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
@@ -71,7 +75,7 @@ export function boardAuthService(db: Db) {
|
||||
eq(companyMemberships.status, "active"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.map((row) => row.companyId)),
|
||||
.then((rows) => rows),
|
||||
db
|
||||
.select({ id: instanceUserRoles.id })
|
||||
.from(instanceUserRoles)
|
||||
@@ -81,7 +85,8 @@ export function boardAuthService(db: Db) {
|
||||
|
||||
return {
|
||||
user,
|
||||
companyIds: memberships,
|
||||
companyIds: memberships.map((row) => row.companyId),
|
||||
memberships,
|
||||
isInstanceAdmin: Boolean(adminRole),
|
||||
};
|
||||
}
|
||||
|
||||
59
server/src/services/company-member-roles.ts
Normal file
59
server/src/services/company-member-roles.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { PERMISSION_KEYS } from "@paperclipai/shared";
|
||||
import type { HumanCompanyMembershipRole } from "@paperclipai/shared";
|
||||
|
||||
const HUMAN_COMPANY_MEMBERSHIP_ROLES: HumanCompanyMembershipRole[] = [
|
||||
"owner",
|
||||
"admin",
|
||||
"operator",
|
||||
"viewer",
|
||||
];
|
||||
|
||||
export function normalizeHumanRole(
|
||||
value: unknown,
|
||||
fallback: HumanCompanyMembershipRole = "operator"
|
||||
): HumanCompanyMembershipRole {
|
||||
if (value === "member") return "operator";
|
||||
return HUMAN_COMPANY_MEMBERSHIP_ROLES.includes(value as HumanCompanyMembershipRole)
|
||||
? (value as HumanCompanyMembershipRole)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function grantsForHumanRole(
|
||||
role: HumanCompanyMembershipRole
|
||||
): Array<{
|
||||
permissionKey: (typeof PERMISSION_KEYS)[number];
|
||||
scope: Record<string, unknown> | null;
|
||||
}> {
|
||||
switch (role) {
|
||||
case "owner":
|
||||
return [
|
||||
{ permissionKey: "agents:create", scope: null },
|
||||
{ permissionKey: "users:invite", scope: null },
|
||||
{ permissionKey: "users:manage_permissions", scope: null },
|
||||
{ permissionKey: "tasks:assign", scope: null },
|
||||
{ permissionKey: "joins:approve", scope: null },
|
||||
];
|
||||
case "admin":
|
||||
return [
|
||||
{ permissionKey: "agents:create", scope: null },
|
||||
{ permissionKey: "users:invite", scope: null },
|
||||
{ permissionKey: "tasks:assign", scope: null },
|
||||
{ permissionKey: "joins:approve", scope: null },
|
||||
];
|
||||
case "operator":
|
||||
return [{ permissionKey: "tasks:assign", scope: null }];
|
||||
case "viewer":
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveHumanInviteRole(
|
||||
defaultsPayload: Record<string, unknown> | null | undefined
|
||||
): HumanCompanyMembershipRole {
|
||||
if (!defaultsPayload || typeof defaultsPayload !== "object") return "operator";
|
||||
const scoped = defaultsPayload.human;
|
||||
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
||||
return "operator";
|
||||
}
|
||||
return normalizeHumanRole((scoped as Record<string, unknown>).role, "operator");
|
||||
}
|
||||
68
server/src/services/invite-grants.ts
Normal file
68
server/src/services/invite-grants.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { PERMISSION_KEYS } from "@paperclipai/shared";
|
||||
import type { HumanCompanyMembershipRole } from "@paperclipai/shared";
|
||||
import { grantsForHumanRole } from "./company-member-roles.js";
|
||||
|
||||
export function grantsFromDefaults(
|
||||
defaultsPayload: Record<string, unknown> | null | undefined,
|
||||
key: "human" | "agent"
|
||||
): Array<{
|
||||
permissionKey: (typeof PERMISSION_KEYS)[number];
|
||||
scope: Record<string, unknown> | null;
|
||||
}> {
|
||||
if (!defaultsPayload || typeof defaultsPayload !== "object") return [];
|
||||
const scoped = defaultsPayload[key];
|
||||
if (!scoped || typeof scoped !== "object") return [];
|
||||
const grants = (scoped as Record<string, unknown>).grants;
|
||||
if (!Array.isArray(grants)) return [];
|
||||
const validPermissionKeys = new Set<string>(PERMISSION_KEYS);
|
||||
const result: Array<{
|
||||
permissionKey: (typeof PERMISSION_KEYS)[number];
|
||||
scope: Record<string, unknown> | null;
|
||||
}> = [];
|
||||
for (const item of grants) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.permissionKey !== "string") continue;
|
||||
if (!validPermissionKeys.has(record.permissionKey)) continue;
|
||||
result.push({
|
||||
permissionKey: record.permissionKey as (typeof PERMISSION_KEYS)[number],
|
||||
scope:
|
||||
record.scope &&
|
||||
typeof record.scope === "object" &&
|
||||
!Array.isArray(record.scope)
|
||||
? (record.scope as Record<string, unknown>)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function agentJoinGrantsFromDefaults(
|
||||
defaultsPayload: Record<string, unknown> | null | undefined
|
||||
): Array<{
|
||||
permissionKey: (typeof PERMISSION_KEYS)[number];
|
||||
scope: Record<string, unknown> | null;
|
||||
}> {
|
||||
const grants = grantsFromDefaults(defaultsPayload, "agent");
|
||||
if (grants.some((grant) => grant.permissionKey === "tasks:assign")) {
|
||||
return grants;
|
||||
}
|
||||
return [
|
||||
...grants,
|
||||
{
|
||||
permissionKey: "tasks:assign",
|
||||
scope: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function humanJoinGrantsFromDefaults(
|
||||
defaultsPayload: Record<string, unknown> | null | undefined,
|
||||
membershipRole: HumanCompanyMembershipRole
|
||||
): Array<{
|
||||
permissionKey: (typeof PERMISSION_KEYS)[number];
|
||||
scope: Record<string, unknown> | null;
|
||||
}> {
|
||||
const grants = grantsFromDefaults(defaultsPayload, "human");
|
||||
return grants.length > 0 ? grants : grantsForHumanRole(membershipRole);
|
||||
}
|
||||
7
server/src/types/express.d.ts
vendored
7
server/src/types/express.d.ts
vendored
@@ -6,9 +6,16 @@ declare global {
|
||||
actor: {
|
||||
type: "board" | "agent" | "none";
|
||||
userId?: string;
|
||||
userName?: string | null;
|
||||
userEmail?: string | null;
|
||||
agentId?: string;
|
||||
companyId?: string;
|
||||
companyIds?: string[];
|
||||
memberships?: Array<{
|
||||
companyId: string;
|
||||
membershipRole?: string | null;
|
||||
status?: string;
|
||||
}>;
|
||||
isInstanceAdmin?: boolean;
|
||||
keyId?: string;
|
||||
runId?: string;
|
||||
|
||||
@@ -118,11 +118,19 @@ function resolveWorktreeRuntimeContext(
|
||||
|
||||
const configPath = resolvePaperclipConfigPath(overrideConfigPath);
|
||||
const envPath = resolvePaperclipEnvPath(configPath);
|
||||
const persistedEnv = readEnvEntries(envPath);
|
||||
const worktreeRoot = path.resolve(path.dirname(configPath), "..");
|
||||
const worktreeName = nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? path.basename(worktreeRoot);
|
||||
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? sanitizeWorktreeInstanceId(worktreeName);
|
||||
const worktreeName =
|
||||
nonEmpty(persistedEnv.PAPERCLIP_WORKTREE_NAME) ??
|
||||
nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ??
|
||||
path.basename(worktreeRoot);
|
||||
const instanceId =
|
||||
nonEmpty(persistedEnv.PAPERCLIP_INSTANCE_ID) ??
|
||||
nonEmpty(env.PAPERCLIP_INSTANCE_ID) ??
|
||||
sanitizeWorktreeInstanceId(worktreeName);
|
||||
const homeDir = resolveHomeAwarePath(
|
||||
nonEmpty(env.PAPERCLIP_HOME) ??
|
||||
nonEmpty(persistedEnv.PAPERCLIP_HOME) ??
|
||||
nonEmpty(env.PAPERCLIP_HOME) ??
|
||||
nonEmpty(env.PAPERCLIP_WORKTREES_DIR) ??
|
||||
"~/.paperclip-worktrees",
|
||||
);
|
||||
|
||||
326
tests/e2e/multi-user-authenticated.spec.ts
Normal file
326
tests/e2e/multi-user-authenticated.spec.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
|
||||
const BASE = process.env.PAPERCLIP_E2E_BASE_URL ?? "http://127.0.0.1:3105";
|
||||
const DATA_DIR = process.env.PAPERCLIP_E2E_DATA_DIR ?? process.env.PAPERCLIP_HOME;
|
||||
const CONFIG_PATH = process.env.PAPERCLIP_E2E_CONFIG_PATH ?? path.resolve(process.cwd(), ".paperclip/config.json");
|
||||
const BOOTSTRAP_SCRIPT_PATH = path.resolve(process.cwd(), "packages/db/scripts/create-auth-bootstrap-invite.ts");
|
||||
const OWNER_PASSWORD = "paperclip-owner-password";
|
||||
const INVITED_PASSWORD = "paperclip-invited-password";
|
||||
|
||||
type HumanUser = {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type CompanySummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
issuePrefix?: string | null;
|
||||
};
|
||||
|
||||
type CompanyMember = {
|
||||
id: string;
|
||||
membershipRole: "owner" | "admin" | "operator" | "viewer";
|
||||
status: "pending" | "active" | "suspended";
|
||||
user: { id: string; email: string | null; name: string | null } | null;
|
||||
};
|
||||
|
||||
type SessionJsonResponse<T> = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
text: string;
|
||||
json: T | null;
|
||||
};
|
||||
|
||||
const runId = Date.now();
|
||||
const companyName = `MU-Auth-${runId}`;
|
||||
const ownerUser: HumanUser = {
|
||||
name: "Owner User",
|
||||
email: `owner-${runId}@paperclip.local`,
|
||||
password: OWNER_PASSWORD,
|
||||
};
|
||||
const invitedUser: HumanUser = {
|
||||
name: "Invited User",
|
||||
email: `invitee-${runId}@paperclip.local`,
|
||||
password: INVITED_PASSWORD,
|
||||
};
|
||||
|
||||
function createBootstrapInvite() {
|
||||
if (!DATA_DIR) {
|
||||
throw new Error("PAPERCLIP_E2E_DATA_DIR or PAPERCLIP_HOME is required for authenticated bootstrap tests");
|
||||
}
|
||||
if (!existsSync(CONFIG_PATH)) {
|
||||
throw new Error(`Authenticated bootstrap config not found at ${CONFIG_PATH}`);
|
||||
}
|
||||
if (!existsSync(BOOTSTRAP_SCRIPT_PATH)) {
|
||||
throw new Error(`Authenticated bootstrap helper not found at ${BOOTSTRAP_SCRIPT_PATH}`);
|
||||
}
|
||||
|
||||
const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
return execFileSync(
|
||||
pnpmCommand,
|
||||
[
|
||||
"--filter",
|
||||
"@paperclipai/db",
|
||||
"exec",
|
||||
"tsx",
|
||||
BOOTSTRAP_SCRIPT_PATH,
|
||||
"--config",
|
||||
CONFIG_PATH,
|
||||
"--base-url",
|
||||
BASE,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
FORCE_COLOR: "0",
|
||||
NO_COLOR: "1",
|
||||
PAPERCLIP_HOME: DATA_DIR,
|
||||
},
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}
|
||||
).trim();
|
||||
}
|
||||
|
||||
async function signUp(page: Page, user: HumanUser) {
|
||||
await page.goto(`${BASE}/auth`);
|
||||
await expect(page.getByRole("heading", { name: "Sign in to Paperclip" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Create one" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Create your Paperclip account" })).toBeVisible();
|
||||
await page.getByLabel("Name").fill(user.name);
|
||||
await page.getByLabel("Email").fill(user.email);
|
||||
await page.getByLabel("Password").fill(user.password);
|
||||
await page.getByRole("button", { name: "Create Account" }).click();
|
||||
await expect(page).not.toHaveURL(/\/auth/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function acceptBootstrapInvite(page: Page, inviteUrl: string) {
|
||||
await page.goto(inviteUrl);
|
||||
await expect(page.getByRole("heading", { name: "Bootstrap your Paperclip instance" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Accept bootstrap invite" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Bootstrap complete" })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await page.getByRole("link", { name: "Open board" }).click();
|
||||
}
|
||||
|
||||
async function createCompanyForSession(page: Page, nextCompanyName: string) {
|
||||
const createRes = await sessionJsonRequest<CompanySummary>(page, `${BASE}/api/companies`, {
|
||||
method: "POST",
|
||||
data: { name: nextCompanyName },
|
||||
});
|
||||
expect(createRes.ok).toBe(true);
|
||||
expect(createRes.json).toBeTruthy();
|
||||
return createRes.json!;
|
||||
}
|
||||
|
||||
async function createAuthenticatedInvite(page: Page, companyPrefix: string) {
|
||||
await page.goto(`${BASE}/${companyPrefix}/company/settings`);
|
||||
await expect(page.getByTestId("company-settings-invites-section")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await page.getByTestId("company-settings-human-invite-role").selectOption("operator");
|
||||
await page.getByTestId("company-settings-create-human-invite").click();
|
||||
const inviteField = page.getByTestId("company-settings-human-invite-url");
|
||||
await expect(inviteField).toBeVisible({ timeout: 20_000 });
|
||||
return (await inviteField.inputValue()).trim();
|
||||
}
|
||||
|
||||
async function signUpFromInvite(page: Page, inviteUrl: string, user: HumanUser) {
|
||||
await page.goto(inviteUrl);
|
||||
await expect(page.getByText("Sign in or create an account before submitting a human join request.")).toBeVisible();
|
||||
await page.getByRole("link", { name: "Sign in / Create account" }).click();
|
||||
await expect(page).toHaveURL(/\/auth\?next=/);
|
||||
await expect(page.getByRole("heading", { name: "Sign in to Paperclip" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Create one" }).click();
|
||||
await page.getByLabel("Name").fill(user.name);
|
||||
await page.getByLabel("Email").fill(user.email);
|
||||
await page.getByLabel("Password").fill(user.password);
|
||||
await page.getByRole("button", { name: "Create Account" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/invite/[^/]+$`), { timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function acceptHumanInvite(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Join company" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Join company" }).click();
|
||||
await expect(page.getByRole("heading", { name: "You joined the company" })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await page.getByRole("link", { name: "Open board" }).click();
|
||||
}
|
||||
|
||||
async function sessionJsonRequest<T>(
|
||||
page: Page,
|
||||
url: string,
|
||||
options: {
|
||||
method?: string;
|
||||
data?: unknown;
|
||||
} = {}
|
||||
) {
|
||||
return page.evaluate(
|
||||
async ({ url: targetUrl, method, data }) => {
|
||||
const response = await fetch(targetUrl, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: data === undefined ? undefined : { "Content-Type": "application/json" },
|
||||
body: data === undefined ? undefined : JSON.stringify(data),
|
||||
});
|
||||
const text = await response.text();
|
||||
let json: unknown = null;
|
||||
if (text.length > 0) {
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
text,
|
||||
json,
|
||||
};
|
||||
},
|
||||
{
|
||||
url,
|
||||
method: options.method ?? "GET",
|
||||
data: options.data,
|
||||
}
|
||||
) as Promise<SessionJsonResponse<T>>;
|
||||
}
|
||||
|
||||
async function waitForMember(page: Page, companyId: string, email: string) {
|
||||
let member: CompanyMember | null = null;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const membersRes = await sessionJsonRequest<{ members: CompanyMember[] }>(
|
||||
page,
|
||||
`${BASE}/api/companies/${companyId}/members`
|
||||
);
|
||||
expect(membersRes.ok).toBe(true);
|
||||
const body = membersRes.json;
|
||||
if (!body) return null;
|
||||
member = body.members.find((entry) => entry.user?.email === email) ?? null;
|
||||
return member;
|
||||
},
|
||||
{
|
||||
timeout: 20_000,
|
||||
intervals: [500, 1_000, 2_000],
|
||||
}
|
||||
)
|
||||
.toMatchObject({
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
user: { email },
|
||||
});
|
||||
return member!;
|
||||
}
|
||||
|
||||
async function waitForMemberRole(
|
||||
page: Page,
|
||||
companyId: string,
|
||||
memberId: string,
|
||||
membershipRole: CompanyMember["membershipRole"]
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const membersRes = await sessionJsonRequest<{ members: CompanyMember[] }>(
|
||||
page,
|
||||
`${BASE}/api/companies/${companyId}/members`
|
||||
);
|
||||
expect(membersRes.ok).toBe(true);
|
||||
const body = membersRes.json;
|
||||
if (!body) return null;
|
||||
return body.members.find((member) => member.id === memberId) ?? null;
|
||||
},
|
||||
{
|
||||
timeout: 20_000,
|
||||
intervals: [500, 1_000, 2_000],
|
||||
}
|
||||
)
|
||||
.toMatchObject({
|
||||
id: memberId,
|
||||
membershipRole,
|
||||
});
|
||||
}
|
||||
|
||||
async function newPage(browser: Browser) {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
test.describe("Multi-user: authenticated mode", () => {
|
||||
test("authenticated humans can bootstrap, invite, join, and respect viewer restrictions", async ({
|
||||
browser,
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const healthRes = await page.request.get(`${BASE}/api/health`);
|
||||
expect(healthRes.ok()).toBe(true);
|
||||
const health = (await healthRes.json()) as {
|
||||
deploymentMode?: string;
|
||||
bootstrapStatus?: string;
|
||||
};
|
||||
expect(health.deploymentMode).toBe("authenticated");
|
||||
|
||||
await signUp(page, ownerUser);
|
||||
await acceptBootstrapInvite(page, createBootstrapInvite());
|
||||
|
||||
const company = await createCompanyForSession(page, companyName);
|
||||
const companyPrefix = company.issuePrefix ?? company.id;
|
||||
await page.goto(`${BASE}/${companyPrefix}/dashboard`);
|
||||
await expect(page.getByTestId("layout-account-menu-trigger")).toContainText(ownerUser.name);
|
||||
await page.getByTestId("layout-account-menu-trigger").click();
|
||||
await expect(page.getByText(ownerUser.email)).toBeVisible();
|
||||
const inviteUrl = await createAuthenticatedInvite(page, companyPrefix);
|
||||
|
||||
const invited = await newPage(browser);
|
||||
try {
|
||||
await signUpFromInvite(invited.page, inviteUrl, invitedUser);
|
||||
await acceptHumanInvite(invited.page);
|
||||
await expect(invited.page).not.toHaveURL(/\/auth/, { timeout: 10_000 });
|
||||
|
||||
const joinedMember = await waitForMember(page, company.id, invitedUser.email);
|
||||
|
||||
await page.goto(`${BASE}/${companyPrefix}/company/settings`);
|
||||
const roleSelect = page.getByTestId(`company-settings-member-role-${joinedMember.id}`);
|
||||
await expect(roleSelect).toBeVisible({ timeout: 20_000 });
|
||||
await roleSelect.selectOption("viewer");
|
||||
await expect(roleSelect).toHaveValue("viewer");
|
||||
await waitForMemberRole(page, company.id, joinedMember.id, "viewer");
|
||||
|
||||
await invited.page.goto(`${BASE}/${companyPrefix}/company/settings`);
|
||||
await expect(
|
||||
invited.page.getByText("Your current company role cannot create human invites.")
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await expect(
|
||||
invited.page.getByTestId("company-settings-create-human-invite")
|
||||
).toHaveCount(0);
|
||||
|
||||
const forbiddenInviteRes = await sessionJsonRequest(
|
||||
invited.page,
|
||||
`${BASE}/api/companies/${company.id}/invites`,
|
||||
{
|
||||
method: "POST",
|
||||
data: {
|
||||
allowedJoinTypes: "human",
|
||||
humanRole: "viewer",
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(forbiddenInviteRes.status).toBe(403);
|
||||
} finally {
|
||||
await invited.context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
518
tests/e2e/multi-user.spec.ts
Normal file
518
tests/e2e/multi-user.spec.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
import { test, expect, type Page, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* E2E: Multi-user implementation tests (local_trusted mode).
|
||||
*
|
||||
* Covers:
|
||||
* 1. Company member management API (list, update role, suspend)
|
||||
* 2. Human invite creation and acceptance API
|
||||
* 3. Company Settings UI — member list, role editing, invite creation
|
||||
* 4. Invite landing page UI
|
||||
* 5. Role-based access control (viewer read-only)
|
||||
* 6. Last-owner protection
|
||||
*/
|
||||
|
||||
const BASE = process.env.PAPERCLIP_E2E_BASE_URL ?? "http://127.0.0.1:3104";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Ensure the server is bootstrapped (claimed) before running tests. */
|
||||
async function ensureBootstrapped(request: APIRequestContext): Promise<void> {
|
||||
const healthRes = await request.get(`${BASE}/api/health`);
|
||||
const health = await healthRes.json();
|
||||
if (health.bootstrapStatus === "ready") return;
|
||||
|
||||
// If bootstrap_pending, we need to use the claim token from the bootstrap invite.
|
||||
// In local_trusted mode, just try hitting companies — that should auto-bootstrap.
|
||||
if (health.deploymentMode === "local_trusted") {
|
||||
// local_trusted should work without explicit bootstrap
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a company via the onboarding wizard API shortcut. */
|
||||
async function createCompanyViaWizard(
|
||||
request: APIRequestContext,
|
||||
name: string
|
||||
): Promise<{ companyId: string; agentId: string; prefix: string }> {
|
||||
await ensureBootstrapped(request);
|
||||
|
||||
const createRes = await request.post(`${BASE}/api/companies`, {
|
||||
data: { name },
|
||||
});
|
||||
if (!createRes.ok()) {
|
||||
const errText = await createRes.text();
|
||||
throw new Error(
|
||||
`Failed to create company (${createRes.status()}): ${errText}`
|
||||
);
|
||||
}
|
||||
const company = await createRes.json();
|
||||
|
||||
// Create a CEO agent
|
||||
const agentRes = await request.post(
|
||||
`${BASE}/api/companies/${company.id}/agents`,
|
||||
{
|
||||
data: {
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
title: "CEO",
|
||||
adapterType: "claude_local",
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(agentRes.ok()).toBe(true);
|
||||
const agent = await agentRes.json();
|
||||
|
||||
return {
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
prefix: company.issuePrefix ?? company.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a human invite and return token + invite URL. */
|
||||
async function createHumanInvite(
|
||||
request: APIRequestContext,
|
||||
companyId: string,
|
||||
role: string = "operator"
|
||||
): Promise<{ token: string; inviteUrl: string; inviteId: string }> {
|
||||
const res = await request.post(
|
||||
`${BASE}/api/companies/${companyId}/invites`,
|
||||
{
|
||||
data: {
|
||||
allowedJoinTypes: "human",
|
||||
humanRole: role,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
return {
|
||||
token: body.token,
|
||||
inviteUrl: body.inviteUrl,
|
||||
inviteId: body.id,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Multi-user: API", () => {
|
||||
let companyId: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const result = await createCompanyViaWizard(
|
||||
request,
|
||||
`MU-API-${Date.now()}`
|
||||
);
|
||||
companyId = result.companyId;
|
||||
});
|
||||
|
||||
test("GET /companies/:id/members returns member list with access info", async ({
|
||||
request,
|
||||
}) => {
|
||||
const res = await request.get(
|
||||
`${BASE}/api/companies/${companyId}/members`
|
||||
);
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body).toHaveProperty("members");
|
||||
expect(body).toHaveProperty("access");
|
||||
expect(Array.isArray(body.members)).toBe(true);
|
||||
expect(body.access).toHaveProperty("currentUserRole");
|
||||
expect(body.access).toHaveProperty("canManageMembers");
|
||||
expect(body.access).toHaveProperty("canInviteUsers");
|
||||
});
|
||||
|
||||
test("POST /companies/:id/invites creates a human invite with role", async ({
|
||||
request,
|
||||
}) => {
|
||||
const res = await request.post(
|
||||
`${BASE}/api/companies/${companyId}/invites`,
|
||||
{
|
||||
data: {
|
||||
allowedJoinTypes: "human",
|
||||
humanRole: "operator",
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body).toHaveProperty("token");
|
||||
expect(body).toHaveProperty("inviteUrl");
|
||||
expect(body.allowedJoinTypes).toBe("human");
|
||||
expect(body.inviteUrl).toContain("/invite/");
|
||||
});
|
||||
|
||||
test("GET /invites/:token returns invite summary", async ({ request }) => {
|
||||
const invite = await createHumanInvite(request, companyId, "viewer");
|
||||
const res = await request.get(`${BASE}/api/invites/${invite.token}`);
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body).toHaveProperty("companyId");
|
||||
expect(body).toHaveProperty("allowedJoinTypes");
|
||||
expect(body.allowedJoinTypes).toBe("human");
|
||||
expect(body).toHaveProperty("inviteType");
|
||||
expect(body.inviteType).toBe("company_join");
|
||||
});
|
||||
|
||||
test("POST /invites/:token/accept (human) creates membership", async ({
|
||||
request,
|
||||
}) => {
|
||||
const invite = await createHumanInvite(request, companyId, "operator");
|
||||
const acceptRes = await request.post(
|
||||
`${BASE}/api/invites/${invite.token}/accept`,
|
||||
{
|
||||
data: { requestType: "human" },
|
||||
}
|
||||
);
|
||||
expect(acceptRes.ok()).toBe(true);
|
||||
const body = await acceptRes.json();
|
||||
|
||||
// In local_trusted, human accept should succeed
|
||||
expect(body).toHaveProperty("id");
|
||||
});
|
||||
|
||||
test("POST /invites/:token/accept rejects agent on human-only invite", async ({
|
||||
request,
|
||||
}) => {
|
||||
const invite = await createHumanInvite(request, companyId, "operator");
|
||||
const acceptRes = await request.post(
|
||||
`${BASE}/api/invites/${invite.token}/accept`,
|
||||
{
|
||||
data: { requestType: "agent", agentName: "Rogue" },
|
||||
}
|
||||
);
|
||||
expect(acceptRes.ok()).toBe(false);
|
||||
expect(acceptRes.status()).toBe(400);
|
||||
});
|
||||
|
||||
test("POST /companies/:id/invites supports all four roles", async ({
|
||||
request,
|
||||
}) => {
|
||||
for (const role of ["owner", "admin", "operator", "viewer"]) {
|
||||
const res = await request.post(
|
||||
`${BASE}/api/companies/${companyId}/invites`,
|
||||
{
|
||||
data: { allowedJoinTypes: "human", humanRole: role },
|
||||
}
|
||||
);
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.token).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("PATCH /companies/:id/members/:memberId cannot remove last owner", async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create a fresh company for this test
|
||||
const fresh = await createCompanyViaWizard(
|
||||
request,
|
||||
`MU-LastOwner-${Date.now()}`
|
||||
);
|
||||
|
||||
// First promote the local-board member to owner
|
||||
const membersRes = await request.get(
|
||||
`${BASE}/api/companies/${fresh.companyId}/members`
|
||||
);
|
||||
const { members } = await membersRes.json();
|
||||
|
||||
// Find the board member (should be the only one)
|
||||
const boardMember = members.find(
|
||||
(m: { principalId: string }) => m.principalId === "local-board"
|
||||
);
|
||||
if (!boardMember) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Promote to owner first
|
||||
const promoteRes = await request.patch(
|
||||
`${BASE}/api/companies/${fresh.companyId}/members/${boardMember.id}`,
|
||||
{ data: { membershipRole: "owner" } }
|
||||
);
|
||||
expect(promoteRes.ok()).toBe(true);
|
||||
|
||||
// Now try to demote the last (and only) owner to operator — should fail
|
||||
const demoteRes = await request.patch(
|
||||
`${BASE}/api/companies/${fresh.companyId}/members/${boardMember.id}`,
|
||||
{ data: { membershipRole: "operator" } }
|
||||
);
|
||||
expect(demoteRes.status()).toBe(409);
|
||||
const errBody = await demoteRes.json();
|
||||
expect(JSON.stringify(errBody)).toContain("last active owner");
|
||||
});
|
||||
|
||||
test("POST /companies/:id/openclaw/invite-prompt creates agent invite", async ({
|
||||
request,
|
||||
}) => {
|
||||
const res = await request.post(
|
||||
`${BASE}/api/companies/${companyId}/openclaw/invite-prompt`,
|
||||
{
|
||||
data: { agentMessage: "E2E test agent invite" },
|
||||
}
|
||||
);
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body).toHaveProperty("token");
|
||||
expect(body).toHaveProperty("inviteUrl");
|
||||
expect(body.allowedJoinTypes).toBe("agent");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Multi-user: Company Settings UI", () => {
|
||||
let companyId: string;
|
||||
let companyPrefix: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const result = await createCompanyViaWizard(
|
||||
request,
|
||||
`MU-UI-${Date.now()}`
|
||||
);
|
||||
companyId = result.companyId;
|
||||
companyPrefix = result.prefix;
|
||||
});
|
||||
|
||||
test("shows Team and Invites sections on settings page", async ({ page }) => {
|
||||
await page.goto(`${BASE}/${companyPrefix}/company/settings`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.getByTestId("company-settings-invites-section")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByTestId("company-settings-team-section")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("shows human invite creation controls", async ({ page }) => {
|
||||
await page.goto(`${BASE}/${companyPrefix}/company/settings`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
const inviteButton = page.getByTestId("company-settings-create-human-invite");
|
||||
await expect(inviteButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const roleSelect = page.getByTestId("company-settings-human-invite-role");
|
||||
await expect(roleSelect).toBeVisible();
|
||||
});
|
||||
|
||||
test("can create human invite and shows URL", async ({ page }) => {
|
||||
await page.goto(`${BASE}/${companyPrefix}/company/settings`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
const inviteButton = page.getByTestId("company-settings-create-human-invite");
|
||||
await expect(inviteButton).toBeVisible({ timeout: 10_000 });
|
||||
await inviteButton.click();
|
||||
|
||||
await expect(page.getByTestId("company-settings-human-invite-url")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Multi-user: Invite Landing UI", () => {
|
||||
let companyId: string;
|
||||
let inviteToken: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const result = await createCompanyViaWizard(
|
||||
request,
|
||||
`MU-Invite-${Date.now()}`
|
||||
);
|
||||
companyId = result.companyId;
|
||||
|
||||
const invite = await createHumanInvite(request, companyId, "operator");
|
||||
inviteToken = invite.token;
|
||||
});
|
||||
|
||||
test("invite landing page loads with join options", async ({ page }) => {
|
||||
await page.goto(`${BASE}/invite/${inviteToken}`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Should show the invite landing page heading
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /join/i })
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("invite landing shows human join type", async ({ page }) => {
|
||||
await page.goto(`${BASE}/invite/${inviteToken}`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// For a human-only invite, should show human join option
|
||||
const humanOption = page.locator("text=/human/i");
|
||||
await expect(humanOption).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("expired/invalid invite token returns error", async ({ page }) => {
|
||||
await page.goto(`${BASE}/invite/invalid-token-e2e-test`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.getByTestId("invite-error")).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Multi-user: Member role management API", () => {
|
||||
let companyId: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const result = await createCompanyViaWizard(
|
||||
request,
|
||||
`MU-Roles-${Date.now()}`
|
||||
);
|
||||
companyId = result.companyId;
|
||||
});
|
||||
|
||||
test("invite + accept creates member with correct role", async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create invite for 'viewer' role
|
||||
const invite = await createHumanInvite(request, companyId, "viewer");
|
||||
|
||||
// Accept the invite
|
||||
const acceptRes = await request.post(
|
||||
`${BASE}/api/invites/${invite.token}/accept`,
|
||||
{ data: { requestType: "human" } }
|
||||
);
|
||||
expect(acceptRes.ok()).toBe(true);
|
||||
|
||||
// Check members list
|
||||
const membersRes = await request.get(
|
||||
`${BASE}/api/companies/${companyId}/members`
|
||||
);
|
||||
const { members } = await membersRes.json();
|
||||
|
||||
// Should have at least one member (the creator/local-board)
|
||||
expect(members.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("PATCH member role updates correctly", async ({ request }) => {
|
||||
// First create an invite and accept it to get a second member
|
||||
const invite = await createHumanInvite(request, companyId, "operator");
|
||||
const acceptRes = await request.post(
|
||||
`${BASE}/api/invites/${invite.token}/accept`,
|
||||
{ data: { requestType: "human" } }
|
||||
);
|
||||
expect(acceptRes.ok()).toBe(true);
|
||||
|
||||
// List members
|
||||
const membersRes = await request.get(
|
||||
`${BASE}/api/companies/${companyId}/members`
|
||||
);
|
||||
const { members } = await membersRes.json();
|
||||
|
||||
// Find a non-owner member to modify
|
||||
const nonOwner = members.find(
|
||||
(m: { membershipRole: string }) => m.membershipRole !== "owner"
|
||||
);
|
||||
if (!nonOwner) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update role to admin
|
||||
const patchRes = await request.patch(
|
||||
`${BASE}/api/companies/${companyId}/members/${nonOwner.id}`,
|
||||
{ data: { membershipRole: "admin" } }
|
||||
);
|
||||
expect(patchRes.ok()).toBe(true);
|
||||
const updated = await patchRes.json();
|
||||
expect(updated.membershipRole).toBe("admin");
|
||||
});
|
||||
|
||||
test("PATCH member status to suspended works", async ({ request }) => {
|
||||
// Create another member
|
||||
const invite = await createHumanInvite(request, companyId, "operator");
|
||||
await request.post(`${BASE}/api/invites/${invite.token}/accept`, {
|
||||
data: { requestType: "human" },
|
||||
});
|
||||
|
||||
const membersRes = await request.get(
|
||||
`${BASE}/api/companies/${companyId}/members`
|
||||
);
|
||||
const { members } = await membersRes.json();
|
||||
|
||||
const nonOwner = members.find(
|
||||
(m: { membershipRole: string; status: string }) =>
|
||||
m.membershipRole !== "owner" && m.status === "active"
|
||||
);
|
||||
if (!nonOwner) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const patchRes = await request.patch(
|
||||
`${BASE}/api/companies/${companyId}/members/${nonOwner.id}`,
|
||||
{ data: { status: "suspended" } }
|
||||
);
|
||||
expect(patchRes.ok()).toBe(true);
|
||||
const updated = await patchRes.json();
|
||||
expect(updated.status).toBe("suspended");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Multi-user: Agent invite flow", () => {
|
||||
let companyId: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const result = await createCompanyViaWizard(
|
||||
request,
|
||||
`MU-Agent-${Date.now()}`
|
||||
);
|
||||
companyId = result.companyId;
|
||||
});
|
||||
|
||||
test("agent invite accept creates pending join request", async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create agent invite
|
||||
const res = await request.post(
|
||||
`${BASE}/api/companies/${companyId}/openclaw/invite-prompt`,
|
||||
{ data: {} }
|
||||
);
|
||||
expect(res.ok()).toBe(true);
|
||||
const { token } = await res.json();
|
||||
|
||||
// Accept as agent
|
||||
const acceptRes = await request.post(
|
||||
`${BASE}/api/invites/${token}/accept`,
|
||||
{
|
||||
data: {
|
||||
requestType: "agent",
|
||||
agentName: "TestAgent",
|
||||
adapterType: "claude_local",
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(acceptRes.ok()).toBe(true);
|
||||
const body = await acceptRes.json();
|
||||
expect(body).toHaveProperty("id");
|
||||
expect(body.status).toBe("pending_approval");
|
||||
});
|
||||
|
||||
test("join requests list shows pending agent request", async ({
|
||||
request,
|
||||
}) => {
|
||||
const res = await request.get(
|
||||
`${BASE}/api/companies/${companyId}/join-requests?status=pending_approval`
|
||||
);
|
||||
expect(res.ok()).toBe(true);
|
||||
const requests = await res.json();
|
||||
expect(Array.isArray(requests)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Multi-user: Health check integration", () => {
|
||||
test("health endpoint reports deployment mode", async ({ request }) => {
|
||||
const res = await request.get(`${BASE}/api/health`);
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty("deploymentMode");
|
||||
expect(body).toHaveProperty("authReady");
|
||||
expect(body.authReady).toBe(true);
|
||||
});
|
||||
});
|
||||
28
tests/e2e/playwright-multiuser-authenticated.config.ts
Normal file
28
tests/e2e/playwright-multiuser-authenticated.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3105);
|
||||
const BASE_URL = process.env.PAPERCLIP_E2E_BASE_URL ?? `http://127.0.0.1:${PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "multi-user-authenticated.spec.ts",
|
||||
timeout: 180_000,
|
||||
expect: {
|
||||
timeout: 20_000,
|
||||
},
|
||||
retries: 0,
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
headless: true,
|
||||
screenshot: "only-on-failure",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { browserName: "chromium" },
|
||||
},
|
||||
],
|
||||
outputDir: "./test-results",
|
||||
reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]],
|
||||
});
|
||||
26
tests/e2e/playwright-multiuser.config.ts
Normal file
26
tests/e2e/playwright-multiuser.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3104);
|
||||
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "multi-user.spec.ts",
|
||||
timeout: 60_000,
|
||||
retries: 0,
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
headless: true,
|
||||
screenshot: "only-on-failure",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { browserName: "chromium" },
|
||||
},
|
||||
],
|
||||
// No webServer — expects an already-running server at BASE_URL.
|
||||
outputDir: "./test-results",
|
||||
reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]],
|
||||
});
|
||||
@@ -12,6 +12,9 @@ const PAPERCLIP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-e2e-home
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "**/*.spec.ts",
|
||||
// These suites target dedicated multi-user configurations/ports and are
|
||||
// intentionally not part of the default local_trusted e2e run.
|
||||
testIgnore: ["multi-user.spec.ts", "multi-user-authenticated.spec.ts"],
|
||||
timeout: 60_000,
|
||||
retries: 0,
|
||||
use: {
|
||||
|
||||
146
ui/src/App.test.tsx
Normal file
146
ui/src/App.test.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, type ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CloudAccessGate } from "./components/CloudAccessGate";
|
||||
|
||||
const mockHealthApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessApi = vi.hoisted(() => ({
|
||||
getCurrentBoardAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./api/health", () => ({
|
||||
healthApi: mockHealthApi,
|
||||
}));
|
||||
|
||||
vi.mock("./api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("./api/access", () => ({
|
||||
accessApi: mockAccessApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Navigate: ({ to }: { to: string }) => <div>Navigate:{to}</div>,
|
||||
Outlet: () => <div>Outlet content</div>,
|
||||
Route: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
Routes: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
useLocation: () => ({ pathname: "/instance/settings/general", search: "", hash: "" }),
|
||||
useParams: () => ({}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("CloudAccessGate", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockHealthApi.get.mockResolvedValue({
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
bootstrapStatus: "ready",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows a no-access message for signed-in users without org access", async () => {
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
||||
});
|
||||
mockAccessApi.getCurrentBoardAccess.mockResolvedValue({
|
||||
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
||||
userId: "user-1",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [],
|
||||
source: "session",
|
||||
keyId: null,
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudAccessGate />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("No company access");
|
||||
expect(container.textContent).not.toContain("Outlet content");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("allows authenticated users with company access through to the board", async () => {
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
||||
});
|
||||
mockAccessApi.getCurrentBoardAccess.mockResolvedValue({
|
||||
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
||||
userId: "user-1",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
keyId: null,
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudAccessGate />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Outlet content");
|
||||
expect(container.textContent).not.toContain("No company access");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { OnboardingWizard } from "./components/OnboardingWizard";
|
||||
import { authApi } from "./api/auth";
|
||||
import { healthApi } from "./api/health";
|
||||
import { CloudAccessGate } from "./components/CloudAccessGate";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Companies } from "./pages/Companies";
|
||||
import { Agents } from "./pages/Agents";
|
||||
@@ -25,18 +23,23 @@ import { Costs } from "./pages/Costs";
|
||||
import { Activity } from "./pages/Activity";
|
||||
import { Inbox } from "./pages/Inbox";
|
||||
import { CompanySettings } from "./pages/CompanySettings";
|
||||
import { CompanyAccess } from "./pages/CompanyAccess";
|
||||
import { CompanyInvites } from "./pages/CompanyInvites";
|
||||
import { CompanySkills } from "./pages/CompanySkills";
|
||||
import { CompanyExport } from "./pages/CompanyExport";
|
||||
import { CompanyImport } from "./pages/CompanyImport";
|
||||
import { DesignGuide } from "./pages/DesignGuide";
|
||||
import { InstanceGeneralSettings } from "./pages/InstanceGeneralSettings";
|
||||
import { InstanceAccess } from "./pages/InstanceAccess";
|
||||
import { InstanceSettings } from "./pages/InstanceSettings";
|
||||
import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings";
|
||||
import { ProfileSettings } from "./pages/ProfileSettings";
|
||||
import { PluginManager } from "./pages/PluginManager";
|
||||
import { PluginSettings } from "./pages/PluginSettings";
|
||||
import { AdapterManager } from "./pages/AdapterManager";
|
||||
import { PluginPage } from "./pages/PluginPage";
|
||||
import { IssueChatUxLab } from "./pages/IssueChatUxLab";
|
||||
import { InviteUxLab } from "./pages/InviteUxLab";
|
||||
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
|
||||
import { OrgChart } from "./pages/OrgChart";
|
||||
import { NewAgent } from "./pages/NewAgent";
|
||||
@@ -44,80 +47,13 @@ import { AuthPage } from "./pages/Auth";
|
||||
import { BoardClaimPage } from "./pages/BoardClaim";
|
||||
import { CliAuthPage } from "./pages/CliAuth";
|
||||
import { InviteLandingPage } from "./pages/InviteLanding";
|
||||
import { JoinRequestQueue } from "./pages/JoinRequestQueue";
|
||||
import { NotFoundPage } from "./pages/NotFound";
|
||||
import { queryKeys } from "./lib/queryKeys";
|
||||
import { useCompany } from "./context/CompanyContext";
|
||||
import { useDialog } from "./context/DialogContext";
|
||||
import { loadLastInboxTab } from "./lib/inbox";
|
||||
import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route";
|
||||
|
||||
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">Instance setup required</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{hasActiveInvite
|
||||
? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:"
|
||||
: "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"}
|
||||
</p>
|
||||
<pre className="mt-4 overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
{`pnpm paperclipai auth bootstrap-ceo`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudAccessGate() {
|
||||
const location = useLocation();
|
||||
const healthQuery = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
retry: false,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as
|
||||
| { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" }
|
||||
| undefined;
|
||||
return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending"
|
||||
? 2000
|
||||
: false;
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated";
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
enabled: isAuthenticatedMode,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (healthQuery.isLoading || (isAuthenticatedMode && sessionQuery.isLoading)) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
if (healthQuery.error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10 text-sm text-destructive">
|
||||
{healthQuery.error instanceof Error ? healthQuery.error.message : "Failed to load app state"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") {
|
||||
return <BootstrapPendingPage hasActiveInvite={healthQuery.data.bootstrapInviteActive} />;
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && !sessionQuery.data) {
|
||||
const next = encodeURIComponent(`${location.pathname}${location.search}`);
|
||||
return <Navigate to={`/auth?next=${next}`} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
function boardRoutes() {
|
||||
return (
|
||||
<>
|
||||
@@ -126,6 +62,8 @@ function boardRoutes() {
|
||||
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
||||
<Route path="companies" element={<Companies />} />
|
||||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/invites" element={<CompanyInvites />} />
|
||||
<Route path="company/export/*" element={<CompanyExport />} />
|
||||
<Route path="company/import" element={<CompanyImport />} />
|
||||
<Route path="skills/*" element={<CompanySkills />} />
|
||||
@@ -177,9 +115,11 @@ function boardRoutes() {
|
||||
<Route path="inbox/recent" element={<Inbox />} />
|
||||
<Route path="inbox/unread" element={<Inbox />} />
|
||||
<Route path="inbox/all" element={<Inbox />} />
|
||||
<Route path="inbox/requests" element={<JoinRequestQueue />} />
|
||||
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
|
||||
<Route path="design-guide" element={<DesignGuide />} />
|
||||
<Route path="tests/ux/chat" element={<IssueChatUxLab />} />
|
||||
<Route path="tests/ux/invites" element={<InviteUxLab />} />
|
||||
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
|
||||
<Route path="instance/settings/adapters" element={<AdapterManager />} />
|
||||
<Route path=":pluginRoutePath" element={<PluginPage />} />
|
||||
@@ -323,7 +263,9 @@ export function App() {
|
||||
<Route path="instance" element={<Navigate to="/instance/settings/general" replace />} />
|
||||
<Route path="instance/settings" element={<Layout />}>
|
||||
<Route index element={<Navigate to="general" replace />} />
|
||||
<Route path="profile" element={<ProfileSettings />} />
|
||||
<Route path="general" element={<InstanceGeneralSettings />} />
|
||||
<Route path="access" element={<InstanceAccess />} />
|
||||
<Route path="heartbeats" element={<InstanceSettings />} />
|
||||
<Route path="experimental" element={<InstanceExperimentalSettings />} />
|
||||
<Route path="plugins" element={<PluginManager />} />
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
|
||||
import type { AgentAdapterType, JoinRequest, PermissionKey } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export type HumanCompanyRole = "owner" | "admin" | "operator" | "viewer";
|
||||
|
||||
type InviteSummary = {
|
||||
id: string;
|
||||
companyId: string | null;
|
||||
companyName?: string | null;
|
||||
companyLogoUrl?: string | null;
|
||||
companyBrandColor?: string | null;
|
||||
inviteType: "company_join" | "bootstrap_ceo";
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
humanRole?: HumanCompanyRole | null;
|
||||
expiresAt: string;
|
||||
onboardingPath?: string;
|
||||
onboardingUrl?: string;
|
||||
@@ -15,6 +20,9 @@ type InviteSummary = {
|
||||
skillIndexPath?: string;
|
||||
skillIndexUrl?: string;
|
||||
inviteMessage?: string | null;
|
||||
invitedByUserName?: string | null;
|
||||
joinRequestStatus?: JoinRequest["status"] | null;
|
||||
joinRequestType?: JoinRequest["requestType"] | null;
|
||||
};
|
||||
|
||||
type AcceptInviteInput =
|
||||
@@ -88,17 +96,162 @@ type CompanyInviteCreated = {
|
||||
inviteUrl: string;
|
||||
expiresAt: string;
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
humanRole?: HumanCompanyRole | null;
|
||||
companyName?: string | null;
|
||||
onboardingTextPath?: string;
|
||||
onboardingTextUrl?: string;
|
||||
inviteMessage?: string | null;
|
||||
};
|
||||
|
||||
export type CompanyMemberGrant = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
principalType: "user";
|
||||
principalId: string;
|
||||
permissionKey: PermissionKey;
|
||||
scope: Record<string, unknown> | null;
|
||||
grantedByUserId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CompanyMember = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
principalType: "user";
|
||||
principalId: string;
|
||||
status: "pending" | "active" | "suspended";
|
||||
membershipRole: HumanCompanyRole | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
grants: CompanyMemberGrant[];
|
||||
};
|
||||
|
||||
export type CompanyMembersResponse = {
|
||||
members: CompanyMember[];
|
||||
access: {
|
||||
currentUserRole: HumanCompanyRole | null;
|
||||
canManageMembers: boolean;
|
||||
canInviteUsers: boolean;
|
||||
canApproveJoinRequests: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type CompanyUserDirectoryEntry = {
|
||||
principalId: string;
|
||||
status: "active";
|
||||
user: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
};
|
||||
|
||||
export type CompanyUserDirectoryResponse = {
|
||||
users: CompanyUserDirectoryEntry[];
|
||||
};
|
||||
|
||||
export type CompanyInviteRecord = {
|
||||
id: string;
|
||||
companyId: string | null;
|
||||
companyName: string | null;
|
||||
inviteType: "company_join" | "bootstrap_ceo";
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
humanRole: HumanCompanyRole | null;
|
||||
defaultsPayload: Record<string, unknown> | null;
|
||||
expiresAt: string;
|
||||
invitedByUserId: string | null;
|
||||
revokedAt: string | null;
|
||||
acceptedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
inviteMessage: string | null;
|
||||
state: "active" | "revoked" | "accepted" | "expired";
|
||||
invitedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
relatedJoinRequestId: string | null;
|
||||
};
|
||||
|
||||
export type CompanyInviteListResponse = {
|
||||
invites: CompanyInviteRecord[];
|
||||
nextOffset: number | null;
|
||||
};
|
||||
|
||||
export type CompanyJoinRequest = JoinRequest & {
|
||||
requesterUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
approvedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
rejectedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
invite: {
|
||||
id: string;
|
||||
inviteType: "company_join" | "bootstrap_ceo";
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
humanRole: HumanCompanyRole | null;
|
||||
inviteMessage: string | null;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
revokedAt: string | null;
|
||||
acceptedAt: string | null;
|
||||
invitedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type AdminUserDirectoryEntry = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
isInstanceAdmin: boolean;
|
||||
activeCompanyMembershipCount: number;
|
||||
};
|
||||
|
||||
export type UserCompanyAccessEntry = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
principalType: "user";
|
||||
principalId: string;
|
||||
status: "pending" | "active" | "suspended";
|
||||
membershipRole: HumanCompanyRole | "member" | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
companyName: string | null;
|
||||
companyStatus: "active" | "paused" | "archived" | null;
|
||||
};
|
||||
|
||||
export type UserCompanyAccessResponse = {
|
||||
user: {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
isInstanceAdmin: boolean;
|
||||
} | null;
|
||||
companyAccess: UserCompanyAccessEntry[];
|
||||
};
|
||||
|
||||
export type CurrentBoardAccess = {
|
||||
user: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
userId: string;
|
||||
isInstanceAdmin: boolean;
|
||||
companyIds: string[];
|
||||
source: string;
|
||||
keyId: string | null;
|
||||
};
|
||||
|
||||
function buildInviteListQuery(options: {
|
||||
state?: "active" | "revoked" | "accepted" | "expired";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const params = new URLSearchParams();
|
||||
if (options.state) params.set("state", options.state);
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
if (options.offset) params.set("offset", String(options.offset));
|
||||
const query = params.toString();
|
||||
return query ? `?${query}` : "";
|
||||
}
|
||||
|
||||
export const accessApi = {
|
||||
createCompanyInvite: (
|
||||
companyId: string,
|
||||
input: {
|
||||
allowedJoinTypes?: "human" | "agent" | "both";
|
||||
humanRole?: HumanCompanyRole | null;
|
||||
defaultsPayload?: Record<string, unknown> | null;
|
||||
agentMessage?: string | null;
|
||||
} = {},
|
||||
@@ -126,8 +279,67 @@ export const accessApi = {
|
||||
input,
|
||||
),
|
||||
|
||||
listJoinRequests: (companyId: string, status: "pending_approval" | "approved" | "rejected" = "pending_approval") =>
|
||||
api.get<JoinRequest[]>(`/companies/${companyId}/join-requests?status=${status}`),
|
||||
listInvites: (
|
||||
companyId: string,
|
||||
options: {
|
||||
state?: "active" | "revoked" | "accepted" | "expired";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {},
|
||||
) =>
|
||||
api.get<CompanyInviteListResponse>(
|
||||
`/companies/${companyId}/invites${buildInviteListQuery(options)}`,
|
||||
),
|
||||
|
||||
revokeInvite: (inviteId: string) => api.post(`/invites/${inviteId}/revoke`, {}),
|
||||
|
||||
listJoinRequests: (
|
||||
companyId: string,
|
||||
status: "pending_approval" | "approved" | "rejected" = "pending_approval",
|
||||
requestType?: "human" | "agent",
|
||||
) =>
|
||||
api.get<CompanyJoinRequest[]>(
|
||||
`/companies/${companyId}/join-requests?status=${status}${requestType ? `&requestType=${requestType}` : ""}`,
|
||||
),
|
||||
|
||||
listMembers: (companyId: string) =>
|
||||
api.get<CompanyMembersResponse>(`/companies/${companyId}/members`),
|
||||
|
||||
listUserDirectory: (companyId: string) =>
|
||||
api.get<CompanyUserDirectoryResponse>(`/companies/${companyId}/user-directory`),
|
||||
|
||||
updateMember: (
|
||||
companyId: string,
|
||||
memberId: string,
|
||||
input: {
|
||||
membershipRole?: HumanCompanyRole | null;
|
||||
status?: "pending" | "active" | "suspended";
|
||||
},
|
||||
) => api.patch<CompanyMember>(`/companies/${companyId}/members/${memberId}`, input),
|
||||
|
||||
updateMemberPermissions: (
|
||||
companyId: string,
|
||||
memberId: string,
|
||||
input: {
|
||||
grants: Array<{
|
||||
permissionKey: PermissionKey;
|
||||
scope?: Record<string, unknown> | null;
|
||||
}>;
|
||||
},
|
||||
) => api.patch<CompanyMember>(`/companies/${companyId}/members/${memberId}/permissions`, input),
|
||||
|
||||
updateMemberAccess: (
|
||||
companyId: string,
|
||||
memberId: string,
|
||||
input: {
|
||||
membershipRole?: HumanCompanyRole | null;
|
||||
status?: "pending" | "active" | "suspended";
|
||||
grants: Array<{
|
||||
permissionKey: PermissionKey;
|
||||
scope?: Record<string, unknown> | null;
|
||||
}>;
|
||||
},
|
||||
) => api.patch<CompanyMember>(`/companies/${companyId}/members/${memberId}/role-and-grants`, input),
|
||||
|
||||
approveJoinRequest: (companyId: string, requestId: string) =>
|
||||
api.post<JoinRequest>(`/companies/${companyId}/join-requests/${requestId}/approve`, {}),
|
||||
@@ -158,4 +370,22 @@ export const accessApi = {
|
||||
|
||||
cancelCliAuthChallenge: (id: string, token: string) =>
|
||||
api.post<{ cancelled: boolean; status: string }>(`/cli-auth/challenges/${id}/cancel`, { token }),
|
||||
|
||||
searchAdminUsers: (query: string) =>
|
||||
api.get<AdminUserDirectoryEntry[]>(`/admin/users?query=${encodeURIComponent(query)}`),
|
||||
|
||||
promoteInstanceAdmin: (userId: string) =>
|
||||
api.post(`/admin/users/${userId}/promote-instance-admin`, {}),
|
||||
|
||||
demoteInstanceAdmin: (userId: string) =>
|
||||
api.post(`/admin/users/${userId}/demote-instance-admin`, {}),
|
||||
|
||||
getUserCompanyAccess: (userId: string) =>
|
||||
api.get<UserCompanyAccessResponse>(`/admin/users/${userId}/company-access`),
|
||||
|
||||
setUserCompanyAccess: (userId: string, companyIds: string[]) =>
|
||||
api.put<UserCompanyAccessResponse>(`/admin/users/${userId}/company-access`, { companyIds }),
|
||||
|
||||
getCurrentBoardAccess: () =>
|
||||
api.get<CurrentBoardAccess>("/cli-auth/me"),
|
||||
};
|
||||
|
||||
@@ -1,27 +1,63 @@
|
||||
export type AuthSession = {
|
||||
session: { id: string; userId: string };
|
||||
user: { id: string; email: string | null; name: string | null };
|
||||
};
|
||||
import {
|
||||
authSessionSchema,
|
||||
currentUserProfileSchema,
|
||||
type AuthSession,
|
||||
type CurrentUserProfile,
|
||||
type UpdateCurrentUserProfile,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
type AuthErrorBody =
|
||||
| {
|
||||
code?: string;
|
||||
message?: string;
|
||||
error?: string | { code?: string; message?: string };
|
||||
}
|
||||
| null;
|
||||
|
||||
export class AuthApiError extends Error {
|
||||
status: number;
|
||||
code: string | null;
|
||||
body: unknown;
|
||||
|
||||
constructor(message: string, status: number, body: unknown, code: string | null = null) {
|
||||
super(message);
|
||||
this.name = "AuthApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
function toSession(value: unknown): AuthSession | null {
|
||||
const direct = authSessionSchema.safeParse(value);
|
||||
if (direct.success) return direct.data;
|
||||
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const sessionValue = record.session;
|
||||
const userValue = record.user;
|
||||
if (!sessionValue || typeof sessionValue !== "object") return null;
|
||||
if (!userValue || typeof userValue !== "object") return null;
|
||||
const session = sessionValue as Record<string, unknown>;
|
||||
const user = userValue as Record<string, unknown>;
|
||||
if (typeof session.id !== "string" || typeof session.userId !== "string") return null;
|
||||
if (typeof user.id !== "string") return null;
|
||||
return {
|
||||
session: { id: session.id, userId: session.userId },
|
||||
user: {
|
||||
id: user.id,
|
||||
email: typeof user.email === "string" ? user.email : null,
|
||||
name: typeof user.name === "string" ? user.name : null,
|
||||
},
|
||||
};
|
||||
const nested = authSessionSchema.safeParse((value as Record<string, unknown>).data);
|
||||
return nested.success ? nested.data : null;
|
||||
}
|
||||
|
||||
function extractAuthError(payload: AuthErrorBody, status: number) {
|
||||
const nested =
|
||||
payload?.error && typeof payload.error === "object"
|
||||
? payload.error
|
||||
: null;
|
||||
const code =
|
||||
typeof nested?.code === "string"
|
||||
? nested.code
|
||||
: typeof payload?.code === "string"
|
||||
? payload.code
|
||||
: null;
|
||||
const message =
|
||||
typeof nested?.message === "string" && nested.message.trim().length > 0
|
||||
? nested.message
|
||||
: typeof payload?.message === "string" && payload.message.trim().length > 0
|
||||
? payload.message
|
||||
: typeof payload?.error === "string" && payload.error.trim().length > 0
|
||||
? payload.error
|
||||
: `Request failed: ${status}`;
|
||||
|
||||
return new AuthApiError(message, status, payload, code);
|
||||
}
|
||||
|
||||
async function authPost(path: string, body: Record<string, unknown>) {
|
||||
@@ -33,16 +69,25 @@ async function authPost(path: string, body: Record<string, unknown>) {
|
||||
});
|
||||
const payload = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
(payload as { error?: { message?: string } | string } | null)?.error &&
|
||||
typeof (payload as { error?: { message?: string } | string }).error === "object"
|
||||
? ((payload as { error?: { message?: string } }).error?.message ?? `Request failed: ${res.status}`)
|
||||
: (payload as { error?: string } | null)?.error ?? `Request failed: ${res.status}`;
|
||||
throw new Error(message);
|
||||
throw extractAuthError(payload as AuthErrorBody, res.status);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function authPatch<T>(path: string, body: Record<string, unknown>, parse: (value: unknown) => T): Promise<T> {
|
||||
const res = await fetch(`/api/auth${path}`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw extractAuthError(payload as AuthErrorBody, res.status);
|
||||
}
|
||||
return parse(payload);
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
getSession: async (): Promise<AuthSession | null> => {
|
||||
const res = await fetch("/api/auth/get-session", {
|
||||
@@ -68,6 +113,21 @@ export const authApi = {
|
||||
await authPost("/sign-up/email", input);
|
||||
},
|
||||
|
||||
getProfile: async (): Promise<CurrentUserProfile> => {
|
||||
const res = await fetch("/api/auth/profile", {
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const payload = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw new Error((payload as { error?: string } | null)?.error ?? `Failed to load profile (${res.status})`);
|
||||
}
|
||||
return currentUserProfileSchema.parse(payload);
|
||||
},
|
||||
|
||||
updateProfile: async (input: UpdateCurrentUserProfile): Promise<CurrentUserProfile> =>
|
||||
authPatch("/profile", input, (payload) => currentUserProfileSchema.parse(payload)),
|
||||
|
||||
signOut: async () => {
|
||||
await authPost("/sign-out", {});
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn } from "../lib/utils";
|
||||
import { formatActivityVerb } from "../lib/activity-format";
|
||||
import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared";
|
||||
import type { CompanyUserProfile } from "../lib/company-members";
|
||||
|
||||
function entityLink(entityType: string, entityId: string, name?: string | null): string | null {
|
||||
switch (entityType) {
|
||||
@@ -19,13 +20,14 @@ function entityLink(entityType: string, entityId: string, name?: string | null):
|
||||
interface ActivityRowProps {
|
||||
event: ActivityEvent;
|
||||
agentMap: Map<string, Agent>;
|
||||
userProfileMap?: Map<string, CompanyUserProfile>;
|
||||
entityNameMap: Map<string, string>;
|
||||
entityTitleMap?: Map<string, string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) {
|
||||
const verb = formatActivityVerb(event.action, event.details, { agentMap });
|
||||
export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) {
|
||||
const verb = formatActivityVerb(event.action, event.details, { agentMap, userProfileMap });
|
||||
|
||||
const isHeartbeatEvent = event.entityType === "heartbeat_run";
|
||||
const heartbeatAgentId = isHeartbeatEvent
|
||||
@@ -43,13 +45,16 @@ export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, cl
|
||||
: entityLink(event.entityType, event.entityId, name);
|
||||
|
||||
const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null;
|
||||
const actorName = actor?.name ?? (event.actorType === "system" ? "System" : event.actorType === "user" ? "Board" : event.actorId || "Unknown");
|
||||
const userProfile = event.actorType === "user" ? userProfileMap?.get(event.actorId) : null;
|
||||
const actorName = actor?.name ?? (event.actorType === "system" ? "System" : userProfile?.label ?? (event.actorType === "user" ? "Board" : event.actorId || "Unknown"));
|
||||
const actorAvatarUrl = userProfile?.image ?? null;
|
||||
|
||||
const inner = (
|
||||
<div className="flex gap-3">
|
||||
<p className="flex-1 min-w-0 truncate">
|
||||
<Identity
|
||||
name={actorName}
|
||||
avatarUrl={actorAvatarUrl}
|
||||
size="xs"
|
||||
className="align-baseline"
|
||||
/>
|
||||
|
||||
114
ui/src/components/CloudAccessGate.tsx
Normal file
114
ui/src/components/CloudAccessGate.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Navigate, Outlet, useLocation } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { healthApi } from "@/api/health";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">Instance setup required</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{hasActiveInvite
|
||||
? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:"
|
||||
: "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"}
|
||||
</p>
|
||||
<pre className="mt-4 overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
{`pnpm paperclipai auth bootstrap-ceo`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoBoardAccessPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">No company access</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
This account is signed in, but it does not have an active company membership or instance-admin access on
|
||||
this Paperclip instance.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Use a company invite or sign in with an account that already belongs to this org.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CloudAccessGate() {
|
||||
const location = useLocation();
|
||||
const healthQuery = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
retry: false,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as
|
||||
| { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" }
|
||||
| undefined;
|
||||
return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending"
|
||||
? 2000
|
||||
: false;
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated";
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
enabled: isAuthenticatedMode,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const boardAccessQuery = useQuery({
|
||||
queryKey: queryKeys.access.currentBoardAccess,
|
||||
queryFn: () => accessApi.getCurrentBoardAccess(),
|
||||
enabled: isAuthenticatedMode && !!sessionQuery.data,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (
|
||||
healthQuery.isLoading ||
|
||||
(isAuthenticatedMode && sessionQuery.isLoading) ||
|
||||
(isAuthenticatedMode && !!sessionQuery.data && boardAccessQuery.isLoading)
|
||||
) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
if (healthQuery.error || boardAccessQuery.error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10 text-sm text-destructive">
|
||||
{healthQuery.error instanceof Error
|
||||
? healthQuery.error.message
|
||||
: boardAccessQuery.error instanceof Error
|
||||
? boardAccessQuery.error.message
|
||||
: "Failed to load app state"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") {
|
||||
return <BootstrapPendingPage hasActiveInvite={healthQuery.data.bootstrapInviteActive} />;
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && !sessionQuery.data) {
|
||||
const next = encodeURIComponent(`${location.pathname}${location.search}`);
|
||||
return <Navigate to={`/auth?next=${next}`} replace />;
|
||||
}
|
||||
|
||||
if (
|
||||
isAuthenticatedMode &&
|
||||
sessionQuery.data &&
|
||||
!boardAccessQuery.data?.isInstanceAdmin &&
|
||||
(boardAccessQuery.data?.companyIds.length ?? 0) === 0
|
||||
) {
|
||||
return <NoBoardAccessPage />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ interface CompanyPatternIconProps {
|
||||
logoUrl?: string | null;
|
||||
brandColor?: string | null;
|
||||
className?: string;
|
||||
logoFit?: "cover" | "contain";
|
||||
}
|
||||
|
||||
function hashString(value: string): number {
|
||||
@@ -165,6 +166,7 @@ export function CompanyPatternIcon({
|
||||
logoUrl,
|
||||
brandColor,
|
||||
className,
|
||||
logoFit = "cover",
|
||||
}: CompanyPatternIconProps) {
|
||||
const initial = companyName.trim().charAt(0).toUpperCase() || "?";
|
||||
const [imageError, setImageError] = useState(false);
|
||||
@@ -189,7 +191,10 @@ export function CompanyPatternIcon({
|
||||
src={logo}
|
||||
alt={`${companyName} logo`}
|
||||
onError={() => setImageError(true)}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
className={cn(
|
||||
"absolute inset-0 h-full w-full",
|
||||
logoFit === "contain" ? "object-contain" : "object-cover",
|
||||
)}
|
||||
/>
|
||||
) : patternDataUrl ? (
|
||||
<img
|
||||
|
||||
137
ui/src/components/CompanySettingsSidebar.test.tsx
Normal file
137
ui/src/components/CompanySettingsSidebar.test.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
|
||||
|
||||
const sidebarNavItemMock = vi.hoisted(() => vi.fn());
|
||||
const mockSidebarBadgesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to: string;
|
||||
onClick?: () => void;
|
||||
}) => (
|
||||
<button type="button" data-to={to} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarNavItem", () => ({
|
||||
SidebarNavItem: (props: {
|
||||
to: string;
|
||||
label: string;
|
||||
end?: boolean;
|
||||
badge?: number;
|
||||
}) => {
|
||||
sidebarNavItemMock(props);
|
||||
return <div>{props.label}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/sidebarBadges", () => ({
|
||||
sidebarBadgesApi: mockSidebarBadgesApi,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("CompanySettingsSidebar", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockSidebarBadgesApi.get.mockResolvedValue({
|
||||
inbox: 0,
|
||||
approvals: 0,
|
||||
failedRuns: 0,
|
||||
joinRequests: 2,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the company back link and the settings sections in the sidebar", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanySettingsSidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Paperclip");
|
||||
expect(container.textContent).toContain("Company Settings");
|
||||
expect(container.textContent).toContain("General");
|
||||
expect(container.textContent).toContain("Access");
|
||||
expect(container.textContent).toContain("Invites");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings",
|
||||
label: "General",
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/access",
|
||||
label: "Access",
|
||||
badge: 2,
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/invites",
|
||||
label: "Invites",
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
69
ui/src/components/CompanySettingsSidebar.tsx
Normal file
69
ui/src/components/CompanySettingsSidebar.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronLeft, MailPlus, Settings, Shield, SlidersHorizontal } from "lucide-react";
|
||||
import { sidebarBadgesApi } from "@/api/sidebarBadges";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Link } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
|
||||
export function CompanySettingsSidebar() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { data: badges } = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.sidebarBadges(selectedCompanyId)
|
||||
: ["sidebar-badges", "__disabled__"] as const,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await sidebarBadgesApi.get(selectedCompanyId!);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: !!selectedCompanyId,
|
||||
retry: false,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{selectedCompany?.name ?? "Company"}</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<Settings className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 truncate text-sm font-bold text-foreground">
|
||||
Company Settings
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to="/company/settings" label="General" icon={SlidersHorizontal} end />
|
||||
<SidebarNavItem
|
||||
to="/company/settings/access"
|
||||
label="Access"
|
||||
icon={Shield}
|
||||
badge={badges?.joinRequests ?? 0}
|
||||
end
|
||||
/>
|
||||
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Agent, Issue } from "@paperclipai/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { accessApi } from "../api/access";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { sortAgentsByRecency, getRecentAssigneeIds } from "../lib/recent-assignees";
|
||||
import {
|
||||
buildExecutionPolicy,
|
||||
@@ -34,14 +38,27 @@ export function ExecutionParticipantPicker({
|
||||
const reviewerValues = stageParticipantValues(issue.executionPolicy, "review");
|
||||
const approverValues = stageParticipantValues(issue.executionPolicy, "approval");
|
||||
const values = stageType === "review" ? reviewerValues : approverValues;
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(issue.companyId),
|
||||
queryFn: () => accessApi.listUserDirectory(issue.companyId),
|
||||
enabled: !!issue.companyId,
|
||||
});
|
||||
|
||||
const sortedAgents = sortAgentsByRecency(
|
||||
agents.filter((a) => a.status !== "terminated"),
|
||||
getRecentAssigneeIds(),
|
||||
);
|
||||
const userLabelMap = useMemo(
|
||||
() => buildCompanyUserLabelMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
const otherUserOptions = useMemo(
|
||||
() => buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId, issue.createdByUserId] }),
|
||||
[companyMembers?.users, currentUserId, issue.createdByUserId],
|
||||
);
|
||||
|
||||
const userLabel = (userId: string | null | undefined) =>
|
||||
formatAssigneeUserLabel(userId, currentUserId);
|
||||
formatAssigneeUserLabel(userId, currentUserId, userLabelMap);
|
||||
const creatorUserLabel = userLabel(issue.createdByUserId);
|
||||
|
||||
const agentName = (id: string) => {
|
||||
@@ -138,6 +155,24 @@ export function ExecutionParticipantPicker({
|
||||
{creatorUserLabel ?? "Requester"}
|
||||
</button>
|
||||
)}
|
||||
{otherUserOptions
|
||||
.filter((option) => {
|
||||
if (!search.trim()) return true;
|
||||
return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(search.toLowerCase());
|
||||
})
|
||||
.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.includes(option.id) && "bg-accent",
|
||||
)}
|
||||
onClick={() => toggle(option.id)}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{sortedAgents
|
||||
.filter((agent) => {
|
||||
if (!search.trim()) return true;
|
||||
|
||||
@@ -88,27 +88,27 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||
if (!closeWorkspace.isPending) onOpenChange(nextOpen);
|
||||
}}>
|
||||
<DialogContent className="max-h-[85vh] overflow-x-hidden overflow-y-auto p-4 sm:max-w-2xl sm:p-6 [&>*]:min-w-0">
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{actionLabel}</DialogTitle>
|
||||
<DialogDescription className="break-words text-xs sm:text-sm">
|
||||
<DialogDescription className="break-words">
|
||||
Archive <span className="font-medium text-foreground">{workspaceName}</span> and clean up any owned workspace
|
||||
artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{readinessQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Checking whether this workspace is safe to close...
|
||||
</div>
|
||||
) : readinessQuery.error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-destructive">
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
|
||||
</div>
|
||||
) : readiness ? (
|
||||
<div className="min-w-0 space-y-3 sm:space-y-4">
|
||||
<div className={`rounded-xl border px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm ${readinessTone(readiness.state)}`}>
|
||||
<div className="space-y-4">
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${readinessTone(readiness.state)}`}>
|
||||
<div className="font-medium">
|
||||
{readiness.state === "blocked"
|
||||
? "Close is blocked"
|
||||
@@ -129,10 +129,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
|
||||
{blockingIssues.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Blocking issues</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h3 className="text-sm font-medium">Blocking issues</h3>
|
||||
<div className="space-y-2">
|
||||
{blockingIssues.map((issue) => (
|
||||
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
||||
{issue.identifier ?? issue.id} · {issue.title}
|
||||
@@ -147,10 +147,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
|
||||
{readiness.blockingReasons.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Blocking reasons</h3>
|
||||
<ul className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm text-muted-foreground">
|
||||
{readiness.blockingReasons.map((reason, idx) => (
|
||||
<li key={`blocking-${idx}`} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 sm:px-3 sm:py-2 text-destructive">
|
||||
<h3 className="text-sm font-medium">Blocking reasons</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{readiness.blockingReasons.map((reason) => (
|
||||
<li key={reason} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive">
|
||||
{reason}
|
||||
</li>
|
||||
))}
|
||||
@@ -160,10 +160,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
|
||||
{readiness.warnings.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Warnings</h3>
|
||||
<ul className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm text-muted-foreground">
|
||||
{readiness.warnings.map((warning, idx) => (
|
||||
<li key={`warning-${idx}`} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-2.5 py-1.5 sm:px-3 sm:py-2">
|
||||
<h3 className="text-sm font-medium">Warnings</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{readiness.warnings.map((warning) => (
|
||||
<li key={warning} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2">
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
@@ -173,16 +173,16 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
|
||||
{readiness.git ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Git status</h3>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-muted/20 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium">Git status</h3>
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</div>
|
||||
<div className="truncate font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
|
||||
<div className="font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Base ref</div>
|
||||
<div className="truncate font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
|
||||
<div className="font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Merged into base</div>
|
||||
@@ -209,10 +209,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
|
||||
{otherLinkedIssues.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Other linked issues</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h3 className="text-sm font-medium">Other linked issues</h3>
|
||||
<div className="space-y-2">
|
||||
{otherLinkedIssues.map((issue) => (
|
||||
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
||||
{issue.identifier ?? issue.id} · {issue.title}
|
||||
@@ -227,10 +227,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
|
||||
{readiness.runtimeServices.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Attached runtime services</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h3 className="text-sm font-medium">Attached runtime services</h3>
|
||||
<div className="space-y-2">
|
||||
{readiness.runtimeServices.map((service) => (
|
||||
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-medium">{service.serviceName}</span>
|
||||
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
|
||||
@@ -245,10 +245,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
) : null}
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Cleanup actions</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h3 className="text-sm font-medium">Cleanup actions</h3>
|
||||
<div className="space-y-2">
|
||||
{readiness.plannedActions.map((action, index) => (
|
||||
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="mt-1 break-words text-muted-foreground">{action.description}</div>
|
||||
{action.command ? (
|
||||
@@ -262,20 +262,20 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
</section>
|
||||
|
||||
{currentStatus === "cleanup_failed" ? (
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3 text-sm text-muted-foreground">
|
||||
Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the
|
||||
workspace status if it succeeds.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{currentStatus === "archived" ? (
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||
This workspace is already archived.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{readiness.git?.repoRoot ? (
|
||||
<div className="overflow-hidden break-words text-xs text-muted-foreground">
|
||||
<div className="break-words text-xs text-muted-foreground">
|
||||
Repo root: <span className="font-mono break-all">{readiness.git.repoRoot}</span>
|
||||
{readiness.git.workspacePath ? (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Clock3, Cpu, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react";
|
||||
import { Clock3, Cpu, FlaskConical, Puzzle, Settings, Shield, SlidersHorizontal, UserRoundPen } from "lucide-react";
|
||||
import { NavLink } from "@/lib/router";
|
||||
import { pluginsApi } from "@/api/plugins";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
@@ -23,7 +23,9 @@ export function InstanceSidebar() {
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to="/instance/settings/profile" label="Profile" icon={UserRoundPen} end />
|
||||
<SidebarNavItem to="/instance/settings/general" label="General" icon={SlidersHorizontal} end />
|
||||
<SidebarNavItem to="/instance/settings/access" label="Access" icon={Shield} end />
|
||||
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
|
||||
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
|
||||
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
|
||||
|
||||
@@ -5,7 +5,12 @@ import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueChatThread, canStopIssueChatRun, resolveAssistantMessageFoldedState } from "./IssueChatThread";
|
||||
import {
|
||||
IssueChatThread,
|
||||
canStopIssueChatRun,
|
||||
resolveAssistantMessageFoldedState,
|
||||
resolveIssueChatHumanAuthor,
|
||||
} from "./IssueChatThread";
|
||||
|
||||
const { markdownEditorFocusMock } = vi.hoisted(() => ({
|
||||
markdownEditorFocusMock: vi.fn(),
|
||||
@@ -661,4 +666,33 @@ describe("IssueChatThread", () => {
|
||||
activeRunIds: new Set<string>(),
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("uses company profile data to distinguish the current user from other humans", () => {
|
||||
const userProfileMap = new Map([
|
||||
["user-1", { label: "Dotta", image: "/avatars/dotta.png" }],
|
||||
["user-2", { label: "Alice", image: "/avatars/alice.png" }],
|
||||
]);
|
||||
|
||||
expect(resolveIssueChatHumanAuthor({
|
||||
authorName: "You",
|
||||
authorUserId: "user-1",
|
||||
currentUserId: "user-1",
|
||||
userProfileMap,
|
||||
})).toEqual({
|
||||
isCurrentUser: true,
|
||||
authorName: "Dotta",
|
||||
avatarUrl: "/avatars/dotta.png",
|
||||
});
|
||||
|
||||
expect(resolveIssueChatHumanAuthor({
|
||||
authorName: "Alice",
|
||||
authorUserId: "user-2",
|
||||
currentUserId: "user-1",
|
||||
userProfileMap,
|
||||
})).toEqual({
|
||||
isCurrentUser: false,
|
||||
authorName: "Alice",
|
||||
avatarUrl: "/avatars/alice.png",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
|
||||
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
shouldPreserveComposerViewport,
|
||||
} from "../lib/issue-chat-scroll";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import type { CompanyUserProfile } from "../lib/company-members";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import {
|
||||
describeToolInput,
|
||||
@@ -96,6 +97,8 @@ interface IssueChatMessageContext {
|
||||
feedbackTermsUrl: string | null;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||||
activeRunIds: ReadonlySet<string>;
|
||||
onVote?: (
|
||||
commentId: string,
|
||||
@@ -217,6 +220,8 @@ interface IssueChatThreadProps {
|
||||
issueStatus?: string;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||||
onVote?: (
|
||||
commentId: string,
|
||||
vote: FeedbackVoteValue,
|
||||
@@ -477,12 +482,13 @@ function formatTimelineAssigneeLabel(
|
||||
assignee: IssueTimelineAssignee,
|
||||
agentMap?: Map<string, Agent>,
|
||||
currentUserId?: string | null,
|
||||
userLabelMap?: ReadonlyMap<string, string> | null,
|
||||
) {
|
||||
if (assignee.agentId) {
|
||||
return agentMap?.get(assignee.agentId)?.name ?? assignee.agentId.slice(0, 8);
|
||||
}
|
||||
if (assignee.userId) {
|
||||
return formatAssigneeUserLabel(assignee.userId, currentUserId) ?? "Board";
|
||||
return formatAssigneeUserLabel(assignee.userId, currentUserId, userLabelMap) ?? "Board";
|
||||
}
|
||||
return "Unassigned";
|
||||
}
|
||||
@@ -495,6 +501,26 @@ function initialsForName(name: string) {
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function resolveIssueChatHumanAuthor(args: {
|
||||
authorName?: string | null;
|
||||
authorUserId?: string | null;
|
||||
currentUserId?: string | null;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||||
}) {
|
||||
const { authorName, authorUserId, currentUserId, userProfileMap } = args;
|
||||
const profile = authorUserId ? userProfileMap?.get(authorUserId) ?? null : null;
|
||||
const isCurrentUser = Boolean(authorUserId && currentUserId && authorUserId === currentUserId);
|
||||
const resolvedAuthorName = profile?.label?.trim()
|
||||
|| authorName?.trim()
|
||||
|| (authorUserId === "local-board" ? "Board" : (isCurrentUser ? "You" : "User"));
|
||||
|
||||
return {
|
||||
isCurrentUser,
|
||||
authorName: resolvedAuthorName,
|
||||
avatarUrl: profile?.image ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatRunStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case "timed_out":
|
||||
@@ -906,108 +932,151 @@ function IssueChatToolPart({
|
||||
}
|
||||
|
||||
function IssueChatUserMessage() {
|
||||
const { onInterruptQueued, onCancelQueued, interruptingQueuedRunId } = useContext(IssueChatCtx);
|
||||
const {
|
||||
onInterruptQueued,
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId,
|
||||
currentUserId,
|
||||
userProfileMap,
|
||||
} = useContext(IssueChatCtx);
|
||||
const message = useMessage();
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
const commentId = typeof custom.commentId === "string" ? custom.commentId : message.id;
|
||||
const authorName = typeof custom.authorName === "string" ? custom.authorName : null;
|
||||
const authorUserId = typeof custom.authorUserId === "string" ? custom.authorUserId : null;
|
||||
const queued = custom.queueState === "queued" || custom.clientStatus === "queued";
|
||||
const pending = custom.clientStatus === "pending";
|
||||
const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null;
|
||||
const [copied, setCopied] = useState(false);
|
||||
const {
|
||||
isCurrentUser,
|
||||
authorName: resolvedAuthorName,
|
||||
avatarUrl,
|
||||
} = resolveIssueChatHumanAuthor({
|
||||
authorName,
|
||||
authorUserId,
|
||||
currentUserId,
|
||||
userProfileMap,
|
||||
});
|
||||
const authorAvatar = (
|
||||
<Avatar size="sm" className="mt-1 shrink-0">
|
||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={resolvedAuthorName} /> : null}
|
||||
<AvatarFallback>{initialsForName(resolvedAuthorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
const messageBody = (
|
||||
<div className={cn("flex min-w-0 max-w-[85%] flex-col", isCurrentUser && "items-end")}>
|
||||
<div className={cn("mb-1 flex items-center gap-2 px-1", isCurrentUser ? "justify-end" : "justify-start")}>
|
||||
<span className="text-sm font-medium text-foreground">{resolvedAuthorName}</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 max-w-full overflow-hidden break-all rounded-2xl px-4 py-2.5",
|
||||
queued
|
||||
? "bg-amber-50/80 dark:bg-amber-500/10"
|
||||
: "bg-muted",
|
||||
pending && "opacity-80",
|
||||
)}
|
||||
>
|
||||
{queued ? (
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/60 bg-amber-100/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
|
||||
Queued
|
||||
</span>
|
||||
{queueTargetRunId && onInterruptQueued ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 border-red-300 px-2 text-[11px] text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
|
||||
disabled={interruptingQueuedRunId === queueTargetRunId}
|
||||
onClick={() => void onInterruptQueued(queueTargetRunId)}
|
||||
>
|
||||
{interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"}
|
||||
</Button>
|
||||
) : null}
|
||||
{onCancelQueued ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 border-amber-300 px-2 text-[11px] text-amber-900 hover:bg-amber-100/80 hover:text-amber-950 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10"
|
||||
onClick={() => onCancelQueued(commentId)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0 max-w-full space-y-3">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: ({ text }) => <IssueChatTextPart text={text} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pending ? (
|
||||
<div className={cn("mt-1 flex px-1 text-[11px] text-muted-foreground", isCurrentUser ? "justify-end" : "justify-start")}>
|
||||
Sending...
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex items-center gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100",
|
||||
isCurrentUser ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={anchorId ? `#${anchorId}` : undefined}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
onClick={() => {
|
||||
const text = message.content
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n\n");
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root id={anchorId}>
|
||||
<div className="group flex items-start justify-end gap-2.5">
|
||||
<div className="flex min-w-0 max-w-[85%] flex-col items-end">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 max-w-full overflow-hidden break-all rounded-2xl px-4 py-2.5",
|
||||
queued
|
||||
? "bg-amber-50/80 dark:bg-amber-500/10"
|
||||
: "bg-muted",
|
||||
pending && "opacity-80",
|
||||
)}
|
||||
>
|
||||
{queued ? (
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/60 bg-amber-100/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
|
||||
Queued
|
||||
</span>
|
||||
{queueTargetRunId && onInterruptQueued ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 border-red-300 px-2 text-[11px] text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
|
||||
disabled={interruptingQueuedRunId === queueTargetRunId}
|
||||
onClick={() => void onInterruptQueued(queueTargetRunId)}
|
||||
>
|
||||
{interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"}
|
||||
</Button>
|
||||
) : null}
|
||||
{onCancelQueued ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 border-amber-300 px-2 text-[11px] text-amber-900 hover:bg-amber-100/80 hover:text-amber-950 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10"
|
||||
onClick={() => onCancelQueued(commentId)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0 max-w-full space-y-3">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: ({ text }) => <IssueChatTextPart text={text} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pending ? (
|
||||
<div className="mt-1 flex justify-end px-1 text-[11px] text-muted-foreground">Sending...</div>
|
||||
) : (
|
||||
<div className="mt-1 flex items-center justify-end gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={anchorId ? `#${anchorId}` : undefined}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
onClick={() => {
|
||||
const text = message.content
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n\n");
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Avatar size="sm" className="mt-1 shrink-0">
|
||||
<AvatarFallback>You</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className={cn("group flex items-start gap-2.5", isCurrentUser && "justify-end")}>
|
||||
{isCurrentUser ? (
|
||||
<>
|
||||
{messageBody}
|
||||
{authorAvatar}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{authorAvatar}
|
||||
{messageBody}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
@@ -1463,7 +1532,7 @@ function IssueChatFeedbackButtons({
|
||||
}
|
||||
|
||||
function IssueChatSystemMessage() {
|
||||
const { agentMap, currentUserId } = useContext(IssueChatCtx);
|
||||
const { agentMap, currentUserId, userLabelMap } = useContext(IssueChatCtx);
|
||||
const message = useMessage();
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
@@ -1519,11 +1588,11 @@ function IssueChatSystemMessage() {
|
||||
Assignee
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatTimelineAssigneeLabel(assigneeChange.from, agentMap, currentUserId)}
|
||||
{formatTimelineAssigneeLabel(assigneeChange.from, agentMap, currentUserId, userLabelMap)}
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">
|
||||
{formatTimelineAssigneeLabel(assigneeChange.to, agentMap, currentUserId)}
|
||||
{formatTimelineAssigneeLabel(assigneeChange.to, agentMap, currentUserId, userLabelMap)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1855,6 +1924,8 @@ export function IssueChatThread({
|
||||
issueStatus,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
onVote,
|
||||
onAdd,
|
||||
onCancelRun,
|
||||
@@ -1947,6 +2018,7 @@ export function IssueChatThread({
|
||||
projectId,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
}),
|
||||
[
|
||||
comments,
|
||||
@@ -1961,6 +2033,7 @@ export function IssueChatThread({
|
||||
projectId,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
],
|
||||
);
|
||||
const stableMessagesRef = useRef<readonly import("@assistant-ui/react").ThreadMessage[]>([]);
|
||||
@@ -2028,6 +2101,8 @@ export function IssueChatThread({
|
||||
feedbackTermsUrl,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
activeRunIds,
|
||||
onVote,
|
||||
onStopRun,
|
||||
@@ -2043,6 +2118,8 @@ export function IssueChatThread({
|
||||
feedbackTermsUrl,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
activeRunIds,
|
||||
onVote,
|
||||
onStopRun,
|
||||
|
||||
@@ -196,6 +196,8 @@ export function InboxIssueTrailingColumns({
|
||||
workspaceId,
|
||||
workspaceName,
|
||||
assigneeName,
|
||||
assigneeUserName,
|
||||
assigneeUserAvatarUrl,
|
||||
currentUserId,
|
||||
parentIdentifier,
|
||||
parentTitle,
|
||||
@@ -209,6 +211,8 @@ export function InboxIssueTrailingColumns({
|
||||
workspaceId?: string | null;
|
||||
workspaceName: string | null;
|
||||
assigneeName: string | null;
|
||||
assigneeUserName?: string | null;
|
||||
assigneeUserAvatarUrl?: string | null;
|
||||
currentUserId: string | null;
|
||||
parentIdentifier: string | null;
|
||||
parentTitle: string | null;
|
||||
@@ -216,7 +220,7 @@ export function InboxIssueTrailingColumns({
|
||||
onFilterWorkspace?: (workspaceId: string) => void;
|
||||
}) {
|
||||
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
|
||||
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
||||
const userLabel = assigneeUserName ?? formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -243,8 +247,13 @@ export function InboxIssueTrailingColumns({
|
||||
|
||||
if (issue.assigneeUserId) {
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-xs font-medium text-muted-foreground">
|
||||
{userLabel}
|
||||
<span key={column} className="min-w-0 text-xs text-foreground">
|
||||
<Identity
|
||||
name={userLabel}
|
||||
avatarUrl={assigneeUserAvatarUrl}
|
||||
size="sm"
|
||||
className="min-w-0"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||
import { Link } from "@/lib/router";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { accessApi } from "../api/access";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
@@ -187,6 +189,11 @@ export function IssueProperties({
|
||||
queryFn: () => agentsApi.list(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(companyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(companyId!),
|
||||
@@ -257,13 +264,21 @@ export function IssueProperties({
|
||||
() => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds),
|
||||
[agents, recentAssigneeIds],
|
||||
);
|
||||
const userLabelMap = useMemo(
|
||||
() => buildCompanyUserLabelMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
const otherUserOptions = useMemo(
|
||||
() => buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId, issue.createdByUserId] }),
|
||||
[companyMembers?.users, currentUserId, issue.createdByUserId],
|
||||
);
|
||||
|
||||
const assignee = issue.assigneeAgentId
|
||||
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
||||
: null;
|
||||
const reviewerValues = stageParticipantValues(issue.executionPolicy, "review");
|
||||
const approverValues = stageParticipantValues(issue.executionPolicy, "approval");
|
||||
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
|
||||
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId, userLabelMap);
|
||||
const assigneeUserLabel = userLabel(issue.assigneeUserId);
|
||||
const creatorUserLabel = userLabel(issue.createdByUserId);
|
||||
const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => {
|
||||
@@ -499,6 +514,31 @@ export function IssueProperties({
|
||||
{creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
|
||||
</button>
|
||||
)}
|
||||
{otherUserOptions
|
||||
.filter((option) => {
|
||||
if (!assigneeSearch.trim()) return true;
|
||||
const q = assigneeSearch.toLowerCase();
|
||||
return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(q);
|
||||
})
|
||||
.map((option) => {
|
||||
const userId = option.id.slice("user:".length);
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
issue.assigneeUserId === userId && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
onUpdate({ assigneeAgentId: null, assigneeUserId: userId });
|
||||
setAssigneeOpen(false);
|
||||
}}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{sortedAgents
|
||||
.filter((a) => {
|
||||
if (!assigneeSearch.trim()) return true;
|
||||
@@ -571,6 +611,24 @@ export function IssueProperties({
|
||||
{creatorUserLabel ? creatorUserLabel : "Requester"}
|
||||
</button>
|
||||
)}
|
||||
{otherUserOptions
|
||||
.filter((option) => {
|
||||
if (!search.trim()) return true;
|
||||
return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(search.toLowerCase());
|
||||
})
|
||||
.map((option) => (
|
||||
<button
|
||||
key={`${stageType}:${option.id}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.includes(option.id) && "bg-accent",
|
||||
)}
|
||||
onClick={() => toggleExecutionParticipant(stageType, option.id)}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{sortedAgents
|
||||
.filter((agent) => {
|
||||
if (!search.trim()) return true;
|
||||
|
||||
@@ -26,6 +26,11 @@ const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessApi = vi.hoisted(() => ({
|
||||
listMembers: vi.fn(),
|
||||
listUserDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
listSummaries: vi.fn(),
|
||||
@@ -51,6 +56,10 @@ vi.mock("../api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/access", () => ({
|
||||
accessApi: mockAccessApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/execution-workspaces", () => ({
|
||||
executionWorkspacesApi: mockExecutionWorkspacesApi,
|
||||
}));
|
||||
@@ -183,12 +192,16 @@ describe("IssuesList", () => {
|
||||
mockIssuesApi.list.mockReset();
|
||||
mockIssuesApi.listLabels.mockReset();
|
||||
mockAuthApi.getSession.mockReset();
|
||||
mockAccessApi.listMembers.mockReset();
|
||||
mockAccessApi.listUserDirectory.mockReset();
|
||||
mockExecutionWorkspacesApi.list.mockReset();
|
||||
mockExecutionWorkspacesApi.listSummaries.mockReset();
|
||||
mockInstanceSettingsApi.getExperimental.mockReset();
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
|
||||
mockAccessApi.listMembers.mockResolvedValue({ members: [], access: {} });
|
||||
mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] });
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
@@ -498,6 +511,50 @@ describe("IssuesList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows human assignee names from company member profiles", async () => {
|
||||
localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "assignee"]));
|
||||
mockAccessApi.listUserDirectory.mockResolvedValue({
|
||||
users: [
|
||||
{
|
||||
principalId: "user-2",
|
||||
status: "active",
|
||||
user: {
|
||||
id: "user-2",
|
||||
name: "Jordan Lee",
|
||||
email: "jordan@example.com",
|
||||
image: "https://example.com/jordan.png",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const assignedIssue = createIssue({
|
||||
id: "issue-human",
|
||||
identifier: "PAP-12",
|
||||
title: "Human assigned issue",
|
||||
assigneeUserId: "user-2",
|
||||
});
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[assignedIssue]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Jordan Lee");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves stored grouping across refresh when initial assignees are applied", async () => {
|
||||
localStorage.setItem(
|
||||
"paperclip:test-issues:company-1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { accessApi } from "../api/access";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
shouldBlurPageSearchOnEscape,
|
||||
} from "../lib/keyboardShortcuts";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import {
|
||||
applyIssueFilters,
|
||||
@@ -282,6 +284,11 @@ export function IssuesList({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
@@ -376,6 +383,15 @@ export function IssuesList({
|
||||
return agents.find((a) => a.id === id)?.name ?? null;
|
||||
}, [agents]);
|
||||
|
||||
const companyUserLabelMap = useMemo(
|
||||
() => buildCompanyUserLabelMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
const companyUserProfileMap = useMemo(
|
||||
() => buildCompanyUserProfileMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
|
||||
const projectById = useMemo(() => {
|
||||
const map = new Map<string, { name: string; color: string | null }>();
|
||||
for (const project of projects ?? []) {
|
||||
@@ -601,11 +617,11 @@ export function IssuesList({
|
||||
key === "__unassigned"
|
||||
? "Unassigned"
|
||||
: key.startsWith("__user:")
|
||||
? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId) ?? "User")
|
||||
? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId, companyUserLabelMap) ?? "User")
|
||||
: (agentName(key) ?? key.slice(0, 8)),
|
||||
items: groups[key]!,
|
||||
}));
|
||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]);
|
||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap, companyUserLabelMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewState.viewMode !== "list") return;
|
||||
@@ -910,6 +926,14 @@ export function IssuesList({
|
||||
const useDeferredRowRendering = !(hasChildren && isExpanded);
|
||||
const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
|
||||
const parentIssue = issue.parentId ? issueById.get(issue.parentId) ?? null : null;
|
||||
const assigneeUserProfile = issue.assigneeUserId
|
||||
? companyUserProfileMap.get(issue.assigneeUserId) ?? null
|
||||
: null;
|
||||
const assigneeUserLabel = formatAssigneeUserLabel(
|
||||
issue.assigneeUserId,
|
||||
currentUserId,
|
||||
companyUserLabelMap,
|
||||
) ?? assigneeUserProfile?.label ?? null;
|
||||
const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -994,6 +1018,8 @@ export function IssuesList({
|
||||
})}
|
||||
onFilterWorkspace={filterToWorkspace}
|
||||
assigneeName={agentName(issue.assigneeAgentId)}
|
||||
assigneeUserName={assigneeUserLabel}
|
||||
assigneeUserAvatarUrl={assigneeUserProfile?.image ?? null}
|
||||
currentUserId={currentUserId}
|
||||
parentIdentifier={parentIssue?.identifier ?? null}
|
||||
parentTitle={parentIssue?.title ?? null}
|
||||
@@ -1007,18 +1033,18 @@ export function IssuesList({
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
|
||||
className="flex w-full shrink-0 items-center overflow-hidden rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
>
|
||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" className="min-w-0" />
|
||||
) : issue.assigneeUserId ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
|
||||
</span>
|
||||
<Identity
|
||||
name={assigneeUserLabel ?? "User"}
|
||||
avatarUrl={assigneeUserProfile?.image ?? null}
|
||||
size="sm"
|
||||
className="min-w-0"
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
|
||||
249
ui/src/components/Layout.test.tsx
Normal file
249
ui/src/components/Layout.test.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Layout } from "./Layout";
|
||||
|
||||
const mockHealthApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
let currentPathname = "/PAP/dashboard";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Outlet: () => <div>Outlet content</div>,
|
||||
useLocation: () => ({ pathname: currentPathname, search: "", hash: "", state: null }),
|
||||
useNavigate: () => mockNavigate,
|
||||
useNavigationType: () => "PUSH",
|
||||
useParams: () => ({ companyPrefix: "PAP" }),
|
||||
}));
|
||||
|
||||
vi.mock("./CompanyRail", () => ({
|
||||
CompanyRail: () => <div>Company rail</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./Sidebar", () => ({
|
||||
Sidebar: () => <div>Main company nav</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./InstanceSidebar", () => ({
|
||||
InstanceSidebar: () => <div>Instance sidebar</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./CompanySettingsSidebar", () => ({
|
||||
CompanySettingsSidebar: () => <div>Company settings sidebar</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./BreadcrumbBar", () => ({
|
||||
BreadcrumbBar: () => <div>Breadcrumbs</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./PropertiesPanel", () => ({
|
||||
PropertiesPanel: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./CommandPalette", () => ({
|
||||
CommandPalette: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./NewIssueDialog", () => ({
|
||||
NewIssueDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./NewProjectDialog", () => ({
|
||||
NewProjectDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./NewGoalDialog", () => ({
|
||||
NewGoalDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./NewAgentDialog", () => ({
|
||||
NewAgentDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./KeyboardShortcutsCheatsheet", () => ({
|
||||
KeyboardShortcutsCheatsheet: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./ToastViewport", () => ({
|
||||
ToastViewport: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./MobileBottomNav", () => ({
|
||||
MobileBottomNav: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./WorktreeBanner", () => ({
|
||||
WorktreeBanner: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./DevRestartBanner", () => ({
|
||||
DevRestartBanner: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarAccountMenu", () => ({
|
||||
SidebarAccountMenu: () => <div>Account menu</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../context/DialogContext", () => ({
|
||||
useDialog: () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
openOnboarding: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/PanelContext", () => ({
|
||||
usePanel: () => ({
|
||||
togglePanelVisible: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
|
||||
loading: false,
|
||||
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
||||
selectedCompanyId: "company-1",
|
||||
selectionSource: "manual",
|
||||
setSelectedCompanyId: mockSetSelectedCompanyId,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
sidebarOpen: true,
|
||||
setSidebarOpen: mockSetSidebarOpen,
|
||||
toggleSidebar: vi.fn(),
|
||||
isMobile: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useKeyboardShortcuts", () => ({
|
||||
useKeyboardShortcuts: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useCompanyPageMemory", () => ({
|
||||
useCompanyPageMemory: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../api/health", () => ({
|
||||
healthApi: mockHealthApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/company-selection", () => ({
|
||||
shouldSyncCompanySelectionFromRoute: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/instance-settings", () => ({
|
||||
DEFAULT_INSTANCE_SETTINGS_PATH: "/instance/settings/general",
|
||||
normalizeRememberedInstanceSettingsPath: (value: string | null | undefined) =>
|
||||
value ?? "/instance/settings/general",
|
||||
}));
|
||||
|
||||
vi.mock("../lib/main-content-focus", () => ({
|
||||
scheduleMainContentFocus: () => () => undefined,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("Layout", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
currentPathname = "/PAP/dashboard";
|
||||
mockHealthApi.get.mockResolvedValue({
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
version: "1.2.3",
|
||||
});
|
||||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
|
||||
keyboardShortcuts: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not render the deployment explainer in the shared layout", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockHealthApi.get).toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Breadcrumbs");
|
||||
expect(container.textContent).toContain("Outlet content");
|
||||
expect(container.textContent).not.toContain("Authenticated private");
|
||||
expect(container.textContent).not.toContain(
|
||||
"Sign-in is required and this instance is intended for private-network access.",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the company settings sidebar on company settings routes", async () => {
|
||||
currentPathname = "/PAP/company/settings/access";
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Company settings sidebar");
|
||||
expect(container.textContent).not.toContain("Instance sidebar");
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BookOpen, Moon, Settings, Sun } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
||||
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
||||
import { CompanyRail } from "./CompanyRail";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { InstanceSidebar } from "./InstanceSidebar";
|
||||
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
|
||||
import { BreadcrumbBar } from "./BreadcrumbBar";
|
||||
import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
@@ -17,12 +17,12 @@ import { ToastViewport } from "./ToastViewport";
|
||||
import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { WorktreeBanner } from "./WorktreeBanner";
|
||||
import { DevRestartBanner } from "./DevRestartBanner";
|
||||
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||
import { healthApi } from "../api/health";
|
||||
@@ -34,15 +34,12 @@ import {
|
||||
} from "../lib/instance-settings";
|
||||
import {
|
||||
resetNavigationScroll,
|
||||
SIDEBAR_SCROLL_RESET_STATE,
|
||||
shouldResetScrollOnNavigation,
|
||||
} from "../lib/navigation-scroll";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { scheduleMainContentFocus } from "../lib/main-content-focus";
|
||||
import { cn } from "../lib/utils";
|
||||
import { NotFoundPage } from "../pages/NotFound";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
|
||||
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
|
||||
|
||||
@@ -67,12 +64,12 @@ export function Layout() {
|
||||
selectionSource,
|
||||
setSelectedCompanyId,
|
||||
} = useCompany();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { companyPrefix } = useParams<{ companyPrefix: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
const isInstanceSettingsRoute = location.pathname.startsWith("/instance/");
|
||||
const isCompanySettingsRoute = location.pathname.includes("/company/settings");
|
||||
const onboardingTriggered = useRef(false);
|
||||
const lastMainScrollTop = useRef(0);
|
||||
const previousPathname = useRef<string | null>(null);
|
||||
@@ -80,7 +77,6 @@ export function Layout() {
|
||||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||
const matchedCompany = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
const requestedPrefix = companyPrefix.toUpperCase();
|
||||
@@ -341,53 +337,19 @@ export function Layout() {
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<CompanyRail />
|
||||
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||
</div>
|
||||
<div className="border-t border-r border-border px-3 py-2 bg-background">
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="https://docs.paperclip.ing/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
|
||||
>
|
||||
<BookOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">Documentation</span>
|
||||
</a>
|
||||
{health?.version && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="px-2 text-xs text-muted-foreground shrink-0 cursor-default">v</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>v{health.version}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||
<Link
|
||||
to={instanceSettingsTarget}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
aria-label="Instance settings"
|
||||
title="Instance settings"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${nextTheme} mode`}
|
||||
title={`Switch to ${nextTheme} mode`}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{isInstanceSettingsRoute ? (
|
||||
<InstanceSidebar />
|
||||
) : isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : (
|
||||
<Sidebar />
|
||||
)}
|
||||
</div>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode={health?.deploymentMode}
|
||||
instanceSettingsTarget={instanceSettingsTarget}
|
||||
version={health?.version}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col shrink-0">
|
||||
@@ -399,54 +361,20 @@ export function Layout() {
|
||||
sidebarOpen ? "w-60" : "w-0"
|
||||
)}
|
||||
>
|
||||
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-r border-border px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="https://docs.paperclip.ing/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
|
||||
>
|
||||
<BookOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">Documentation</span>
|
||||
</a>
|
||||
{health?.version && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="px-2 text-xs text-muted-foreground shrink-0 cursor-default">v</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>v{health.version}</TooltipContent>
|
||||
</Tooltip>
|
||||
{isInstanceSettingsRoute ? (
|
||||
<InstanceSidebar />
|
||||
) : isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : (
|
||||
<Sidebar />
|
||||
)}
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||
<Link
|
||||
to={instanceSettingsTarget}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
aria-label="Instance settings"
|
||||
title="Instance settings"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${nextTheme} mode`}
|
||||
title={`Switch to ${nextTheme} mode`}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode={health?.deploymentMode}
|
||||
instanceSettingsTarget={instanceSettingsTarget}
|
||||
version={health?.version}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref, buildUserMentionHref } from "@paperclipai/shared";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
@@ -75,17 +75,19 @@ describe("MarkdownBody", () => {
|
||||
expect(html).toContain('alt="Org chart"');
|
||||
});
|
||||
|
||||
it("renders agent, project, and skill mentions as chips", () => {
|
||||
it("renders user, agent, project, and skill mentions as chips", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>
|
||||
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
|
||||
{`[@Taylor](${buildUserMentionHref("user-123")}) [@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
|
||||
</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('href="/company/settings/access"');
|
||||
expect(html).toContain('data-mention-kind="user"');
|
||||
expect(html).toContain('href="/agents/agent-123"');
|
||||
expect(html).toContain('data-mention-kind="agent"');
|
||||
expect(html).toContain("--paperclip-mention-icon-mask");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user