feat(self-host): share one workspace across Cloudflare Access users (#467)
This commit is contained in:
parent
f14aa4c746
commit
17e7515e82
@ -80,6 +80,8 @@ pnpm deploy:selfhost --yes
|
||||
|
||||
Add the teammate to `ACCESS_ALLOWED_EMAILS` in `.env.selfhost` and redeploy. Dashboard edits to that Access policy are overwritten on the next deploy. (If you manage the Access application yourself, edit its Allow policy in Zero Trust instead.)
|
||||
|
||||
Everyone allowed through Cloudflare Access works in one shared workspace and sees the same projects. Deployments upgraded from older versions (which gave each user a separate workspace) show a one-time dashboard banner — clicking it migrates all previous per-user work into the shared workspace.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Login fails: re-check `ACCESS_ALLOWED_EMAILS` in `.env.selfhost` and redeploy.
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
GscCard,
|
||||
} from "@/client/features/dashboard/DashboardCards";
|
||||
import { McpConnectCard } from "@/client/features/dashboard/McpConnectCard";
|
||||
import { WorkspaceMergeBanner } from "@/client/features/dashboard/WorkspaceMergeBanner";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import type { DashboardActivation } from "@/server/features/dashboard/services/DashboardService";
|
||||
import {
|
||||
@ -306,6 +307,8 @@ export function DashboardPage({ projectId }: { projectId: string }) {
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-5">
|
||||
<h1 className="text-2xl font-semibold">Dashboard</h1>
|
||||
|
||||
<WorkspaceMergeBanner />
|
||||
|
||||
<OnboardingChecklist projectId={projectId} activation={activation} />
|
||||
|
||||
{/* Every card is half width on large screens (only the checklist spans).
|
||||
|
||||
64
src/client/features/dashboard/WorkspaceMergeBanner.tsx
Normal file
64
src/client/features/dashboard/WorkspaceMergeBanner.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
import {
|
||||
getWorkspaceMergeStatus,
|
||||
mergeLegacyWorkspaces,
|
||||
} from "@/serverFunctions/workspace";
|
||||
|
||||
// Shown on self-hosted Cloudflare Access deployments that still have per-user
|
||||
// workspaces from before the shared workspace existed. The server decides
|
||||
// visibility (AUTH_MODE is a runtime var there); hosted builds skip the query
|
||||
// entirely since the mode is known at build time.
|
||||
export function WorkspaceMergeBanner() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["workspaceMergeStatus"],
|
||||
queryFn: () => getWorkspaceMergeStatus(),
|
||||
enabled: !isHostedClientAuthMode(),
|
||||
});
|
||||
|
||||
const mergeMutation = useMutation({
|
||||
mutationFn: () => mergeLegacyWorkspaces(),
|
||||
onSuccess: ({ mergedWorkspaces }) => {
|
||||
toast.success(
|
||||
`Migrated ${mergedWorkspaces} workspace${mergedWorkspaces === 1 ? "" : "s"} into the shared workspace.`,
|
||||
);
|
||||
// The merge changes projects, connections, and the banner's own status —
|
||||
// refetch everything rather than enumerating keys.
|
||||
void queryClient.invalidateQueries();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(
|
||||
getStandardErrorMessage(
|
||||
error,
|
||||
"Couldn't migrate the workspaces. Try again.",
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
if (!statusQuery.data || statusQuery.data.legacyWorkspaceCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-warning/40 bg-warning/10 p-5">
|
||||
<p className="max-w-3xl text-sm">
|
||||
When self-hosting on Cloudflare, there was a bug where each user had
|
||||
their own workspace. It was intended for all users to be in one
|
||||
workspace. Clicking the button below will migrate everyone's
|
||||
previous work into this shared workspace.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm mt-4"
|
||||
disabled={mergeMutation.isPending}
|
||||
onClick={() => mergeMutation.mutate()}
|
||||
>
|
||||
{mergeMutation.isPending ? "Migrating…" : "Migrate workspaces"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,7 +3,7 @@ import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { validateTeamDomain } from "@/shared/selfhost-checks";
|
||||
import { classifyAccessVerificationError } from "./accessTokenErrors";
|
||||
import { resolveDelegatedContext } from "./delegated";
|
||||
import { resolveSharedWorkspaceContext } from "./delegated";
|
||||
import type { EnsuredUserContext } from "./types";
|
||||
|
||||
const jwksByTeamDomain = new Map<
|
||||
@ -94,5 +94,5 @@ export async function resolveCloudflareAccessContext(
|
||||
throw new AppError("UNAUTHENTICATED");
|
||||
}
|
||||
|
||||
return resolveDelegatedContext(userId, userEmail);
|
||||
return resolveSharedWorkspaceContext(userId, userEmail);
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { db } from "@/db";
|
||||
import { user } from "@/db/schema";
|
||||
import { ensureDelegatedOrganizationForUser } from "@/server/auth/delegated-organization";
|
||||
import {
|
||||
ensureDelegatedOrganizationForUser,
|
||||
ensureSharedWorkspaceOrganization,
|
||||
} from "@/server/auth/delegated-organization";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { EnsuredUserContext } from "./types";
|
||||
|
||||
@ -52,7 +55,7 @@ async function ensureUserRecord(userId: string, userEmail: string) {
|
||||
return existing.email;
|
||||
}
|
||||
|
||||
export async function resolveDelegatedContext(
|
||||
async function resolveDelegatedContext(
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
): Promise<EnsuredUserContext> {
|
||||
@ -71,6 +74,24 @@ export async function resolveDelegatedContext(
|
||||
};
|
||||
}
|
||||
|
||||
// Cloudflare Access mode: everyone the Access policy lets in works in one
|
||||
// shared workspace, keeping their own user identity. Per-user workspaces were
|
||||
// the pre-shared-workspace behavior; workspace-merge.ts folds those in.
|
||||
export async function resolveSharedWorkspaceContext(
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
): Promise<EnsuredUserContext> {
|
||||
const ensuredEmail = await ensureUserRecord(userId, userEmail);
|
||||
const organizationId = await ensureSharedWorkspaceOrganization();
|
||||
|
||||
return {
|
||||
userId,
|
||||
userEmail: ensuredEmail,
|
||||
emailVerified: true,
|
||||
organizationId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveLocalNoAuthContext(): Promise<EnsuredUserContext> {
|
||||
return resolveDelegatedContext(LOCAL_ADMIN_USER_ID, LOCAL_ADMIN_EMAIL);
|
||||
}
|
||||
|
||||
@ -1,6 +1,21 @@
|
||||
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||
import { slugify, toHex } from "./org-slug";
|
||||
|
||||
// Every Cloudflare Access user on a deployment shares this one workspace. The
|
||||
// id deliberately lacks the "delegated-" prefix so the legacy per-user pattern
|
||||
// below can be matched (and merged) without excluding it.
|
||||
export const SHARED_WORKSPACE_ORGANIZATION_ID = "shared-workspace";
|
||||
|
||||
export async function ensureSharedWorkspaceOrganization() {
|
||||
await AuthRepository.upsertDelegatedOrganization({
|
||||
id: SHARED_WORKSPACE_ORGANIZATION_ID,
|
||||
name: "Shared workspace",
|
||||
slug: SHARED_WORKSPACE_ORGANIZATION_ID,
|
||||
});
|
||||
|
||||
return SHARED_WORKSPACE_ORGANIZATION_ID;
|
||||
}
|
||||
|
||||
function getDelegatedOrganizationId(userId: string) {
|
||||
return `delegated-${userId}`;
|
||||
}
|
||||
|
||||
221
src/server/auth/workspace-merge.test.ts
Normal file
221
src/server/auth/workspace-merge.test.ts
Normal file
@ -0,0 +1,221 @@
|
||||
import { createClient, type Client } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import type * as WorkspaceMergeModule from "./workspace-merge";
|
||||
|
||||
// Real in-memory SQLite so the repoint-before-delete ordering, the Default
|
||||
// rename against the partial unique index, and the org-delete cascades run
|
||||
// against actual SQL — the parts a mocked db can't see.
|
||||
|
||||
const mockEnv = vi.hoisted(
|
||||
() =>
|
||||
({ DATABASE_PROVIDER: "d1" }) as {
|
||||
DATABASE_PROVIDER: string;
|
||||
AUTH_MODE?: string;
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock("cloudflare:workers", () => ({ env: mockEnv }));
|
||||
|
||||
let client: Client;
|
||||
let testDb: ReturnType<typeof drizzle>;
|
||||
let WorkspaceMergeService: typeof WorkspaceMergeModule.WorkspaceMergeService;
|
||||
|
||||
async function rows(sql: string) {
|
||||
return (await client.execute(sql)).rows;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
client = createClient({ url: "file::memory:" });
|
||||
testDb = drizzle(client);
|
||||
// testDb only exists at runtime, so the module under test must load after
|
||||
// these mocks — the one sanctioned use of doMock + dynamic import.
|
||||
vi.doMock("@/db", () => ({ db: testDb }));
|
||||
vi.doMock("@/db/d1/client", () => ({ d1Db: testDb }));
|
||||
vi.doMock("@/db/pg/client", () => ({ pgDb: null }));
|
||||
|
||||
await client.executeMultiple(`
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE organization (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT
|
||||
);
|
||||
CREATE TABLE projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
domain TEXT,
|
||||
archived_at TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX projects_one_default_per_organization_idx
|
||||
ON projects (organization_id)
|
||||
WHERE name = 'Default' AND domain IS NULL AND archived_at IS NULL;
|
||||
CREATE TABLE user_onboarding_answers (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE organization_activation_state (
|
||||
organization_id TEXT PRIMARY KEY REFERENCES organization(id) ON DELETE CASCADE,
|
||||
first_mcp_authorized_at TEXT,
|
||||
first_mcp_tool_call_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE gsc_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
|
||||
site_url TEXT NOT NULL,
|
||||
connected_by_user_id TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE ga4_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
({ WorkspaceMergeService } = await import("./workspace-merge"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
client.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockEnv.AUTH_MODE = "cloudflare_access";
|
||||
await client.executeMultiple(`
|
||||
DELETE FROM projects;
|
||||
DELETE FROM user_onboarding_answers;
|
||||
DELETE FROM organization_activation_state;
|
||||
DELETE FROM gsc_connections;
|
||||
DELETE FROM ga4_connections;
|
||||
DELETE FROM organization;
|
||||
`);
|
||||
});
|
||||
|
||||
async function seedLegacyWorkspaces() {
|
||||
await client.executeMultiple(`
|
||||
INSERT INTO organization (id, name, slug) VALUES
|
||||
('shared-workspace', 'Shared workspace', 'shared-workspace'),
|
||||
('delegated-u1', 'ben workspace', 'delegated-ben-1'),
|
||||
('delegated-u2', 'sam workspace', 'delegated-sam-2'),
|
||||
('org_hosted', 'Hosted org', 'hosted');
|
||||
INSERT INTO projects (id, organization_id, name, domain, archived_at) VALUES
|
||||
('p-shared', 'shared-workspace', 'Default', NULL, NULL),
|
||||
('p1-default', 'delegated-u1', 'Default', NULL, NULL),
|
||||
('p1-acme', 'delegated-u1', 'Acme', 'acme.com', NULL),
|
||||
('p2-default', 'delegated-u2', 'Default', NULL, NULL),
|
||||
('p-hosted', 'org_hosted', 'Default', NULL, NULL);
|
||||
INSERT INTO user_onboarding_answers (user_id, organization_id) VALUES
|
||||
('u1', 'delegated-u1');
|
||||
INSERT INTO organization_activation_state
|
||||
(organization_id, first_mcp_authorized_at, first_mcp_tool_call_at) VALUES
|
||||
('delegated-u1', '2026-03-01 09:00:00', '2026-03-02 09:00:00'),
|
||||
('delegated-u2', '2026-01-02 09:00:00', NULL);
|
||||
INSERT INTO gsc_connections
|
||||
(id, project_id, organization_id, site_url, connected_by_user_id) VALUES
|
||||
('gsc1', 'p1-acme', 'delegated-u1', 'sc-domain:acme.com', 'u1');
|
||||
`);
|
||||
}
|
||||
|
||||
describe("WorkspaceMergeService", () => {
|
||||
it("folds legacy workspaces into the shared workspace and leaves other orgs alone", async () => {
|
||||
await seedLegacyWorkspaces();
|
||||
|
||||
await expect(WorkspaceMergeService.countLegacyWorkspaces()).resolves.toBe(
|
||||
2,
|
||||
);
|
||||
await expect(
|
||||
WorkspaceMergeService.mergeLegacyWorkspaces(),
|
||||
).resolves.toEqual({ mergedWorkspaces: 2 });
|
||||
|
||||
expect(
|
||||
await rows("SELECT id, organization_id, name FROM projects ORDER BY id"),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "p-hosted",
|
||||
organization_id: "org_hosted",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "p-shared",
|
||||
organization_id: "shared-workspace",
|
||||
name: "Default",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "p1-acme",
|
||||
organization_id: "shared-workspace",
|
||||
name: "Acme",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "p1-default",
|
||||
organization_id: "shared-workspace",
|
||||
name: "Default (ben)",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "p2-default",
|
||||
organization_id: "shared-workspace",
|
||||
name: "Default (sam)",
|
||||
}),
|
||||
]);
|
||||
|
||||
// Legacy orgs are gone; their activation rows cascaded away after the
|
||||
// earliest milestones were folded into the shared row.
|
||||
expect(await rows("SELECT id FROM organization ORDER BY id")).toEqual([
|
||||
expect.objectContaining({ id: "org_hosted" }),
|
||||
expect.objectContaining({ id: "shared-workspace" }),
|
||||
]);
|
||||
expect(await rows("SELECT * FROM organization_activation_state")).toEqual([
|
||||
expect.objectContaining({
|
||||
organization_id: "shared-workspace",
|
||||
first_mcp_authorized_at: "2026-01-02 09:00:00",
|
||||
first_mcp_tool_call_at: "2026-03-02 09:00:00",
|
||||
}),
|
||||
]);
|
||||
expect(await rows("SELECT organization_id FROM gsc_connections")).toEqual([
|
||||
expect.objectContaining({ organization_id: "shared-workspace" }),
|
||||
]);
|
||||
expect(
|
||||
await rows("SELECT organization_id FROM user_onboarding_answers"),
|
||||
).toEqual([
|
||||
expect.objectContaining({ organization_id: "shared-workspace" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses to run outside cloudflare_access mode", async () => {
|
||||
await seedLegacyWorkspaces();
|
||||
mockEnv.AUTH_MODE = "local_noauth";
|
||||
|
||||
await expect(
|
||||
WorkspaceMergeService.mergeLegacyWorkspaces(),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
// Nothing was touched.
|
||||
expect(await rows("SELECT count(*) AS n FROM organization")).toEqual([
|
||||
expect.objectContaining({ n: 4 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("is a no-op when run again", async () => {
|
||||
await seedLegacyWorkspaces();
|
||||
await WorkspaceMergeService.mergeLegacyWorkspaces();
|
||||
|
||||
await expect(
|
||||
WorkspaceMergeService.mergeLegacyWorkspaces(),
|
||||
).resolves.toEqual({ mergedWorkspaces: 0 });
|
||||
await expect(WorkspaceMergeService.countLegacyWorkspaces()).resolves.toBe(
|
||||
0,
|
||||
);
|
||||
// Nothing renamed twice.
|
||||
expect(
|
||||
await rows("SELECT name FROM projects WHERE id = 'p1-default'"),
|
||||
).toEqual([expect.objectContaining({ name: "Default (ben)" })]);
|
||||
});
|
||||
});
|
||||
163
src/server/auth/workspace-merge.ts
Normal file
163
src/server/auth/workspace-merge.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { and, eq, inArray, isNull, like } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { runBatch } from "@/db/runBatch";
|
||||
import { getAuthMode } from "@/lib/auth-mode";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import {
|
||||
ga4Connections,
|
||||
gscConnections,
|
||||
organization,
|
||||
organizationActivationState,
|
||||
projects,
|
||||
userOnboardingAnswers,
|
||||
} from "@/db/schema";
|
||||
import { SHARED_WORKSPACE_ORGANIZATION_ID } from "./delegated-organization";
|
||||
|
||||
// Earliest-wins pick across nullable UTC timestamp strings (string compare is
|
||||
// chronological for these).
|
||||
function earliest(values: (string | null)[]) {
|
||||
return (
|
||||
values.filter((value): value is string => value !== null).toSorted()[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// Before the shared workspace, every Cloudflare Access user got their own
|
||||
// `delegated-${userId}` organization (and local_noauth got delegated-local-admin,
|
||||
// which an operator switching modes wants folded in too). The shared workspace
|
||||
// id has no such prefix, so this matches exactly the legacy set.
|
||||
const legacyWorkspaceFilter = like(organization.id, "delegated-%");
|
||||
|
||||
async function countLegacyWorkspaces() {
|
||||
const rows = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(legacyWorkspaceFilter);
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
// Folds every legacy per-user workspace into the shared workspace: projects
|
||||
// (which carry all their keywords, rank tracking, audits, and agent data with
|
||||
// them) plus the org-scoped rows, then deletes the emptied legacy orgs. Safe
|
||||
// to re-run and safe under concurrent clicks — every statement is a no-op once
|
||||
// the legacy orgs are gone.
|
||||
async function mergeLegacyWorkspaces() {
|
||||
// Hard gate, independent of any caller-side check: outside cloudflare_access
|
||||
// mode the "delegated-%" orgs are either live (local_noauth resolves to
|
||||
// delegated-local-admin — merging would strand its data in a workspace that
|
||||
// mode never shows) or should not exist at all (hosted).
|
||||
if (getAuthMode(env.AUTH_MODE) !== "cloudflare_access") {
|
||||
throw new AppError(
|
||||
"FORBIDDEN",
|
||||
"Workspace merge is only available in cloudflare_access auth mode.",
|
||||
);
|
||||
}
|
||||
|
||||
const legacyOrgs = await db
|
||||
.select({ id: organization.id, name: organization.name })
|
||||
.from(organization)
|
||||
.where(legacyWorkspaceFilter);
|
||||
|
||||
if (legacyOrgs.length === 0) {
|
||||
return { mergedWorkspaces: 0 };
|
||||
}
|
||||
|
||||
const legacyIds = legacyOrgs.map((org) => org.id);
|
||||
// Legacy org names are "<email localpart> workspace" — reuse the localpart
|
||||
// to label renamed projects with their previous owner.
|
||||
const ownerLabelByOrgId = new Map(
|
||||
legacyOrgs.map((org) => [org.id, org.name.replace(/ workspace$/, "")]),
|
||||
);
|
||||
|
||||
// Each legacy workspace auto-created a ("Default", no-domain) project, and
|
||||
// the shared workspace allows only one active such project. Rename them
|
||||
// before repointing so the partial unique index can't reject the move —
|
||||
// renaming (never deleting) means a Default that holds work survives intact.
|
||||
const conflictingDefaults = await db
|
||||
.select({ id: projects.id, organizationId: projects.organizationId })
|
||||
.from(projects)
|
||||
.where(
|
||||
and(
|
||||
inArray(projects.organizationId, legacyIds),
|
||||
eq(projects.name, "Default"),
|
||||
isNull(projects.domain),
|
||||
isNull(projects.archivedAt),
|
||||
),
|
||||
);
|
||||
|
||||
// Earliest-wins merge of activation milestones across the shared row and
|
||||
// all legacy rows.
|
||||
const activationRows = await db
|
||||
.select()
|
||||
.from(organizationActivationState)
|
||||
.where(
|
||||
inArray(organizationActivationState.organizationId, [
|
||||
SHARED_WORKSPACE_ORGANIZATION_ID,
|
||||
...legacyIds,
|
||||
]),
|
||||
);
|
||||
const mergedActivation = {
|
||||
firstMcpAuthorizedAt: earliest(
|
||||
activationRows.map((row) => row.firstMcpAuthorizedAt),
|
||||
),
|
||||
firstMcpToolCallAt: earliest(
|
||||
activationRows.map((row) => row.firstMcpToolCallAt),
|
||||
),
|
||||
};
|
||||
|
||||
const repointToShared = { organizationId: SHARED_WORKSPACE_ORGANIZATION_ID };
|
||||
|
||||
await runBatch((tx) => [
|
||||
...conflictingDefaults.map((project) =>
|
||||
tx
|
||||
.update(projects)
|
||||
.set({
|
||||
name: `Default (${ownerLabelByOrgId.get(project.organizationId) ?? "imported"})`,
|
||||
})
|
||||
.where(eq(projects.id, project.id)),
|
||||
),
|
||||
tx
|
||||
.update(projects)
|
||||
.set(repointToShared)
|
||||
.where(inArray(projects.organizationId, legacyIds)),
|
||||
tx
|
||||
.update(userOnboardingAnswers)
|
||||
.set(repointToShared)
|
||||
.where(inArray(userOnboardingAnswers.organizationId, legacyIds)),
|
||||
tx
|
||||
.update(gscConnections)
|
||||
.set(repointToShared)
|
||||
.where(inArray(gscConnections.organizationId, legacyIds)),
|
||||
tx
|
||||
.update(ga4Connections)
|
||||
.set(repointToShared)
|
||||
.where(inArray(ga4Connections.organizationId, legacyIds)),
|
||||
...(activationRows.length > 0
|
||||
? [
|
||||
tx
|
||||
.insert(organizationActivationState)
|
||||
.values({
|
||||
organizationId: SHARED_WORKSPACE_ORGANIZATION_ID,
|
||||
...mergedActivation,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: organizationActivationState.organizationId,
|
||||
set: mergedActivation,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
// Everything user-visible is repointed above; deleting the legacy orgs
|
||||
// cascades away only the per-org leftovers (members, billing status,
|
||||
// legacy activation rows).
|
||||
tx.delete(organization).where(inArray(organization.id, legacyIds)),
|
||||
]);
|
||||
|
||||
return { mergedWorkspaces: legacyOrgs.length };
|
||||
}
|
||||
|
||||
export const WorkspaceMergeService = {
|
||||
countLegacyWorkspaces,
|
||||
mergeLegacyWorkspaces,
|
||||
} as const;
|
||||
32
src/serverFunctions/workspace.ts
Normal file
32
src/serverFunctions/workspace.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { getAuthMode } from "@/lib/auth-mode";
|
||||
import { WorkspaceMergeService } from "@/server/auth/workspace-merge";
|
||||
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||
|
||||
// Legacy per-user workspaces only ever existed in cloudflare_access mode, and
|
||||
// the merge must not be reachable anywhere else. AUTH_MODE is a runtime var on
|
||||
// self-host deployments, so the client can't gate this itself — the status
|
||||
// response is what hides the banner in other modes.
|
||||
function isCloudflareAccessMode() {
|
||||
return getAuthMode(env.AUTH_MODE) === "cloudflare_access";
|
||||
}
|
||||
|
||||
export const getWorkspaceMergeStatus = createServerFn({ method: "POST" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.handler(async () => {
|
||||
if (!isCloudflareAccessMode()) {
|
||||
return { legacyWorkspaceCount: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
legacyWorkspaceCount: await WorkspaceMergeService.countLegacyWorkspaces(),
|
||||
};
|
||||
});
|
||||
|
||||
// Any authenticated user may run the merge: everyone behind the same Access
|
||||
// policy is equally trusted in this deployment model. The service itself
|
||||
// refuses to run outside cloudflare_access mode.
|
||||
export const mergeLegacyWorkspaces = createServerFn({ method: "POST" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.handler(async () => WorkspaceMergeService.mergeLegacyWorkspaces());
|
||||
Loading…
x
Reference in New Issue
Block a user