Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/desktop/src/enterprise-capture/lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
dispatchPendingEnterpriseCompletions: vi.fn(),
getFingerprint: vi.fn(),
getWorkspaceAccess: vi.fn(),
syncEnterpriseWorkspace: vi.fn(),
useQuery: vi.fn(),
workspaces: [{ workspaceId: "workspace-1" }],
Expand All @@ -20,9 +21,14 @@ vi.mock("./sync", () => ({
}));
vi.mock("~/auth", () => ({
useAuth: () => ({
supabase: {},
session: { access_token: "access-token", user: { id: "user-1" } },
}),
}));
vi.mock("~/settings/team/client", () => ({
getWorkspaceAccess: mocks.getWorkspaceAccess,
requireTeamContext: (auth: unknown) => auth,
}));
vi.mock("~/env", () => ({
env: { VITE_ENTERPRISE_API_URL: "https://capture.example.test" },
}));
Expand All @@ -42,6 +48,9 @@ describe("EnterpriseCaptureSync", () => {
vi.clearAllMocks();
mocks.workspaces = [{ workspaceId: "workspace-1" }];
mocks.dispatchPendingEnterpriseCompletions.mockResolvedValue(undefined);
mocks.getWorkspaceAccess.mockResolvedValue({
capabilities: ["enterprise.capture"],
});
mocks.syncEnterpriseWorkspace.mockResolvedValue(undefined);
mocks.useQuery.mockReturnValue({});
});
Expand Down Expand Up @@ -97,4 +106,29 @@ describe("EnterpriseCaptureSync", () => {
).toEqual(["workspace-1", "workspace-2"]);
expect(mocks.dispatchPendingEnterpriseCompletions).toHaveBeenCalledOnce();
});

it("syncs only workspaces with the Enterprise capture capability", async () => {
mocks.workspaces = [
{ workspaceId: "workspace-1" },
{ workspaceId: "workspace-2" },
];
mocks.getFingerprint.mockResolvedValue({
status: "ok",
data: "device-1",
});
mocks.getWorkspaceAccess.mockImplementation(
async (_context: unknown, workspaceId: string) => ({
capabilities:
workspaceId === "workspace-2" ? ["enterprise.capture"] : [],
}),
);
render(<EnterpriseCaptureSync />);

await expect(getQueryFn()()).resolves.toBeNull();

expect(mocks.syncEnterpriseWorkspace).toHaveBeenCalledOnce();
expect(mocks.syncEnterpriseWorkspace).toHaveBeenCalledWith(
expect.objectContaining({ workspaceId: "workspace-2" }),
);
});
});
28 changes: 25 additions & 3 deletions apps/desktop/src/enterprise-capture/lifecycle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {

import { useAuth } from "~/auth";
import { env } from "~/env";
import { getWorkspaceAccess, requireTeamContext } from "~/settings/team/client";
import { useMyWorkspacesWithMirror } from "~/settings/team/mirror";

const POLL_INTERVAL_MS = 15_000;
Expand All @@ -28,12 +29,33 @@ export function EnterpriseCaptureSync() {
session?.user.id,
workspaces.data?.map((workspace) => workspace.workspaceId).sort(),
],
enabled: Boolean(serverUrl && session && workspaces.data),
enabled: Boolean(serverUrl && auth.supabase && session && workspaces.data),
queryFn: async () => {
if (!serverUrl || !session || !workspaces.data) return null;
const consumerId = await getDeviceFingerprint(deviceFingerprint);
if (!serverUrl || !auth.supabase || !session || !workspaces.data) {
return null;
}
const context = requireTeamContext(auth);
const eligibleWorkspaces: NonNullable<typeof workspaces.data> = [];
let workspaceError: unknown;
for (const workspace of workspaces.data) {
try {
const access = await getWorkspaceAccess(
context,
workspace.workspaceId,
);
if (access.capabilities.includes("enterprise.capture")) {
eligibleWorkspaces.push(workspace);
}
} catch (error) {
workspaceError ??= error;
}
}
if (eligibleWorkspaces.length === 0) {
if (workspaceError) throw workspaceError;
return null;
}
const consumerId = await getDeviceFingerprint(deviceFingerprint);
for (const workspace of eligibleWorkspaces) {
try {
await syncEnterpriseWorkspace({
serverUrl,
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/settings/team/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
createWorkspace,
createWorkspaceInvitation,
getSeatUsage,
getWorkspaceAccess,
getWorkspacePolicy,
intersectAllowedShareScopes,
listWorkspaceInvitations,
Expand Down Expand Up @@ -47,6 +48,30 @@ describe("requireTeamContext", () => {
});

describe("workspace reads", () => {
it("parses workspace-scoped capabilities and ignores future additions", async () => {
const { context: ctx } = context([
{
workspace_role: "admin",
workspace_tier: "enterprise",
capabilities: [
"team.manage_members",
"enterprise.capture",
"future.capability",
],
seat_limit: 12,
used_seats: 4,
},
]);

await expect(getWorkspaceAccess(ctx, WORKSPACE_ID)).resolves.toEqual({
role: "admin",
tier: "enterprise",
capabilities: ["team.manage_members", "enterprise.capture"],
seatLimit: 12,
usedSeats: 4,
});
});

it("keeps only active members and normalizes their role", async () => {
const { context: ctx } = context([
{
Expand Down
59 changes: 59 additions & 0 deletions apps/desktop/src/settings/team/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,31 @@ export type WorkspaceSeatUsage = {
isBilled: boolean;
};

export const WORKSPACE_CAPABILITIES = [
"team.shared_notes",
"team.manage_workspace",
"team.manage_members",
"team.manage_policies",
"team.view_usage",
"team.custom_subdomain",
"enterprise.sso",
"enterprise.scim",
"enterprise.retention",
"enterprise.audit_logs",
"enterprise.capture",
] as const;

export type WorkspaceCapability = (typeof WORKSPACE_CAPABILITIES)[number];
export type WorkspaceTier = "free" | "team" | "enterprise";

export type WorkspaceAccess = {
role: WorkspaceRole;
tier: WorkspaceTier;
capabilities: WorkspaceCapability[];
seatLimit: number | null;
usedSeats: number;
};

export class TeamError extends Error {
constructor(message = "Workspace request failed") {
super(message);
Expand Down Expand Up @@ -173,6 +198,29 @@ export async function getSeatUsage(
};
}

export async function getWorkspaceAccess(
context: TeamContext,
workspaceId: string,
): Promise<WorkspaceAccess> {
assertWorkspaceId(workspaceId);
const row = rows(
await callRpc(context, "get_workspace_access", {
p_workspace_id: workspaceId,
}),
)[0];
if (!row) throw new TeamError();
const capabilities = Array.isArray(row.capabilities)
? row.capabilities.filter(isWorkspaceCapability)
: [];
return {
role: role(row.workspace_role),
tier: workspaceTier(row.workspace_tier),
capabilities,
seatLimit: typeof row.seat_limit === "number" ? row.seat_limit : null,
usedSeats: typeof row.used_seats === "number" ? row.used_seats : 0,
};
}

const INVITE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
const INVITATION_EMAIL_TIMEOUT_MS = 10_000;

Expand Down Expand Up @@ -551,6 +599,17 @@ function normalizeEmail(value: string) {
return email;
}

function isWorkspaceCapability(value: unknown): value is WorkspaceCapability {
return WORKSPACE_CAPABILITIES.some((capability) => capability === value);
}

function workspaceTier(value: unknown): WorkspaceTier {
if (value !== "free" && value !== "team" && value !== "enterprise") {
throw new TeamError();
}
return value;
}

function inviteTokenValue(value: unknown) {
if (typeof value !== "string" || !INVITE_TOKEN_PATTERN.test(value)) {
throw new TeamError();
Expand Down
76 changes: 76 additions & 0 deletions apps/desktop/src/settings/team/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ const mocks = vi.hoisted(() => ({
isPending: false,
},
client: {
access: {
role: "owner" as const,
tier: "team" as "free" | "team" | "enterprise",
capabilities: [
"team.shared_notes",
"team.manage_workspace",
"team.manage_members",
"team.manage_policies",
"team.view_usage",
"team.custom_subdomain",
] as string[],
seatLimit: 1 as number | null,
usedSeats: 1,
},
members: [] as Array<{
userId: string;
email: string;
Expand Down Expand Up @@ -139,6 +153,7 @@ vi.mock("./client", () => ({
setMemberRole: vi.fn(() => Promise.resolve()),
transferOwnership: vi.fn(() => Promise.resolve()),
getWorkspaceUsageOverview: () => Promise.resolve(mocks.client.usage),
getWorkspaceAccess: () => Promise.resolve(mocks.client.access),
getWorkspacePolicy: mocks.client.getWorkspacePolicy,
setWorkspacePolicy: vi.fn(() => Promise.resolve()),
setWorkspaceShareSlug: mocks.client.setWorkspaceShareSlug,
Expand Down Expand Up @@ -181,6 +196,20 @@ describe("SettingsTeam", () => {
usedSeats: 1,
isBilled: true,
};
mocks.client.access = {
role: "owner",
tier: "team",
capabilities: [
"team.shared_notes",
"team.manage_workspace",
"team.manage_members",
"team.manage_policies",
"team.view_usage",
"team.custom_subdomain",
],
seatLimit: 1,
usedSeats: 1,
};
mocks.client.revokeInvitation.mockClear();
mocks.client.renameWorkspace.mockClear();
mocks.client.setWorkspaceLogo.mockClear();
Expand All @@ -207,6 +236,9 @@ describe("SettingsTeam", () => {
it("keeps an unbilled workspace accessible and offers Team checkout", async () => {
mocks.client.usage.isBilled = false;
mocks.client.usage.seatLimit = null;
mocks.client.access.tier = "free";
mocks.client.access.capabilities = [];
mocks.client.access.seatLimit = null;
mocks.workspaces.data = [
{
workspaceId: "00000000-0000-4000-8000-000000000001",
Expand Down Expand Up @@ -392,6 +424,50 @@ describe("SettingsTeam", () => {
);
});

it("keeps Enterprise policy controls hidden on Team", async () => {
mocks.workspaces.data = [
{
workspaceId: "00000000-0000-4000-8000-000000000001",
name: "Fastrepl",
ownerUserId: "user-1",
role: "owner",
},
];

renderTeam();

await screen.findByText("Policies");
expect(screen.queryByText("Require SSO")).toBeNull();
expect(screen.queryByText("Retention (days)")).toBeNull();
expect(screen.queryByText("SCIM bearer token")).toBeNull();
});

it("shows Enterprise policy controls only with Enterprise capabilities", async () => {
mocks.client.access.tier = "enterprise";
mocks.client.access.capabilities = [
...mocks.client.access.capabilities,
"enterprise.sso",
"enterprise.scim",
"enterprise.retention",
"enterprise.audit_logs",
"enterprise.capture",
];
mocks.workspaces.data = [
{
workspaceId: "00000000-0000-4000-8000-000000000001",
name: "Fastrepl",
ownerUserId: "user-1",
role: "owner",
},
];

renderTeam();

expect(await screen.findByText("Require SSO")).toBeTruthy();
expect(screen.getByText("Retention (days)")).toBeTruthy();
expect(screen.getByText("SCIM bearer token")).toBeTruthy();
});

it("resends a pending invitation by delivering a fresh invite", async () => {
mocks.workspaces.data = [
{
Expand Down
Loading
Loading