diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx index b1b53c7112f..a3e8e0435c9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx @@ -125,7 +125,7 @@ export function CreateTaskDialog({ setIsCreating(true); try { - const result = await apiTrpcClient.task.createFromUi.mutate({ + const result = await apiTrpcClient.task.create.mutate({ title: title.trim(), description: description.trim() || null, statusId, diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts index 66ccea64bd1..d969fd12eff 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts @@ -226,11 +226,6 @@ function createOrgCollections(organizationId: string): OrgCollections { columnMapper, }, getKey: (item) => item.id, - onInsert: async ({ transaction }) => { - const item = transaction.mutations[0].modified; - const result = await apiClient.task.create.mutate(item); - return { txid: result.txid }; - }, onUpdate: async ({ transaction }) => { const { original, changes } = transaction.mutations[0]; const result = await apiClient.task.update.mutate({ diff --git a/apps/mobile/lib/collections/collections.ts b/apps/mobile/lib/collections/collections.ts index 44be2fc32c7..ebad3084bdd 100644 --- a/apps/mobile/lib/collections/collections.ts +++ b/apps/mobile/lib/collections/collections.ts @@ -60,11 +60,6 @@ function createOrgCollections(organizationId: string): OrgCollections { columnMapper, }, getKey: (item) => item.id, - onInsert: async ({ transaction }) => { - const item = transaction.mutations[0].modified; - const result = await apiClient.task.create.mutate(item); - return { txid: result.txid }; - }, onUpdate: async ({ transaction }) => { const { original, changes } = transaction.mutations[0]; const result = await apiClient.task.update.mutate({ diff --git a/apps/web/src/app/cli/authorize/page.tsx b/apps/web/src/app/cli/authorize/page.tsx index c38eb5057ba..f72d67ba4f5 100644 --- a/apps/web/src/app/cli/authorize/page.tsx +++ b/apps/web/src/app/cli/authorize/page.tsx @@ -1,7 +1,6 @@ import { auth } from "@superset/auth/server"; import { headers } from "next/headers"; import Image from "next/image"; -import { redirect } from "next/navigation"; import { env } from "@/env"; import { api } from "@/trpc/server"; @@ -11,6 +10,18 @@ interface CliAuthorizePageProps { searchParams: Promise>; } +function isLoopbackRedirectUri(value: string): boolean { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return false; + } + if (parsed.protocol !== "http:") return false; + if (parsed.username !== "" || parsed.password !== "") return false; + return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost"; +} + export default async function CliAuthorizePage({ searchParams, }: CliAuthorizePageProps) { @@ -18,16 +29,16 @@ export default async function CliAuthorizePage({ headers: await headers(), }); - const params = await searchParams; - if (!session) { - const returnUrl = `/cli/authorize?${new URLSearchParams(params).toString()}`; - redirect(`/sign-in?redirect=${encodeURIComponent(returnUrl)}`); + // Defensive — middleware should have caught this. + return null; } - const { state, redirect_uri } = params; + const params = await searchParams; + const state = params.state; + const redirectUri = params.redirect_uri; - if (!state || !redirect_uri) { + if (!state || !redirectUri) { return (

@@ -37,10 +48,7 @@ export default async function CliAuthorizePage({ ); } - if ( - !redirect_uri.startsWith("http://127.0.0.1:") && - !redirect_uri.startsWith("http://localhost:") - ) { + if (!isLoopbackRedirectUri(redirectUri)) { return (

@@ -69,7 +77,7 @@ export default async function CliAuthorizePage({

({ id: organization.id, diff --git a/apps/web/src/app/oauth/consent/page.tsx b/apps/web/src/app/oauth/consent/page.tsx index 60853d827e9..ab36fed2111 100644 --- a/apps/web/src/app/oauth/consent/page.tsx +++ b/apps/web/src/app/oauth/consent/page.tsx @@ -2,7 +2,6 @@ import { auth } from "@superset/auth/server"; import { db } from "@superset/db/client"; import { headers } from "next/headers"; import Image from "next/image"; -import { redirect } from "next/navigation"; import { env } from "@/env"; import { api } from "@/trpc/server"; @@ -18,13 +17,13 @@ export default async function ConsentPage({ searchParams }: ConsentPageProps) { }); if (!session) { - const params = await searchParams; - const returnUrl = `/oauth/consent?${new URLSearchParams(params).toString()}`; - redirect(`/sign-in?redirect=${encodeURIComponent(returnUrl)}`); + // Defensive — middleware should have caught this. + return null; } const params = await searchParams; - const { client_id, scope } = params; + const client_id = params.client_id; + const scope = params.scope; if (!client_id) { return ( diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index da63d0012db..728c5ce6d25 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -29,7 +29,9 @@ export default async function proxy(req: NextRequest) { } if (!session && !isPublicRoute(pathname)) { - return NextResponse.redirect(new URL("/sign-in", req.url)); + const signInUrl = new URL("/sign-in", req.url); + signInUrl.searchParams.set("redirect", pathname + req.nextUrl.search); + return NextResponse.redirect(signInUrl); } return NextResponse.next(); diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index 9052501ac9f..ec8fa2c76f2 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -18,7 +18,16 @@ import { export const projectRouter = router({ list: protectedProcedure.query(({ ctx }) => { - return ctx.db.select({ id: projects.id }).from(projects).all(); + return ctx.db + .select({ + id: projects.id, + repoPath: projects.repoPath, + repoOwner: projects.repoOwner, + repoName: projects.repoName, + repoUrl: projects.repoUrl, + }) + .from(projects) + .all(); }), get: protectedProcedure diff --git a/packages/trpc/src/router/automation/automation.ts b/packages/trpc/src/router/automation/automation.ts index 18b0f1ba2e8..8b07ec3864e 100644 --- a/packages/trpc/src/router/automation/automation.ts +++ b/packages/trpc/src/router/automation/automation.ts @@ -4,6 +4,7 @@ import { automations, type SelectSubscription, v2Hosts, + v2Projects, v2UsersHosts, v2Workspaces, } from "@superset/db/schema"; @@ -92,11 +93,12 @@ async function verifyHostAccess( async function verifyWorkspaceInOrg( organizationId: string, workspaceId: string, -): Promise { +): Promise<{ id: string; projectId: string }> { const [workspace] = await db .select({ id: v2Workspaces.id, organizationId: v2Workspaces.organizationId, + projectId: v2Workspaces.projectId, }) .from(v2Workspaces) .where(eq(v2Workspaces.id, workspaceId)) @@ -108,6 +110,22 @@ async function verifyWorkspaceInOrg( message: "Workspace not found", }); } + return { id: workspace.id, projectId: workspace.projectId }; +} + +async function verifyProjectInOrg(organizationId: string, projectId: string) { + const [project] = await db + .select({ id: v2Projects.id, organizationId: v2Projects.organizationId }) + .from(v2Projects) + .where(eq(v2Projects.id, projectId)) + .limit(1); + + if (!project || project.organizationId !== organizationId) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } } async function getAutomationForUser( @@ -192,8 +210,29 @@ export const automationRouter = { input.targetHostId, ); } + + let v2ProjectId = input.v2ProjectId; if (input.v2WorkspaceId) { - await verifyWorkspaceInOrg(organizationId, input.v2WorkspaceId); + const workspace = await verifyWorkspaceInOrg( + organizationId, + input.v2WorkspaceId, + ); + if (v2ProjectId && v2ProjectId !== workspace.projectId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "v2ProjectId does not match the workspace's project", + }); + } + v2ProjectId = workspace.projectId; + } else if (v2ProjectId) { + await verifyProjectInOrg(organizationId, v2ProjectId); + } + + if (!v2ProjectId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "v2ProjectId required when v2WorkspaceId is not provided", + }); } const dtstart = input.dtstart ?? new Date(); @@ -212,7 +251,7 @@ export const automationRouter = { prompt: input.prompt, agentConfig: input.agentConfig, targetHostId: input.targetHostId ?? null, - v2ProjectId: input.v2ProjectId, + v2ProjectId, v2WorkspaceId: input.v2WorkspaceId ?? null, rrule: input.rrule, dtstart, diff --git a/packages/trpc/src/router/automation/schema.ts b/packages/trpc/src/router/automation/schema.ts index a715ff013ef..e283ffcc784 100644 --- a/packages/trpc/src/router/automation/schema.ts +++ b/packages/trpc/src/router/automation/schema.ts @@ -34,18 +34,23 @@ const rruleBody = z .max(500) .describe("RFC 5545 RRULE body, no DTSTART prefix"); -export const createAutomationSchema = z.object({ - name: z.string().min(1).max(200), - prompt: z.string().min(1).max(20_000), - agentConfig: agentConfigSchema, - targetHostId: z.string().min(1).nullish(), - v2ProjectId: z.string().uuid(), - v2WorkspaceId: z.string().uuid().nullish(), - rrule: rruleBody, - dtstart: z.coerce.date().optional(), - timezone: iana, - mcpScope: z.array(z.string()).default([]), -}); +export const createAutomationSchema = z + .object({ + name: z.string().min(1).max(200), + prompt: z.string().min(1).max(20_000), + agentConfig: agentConfigSchema, + targetHostId: z.string().min(1).nullish(), + v2ProjectId: z.string().uuid().optional(), + v2WorkspaceId: z.string().uuid().nullish(), + rrule: rruleBody, + dtstart: z.coerce.date().optional(), + timezone: iana, + mcpScope: z.array(z.string()).default([]), + }) + .refine((input) => input.v2ProjectId || input.v2WorkspaceId, { + message: "Provide v2ProjectId or v2WorkspaceId", + path: ["v2ProjectId"], + }); export const updateAutomationSchema = z.object({ id: z.string().uuid(), diff --git a/packages/trpc/src/router/host/host.ts b/packages/trpc/src/router/host/host.ts index a6a6317f15b..3afdc041fe3 100644 --- a/packages/trpc/src/router/host/host.ts +++ b/packages/trpc/src/router/host/host.ts @@ -18,6 +18,46 @@ import { z } from "zod"; import { jwtProcedure, protectedProcedure } from "../../trpc"; export const hostRouter = { + list: jwtProcedure + .input(z.object({ organizationId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + if (!ctx.organizationIds.includes(input.organizationId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not a member of this organization", + }); + } + + const rows = await db + .select({ + machineId: v2Hosts.machineId, + name: v2Hosts.name, + isOnline: v2Hosts.isOnline, + organizationId: v2Hosts.organizationId, + }) + .from(v2Hosts) + .innerJoin( + v2UsersHosts, + and( + eq(v2UsersHosts.organizationId, v2Hosts.organizationId), + eq(v2UsersHosts.hostId, v2Hosts.machineId), + ), + ) + .where( + and( + eq(v2Hosts.organizationId, input.organizationId), + eq(v2UsersHosts.userId, ctx.userId), + ), + ); + + return rows.map((row) => ({ + id: row.machineId, + name: row.name, + online: row.isOnline, + organizationId: row.organizationId, + })); + }), + ensure: jwtProcedure .input( z.object({ diff --git a/packages/trpc/src/router/task/schema.ts b/packages/trpc/src/router/task/schema.ts index 09f0050b14d..77f54a000f8 100644 --- a/packages/trpc/src/router/task/schema.ts +++ b/packages/trpc/src/router/task/schema.ts @@ -2,21 +2,6 @@ import { taskPriorityValues } from "@superset/db/enums"; import { z } from "zod"; export const createTaskSchema = z.object({ - slug: z.string().min(1), - title: z.string().min(1), - description: z.string().nullish(), - statusId: z.string().uuid(), - priority: z.enum(taskPriorityValues).default("none"), - - organizationId: z.string().uuid(), - assigneeId: z.string().uuid().nullish(), - branch: z.string().nullish(), - estimate: z.number().int().positive().nullish(), - dueDate: z.coerce.date().nullish(), - labels: z.array(z.string()).nullish(), -}); - -export const createTaskFromUiSchema = z.object({ title: z.string().min(1), description: z.string().nullish(), statusId: z.string().uuid().nullish(), @@ -33,11 +18,24 @@ export const updateTaskSchema = z.object({ description: z.string().nullish(), statusId: z.string().uuid().optional(), priority: z.enum(taskPriorityValues).optional(), - assigneeId: z.string().uuid().nullish(), - branch: z.string().nullish(), prUrl: z.string().url().nullish(), estimate: z.number().int().positive().nullish(), dueDate: z.coerce.date().nullish(), labels: z.array(z.string()).nullish(), + // Deprecated: accepted-but-ignored. Drop in CLI-vNext cleanup PR. + branch: z.string().nullish(), }); + +export const taskListInputSchema = z + .object({ + statusId: z.string().uuid().nullish(), + priority: z.enum(taskPriorityValues).nullish(), + assigneeId: z.string().uuid().nullish(), + assigneeMe: z.boolean().nullish(), + creatorMe: z.boolean().nullish(), + search: z.string().min(1).nullish(), + limit: z.number().int().positive().max(500).default(50), + offset: z.number().int().nonnegative().default(0), + }) + .nullish(); diff --git a/packages/trpc/src/router/task/task.ts b/packages/trpc/src/router/task/task.ts index b9ef11852d6..b3402afcd7c 100644 --- a/packages/trpc/src/router/task/task.ts +++ b/packages/trpc/src/router/task/task.ts @@ -11,7 +11,7 @@ import { and, desc, eq, ilike, isNull } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { z } from "zod"; import { syncTask } from "../../lib/integrations/sync"; -import { protectedProcedure } from "../../trpc"; +import { protectedProcedure, type TRPCContext } from "../../trpc"; import { verifyOrgMembership } from "../integration/utils"; import { requireActiveOrgMembership } from "../utils/active-org"; import { @@ -19,13 +19,17 @@ import { requireOrgScopedResource, } from "../utils/org-resource-access"; import { - createTaskFromUiSchema, createTaskSchema, + taskListInputSchema, updateTaskSchema, } from "./schema"; const TASK_SLUG_CONSTRAINT = "tasks_org_slug_unique"; const TASK_SLUG_RETRY_LIMIT = 5; + +function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, (match) => `\\${match}`); +} type DbWsTransaction = Parameters[0]>[0]; type Executor = typeof dbWs | DbWsTransaction; @@ -168,13 +172,108 @@ async function getScopedAssigneeId( return member.userId; } +type CreateTaskContext = { + session: NonNullable; + activeOrganizationId: string | null; +}; + +async function createTask( + ctx: CreateTaskContext, + input: z.infer, +) { + const organizationId = await requireActiveOrgMembership(ctx); + + for (let attempt = 0; attempt < TASK_SLUG_RETRY_LIMIT; attempt += 1) { + try { + const result = await dbWs.transaction(async (tx) => { + const statusId = input.statusId + ? await getScopedStatusId( + tx, + organizationId, + input.statusId, + "Status must belong to the active organization", + ) + : await seedDefaultStatuses(organizationId, tx); + + const assigneeId = input.assigneeId + ? await getScopedAssigneeId( + tx, + organizationId, + input.assigneeId, + "Assignee must belong to the active organization", + ) + : null; + + const baseSlug = generateBaseTaskSlug(input.title); + const existingSlugs = await tx + .select({ slug: tasks.slug }) + .from(tasks) + .where( + and( + eq(tasks.organizationId, organizationId), + ilike(tasks.slug, `${baseSlug}%`), + ), + ); + const slug = generateUniqueTaskSlug( + baseSlug, + existingSlugs.map((task) => task.slug), + ); + + const [task] = await tx + .insert(tasks) + .values({ + slug, + title: input.title, + description: input.description ?? null, + statusId, + priority: input.priority ?? "none", + organizationId, + creatorId: ctx.session.user.id, + assigneeId, + estimate: input.estimate ?? null, + dueDate: input.dueDate ?? null, + labels: input.labels ?? [], + }) + .returning(); + + const txid = await getCurrentTxid(tx); + + return { task, txid }; + }); + + if (result.task) { + syncTask(result.task.id); + } + + return result; + } catch (error) { + if ( + isConstraintError(error, TASK_SLUG_CONSTRAINT) && + attempt < TASK_SLUG_RETRY_LIMIT - 1 + ) { + continue; + } + + throw error; + } + } + + throw new TRPCError({ + code: "CONFLICT", + message: "Failed to generate a unique task slug", + }); +} + export const taskRouter = { + /** + * @deprecated Use `task.list` instead. Kept for one release cycle so the + * shipped CLI on `main` keeps compiling against the new backend during + * the CLI-v1 split rollout. + */ all: protectedProcedure.query(async ({ ctx }) => { const organizationId = await requireActiveOrgMembership(ctx); - const assignee = alias(users, "assignee"); const creator = alias(users, "creator"); - return db .select({ task: tasks, @@ -198,6 +297,60 @@ export const taskRouter = { .orderBy(desc(tasks.createdAt)); }), + list: protectedProcedure + .input(taskListInputSchema) + .query(async ({ ctx, input }) => { + const organizationId = await requireActiveOrgMembership(ctx); + + const assignee = alias(users, "assignee"); + const creator = alias(users, "creator"); + const status = alias(taskStatuses, "status"); + + const filters = [ + eq(tasks.organizationId, organizationId), + isNull(tasks.deletedAt), + ]; + if (input?.priority) filters.push(eq(tasks.priority, input.priority)); + if (input?.statusId) filters.push(eq(tasks.statusId, input.statusId)); + if (input?.assigneeMe) { + filters.push(eq(tasks.assigneeId, ctx.session.user.id)); + } else if (input?.assigneeId) { + filters.push(eq(tasks.assigneeId, input.assigneeId)); + } + if (input?.creatorMe) { + filters.push(eq(tasks.creatorId, ctx.session.user.id)); + } + if (input?.search) { + filters.push( + ilike(tasks.title, `%${escapeLikePattern(input.search)}%`), + ); + } + + return db + .select({ + task: tasks, + assignee: { + id: assignee.id, + name: assignee.name, + image: assignee.image, + }, + creator: { + id: creator.id, + name: creator.name, + image: creator.image, + }, + statusName: status.name, + }) + .from(tasks) + .leftJoin(assignee, eq(tasks.assigneeId, assignee.id)) + .leftJoin(creator, eq(tasks.creatorId, creator.id)) + .leftJoin(status, eq(tasks.statusId, status.id)) + .where(and(...filters)) + .orderBy(desc(tasks.createdAt)) + .limit(input?.limit ?? 50) + .offset(input?.offset ?? 0); + }), + byOrganization: protectedProcedure .input(z.string().uuid()) .query(async ({ ctx, input }) => { @@ -219,136 +372,33 @@ export const taskRouter = { return getTaskBySlug(ctx.session.user.id, organizationId, input); }), - create: protectedProcedure - .input(createTaskSchema) - .mutation(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - - const result = await dbWs.transaction(async (tx) => { - const statusId = await getScopedStatusId( - tx, - input.organizationId, - input.statusId, - "Status must belong to the organization", + byIdOrSlug: protectedProcedure + .input(z.string().min(1)) + .query(async ({ ctx, input }) => { + const looksLikeUuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + input, ); - const assigneeId = - input.assigneeId === undefined - ? undefined - : await getScopedAssigneeId( - tx, - input.organizationId, - input.assigneeId ?? null, - "Assignee must belong to the organization", - ); - - const [task] = await tx - .insert(tasks) - .values({ - ...input, - statusId, - assigneeId, - creatorId: ctx.session.user.id, - labels: input.labels ?? [], - }) - .returning(); - - const txid = await getCurrentTxid(tx); - - return { task, txid }; - }); - - if (result.task) { - syncTask(result.task.id); + if (looksLikeUuid) { + const task = await getTaskById(ctx.session.user.id, input); + if (task) return task; } - - return result; + const organizationId = await requireActiveOrgMembership(ctx); + return getTaskBySlug(ctx.session.user.id, organizationId, input); }), + /** + * @deprecated Use `task.create` instead. Kept for one release cycle so + * shipped renderer/CLI on `main` keep working during the CLI-v1 split + * rollout. + */ createFromUi: protectedProcedure - .input(createTaskFromUiSchema) - .mutation(async ({ ctx, input }) => { - const organizationId = await requireActiveOrgMembership(ctx); - - for (let attempt = 0; attempt < TASK_SLUG_RETRY_LIMIT; attempt += 1) { - try { - const result = await dbWs.transaction(async (tx) => { - const statusId = input.statusId - ? await getScopedStatusId( - tx, - organizationId, - input.statusId, - "Status must belong to the active organization", - ) - : await seedDefaultStatuses(organizationId, tx); - - const assigneeId = input.assigneeId - ? await getScopedAssigneeId( - tx, - organizationId, - input.assigneeId, - "Assignee must belong to the active organization", - ) - : null; - - const baseSlug = generateBaseTaskSlug(input.title); - const existingSlugs = await tx - .select({ slug: tasks.slug }) - .from(tasks) - .where( - and( - eq(tasks.organizationId, organizationId), - ilike(tasks.slug, `${baseSlug}%`), - ), - ); - const slug = generateUniqueTaskSlug( - baseSlug, - existingSlugs.map((task) => task.slug), - ); - - const [task] = await tx - .insert(tasks) - .values({ - slug, - title: input.title, - description: input.description ?? null, - statusId, - priority: input.priority ?? "none", - organizationId, - creatorId: ctx.session.user.id, - assigneeId, - estimate: input.estimate ?? null, - dueDate: input.dueDate ?? null, - labels: input.labels ?? [], - }) - .returning(); - - const txid = await getCurrentTxid(tx); - - return { task, txid }; - }); - - if (result.task) { - syncTask(result.task.id); - } - - return result; - } catch (error) { - if ( - isConstraintError(error, TASK_SLUG_CONSTRAINT) && - attempt < TASK_SLUG_RETRY_LIMIT - 1 - ) { - continue; - } - - throw error; - } - } + .input(createTaskSchema) + .mutation(({ ctx, input }) => createTask(ctx, input)), - throw new TRPCError({ - code: "CONFLICT", - message: "Failed to generate a unique task slug", - }); - }), + create: protectedProcedure + .input(createTaskSchema) + .mutation(({ ctx, input }) => createTask(ctx, input)), update: protectedProcedure .input(updateTaskSchema) diff --git a/packages/trpc/src/router/v2-workspace/v2-workspace.ts b/packages/trpc/src/router/v2-workspace/v2-workspace.ts index df95e8bfe89..110ae0e7834 100644 --- a/packages/trpc/src/router/v2-workspace/v2-workspace.ts +++ b/packages/trpc/src/router/v2-workspace/v2-workspace.ts @@ -1,6 +1,11 @@ -import { dbWs } from "@superset/db/client"; +import { db, dbWs } from "@superset/db/client"; import { v2WorkspaceTypeValues } from "@superset/db/enums"; -import { v2Hosts, v2Projects, v2Workspaces } from "@superset/db/schema"; +import { + v2Hosts, + v2Projects, + v2UsersHosts, + v2Workspaces, +} from "@superset/db/schema"; import { getCurrentTxid } from "@superset/db/utils"; import type { TRPCRouterRecord } from "@trpc/server"; import { TRPCError } from "@trpc/server"; @@ -102,6 +107,57 @@ async function getWorkspaceAccess( } export const v2WorkspaceRouter = { + list: jwtProcedure + .input( + z.object({ + organizationId: z.string().uuid(), + hostId: z.string().min(1).optional(), + }), + ) + .query(async ({ ctx, input }) => { + if (!ctx.organizationIds.includes(input.organizationId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not a member of this organization", + }); + } + + const rows = await db + .select({ + id: v2Workspaces.id, + name: v2Workspaces.name, + branch: v2Workspaces.branch, + projectId: v2Workspaces.projectId, + projectName: v2Projects.name, + hostId: v2Workspaces.hostId, + }) + .from(v2Workspaces) + .innerJoin( + v2UsersHosts, + and( + eq(v2UsersHosts.organizationId, v2Workspaces.organizationId), + eq(v2UsersHosts.hostId, v2Workspaces.hostId), + ), + ) + .leftJoin(v2Projects, eq(v2Projects.id, v2Workspaces.projectId)) + .where( + and( + eq(v2Workspaces.organizationId, input.organizationId), + eq(v2UsersHosts.userId, ctx.userId), + input.hostId ? eq(v2Workspaces.hostId, input.hostId) : undefined, + ), + ); + + return rows.map((row) => ({ + id: row.id, + name: row.name, + branch: row.branch, + projectId: row.projectId, + projectName: row.projectName ?? "", + hostId: row.hostId, + })); + }), + create: jwtProcedure .input( z.object({ diff --git a/packages/trpc/src/trpc.ts b/packages/trpc/src/trpc.ts index f58c5a4c1cc..921d9e6c707 100644 --- a/packages/trpc/src/trpc.ts +++ b/packages/trpc/src/trpc.ts @@ -75,33 +75,57 @@ export const jwtProcedure = t.procedure.use(async ({ ctx, next }) => { if (!authHeader?.startsWith("Bearer ")) { throw new TRPCError({ code: "UNAUTHORIZED", - message: "JWT bearer token required", + message: "Bearer token required", }); } const token = authHeader.slice(7); + try { const { payload } = await ctx.auth.api.verifyJWT({ body: { token } }); - if (!payload?.sub) { - throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid JWT" }); + if (payload?.sub) { + const organizationIds = (payload.organizationIds as string[]) ?? []; + return next({ + ctx: { + userId: payload.sub, + email: (payload.email as string) ?? "", + organizationIds, + activeOrganizationId: organizationIds[0] ?? null, + }, + }); } + } catch (error) { + // A live session is the legit fallback for an unverifiable token + // (expired/missing). A TRPCError from verifyJWT is an explicit + // rejection (revoked/forged) — surface it instead of laundering it + // into session auth. + if (error instanceof TRPCError) throw error; + } - const organizationIds = (payload.organizationIds as string[]) ?? []; + if (ctx.session) { + const userId = ctx.session.user.id; + const memberRows = await db.query.members.findMany({ + where: eq(members.userId, userId), + columns: { organizationId: true }, + }); + const organizationIds = memberRows.map((row) => row.organizationId); return next({ ctx: { - userId: payload.sub, - email: (payload.email as string) ?? "", + userId, + email: ctx.session.user.email ?? "", organizationIds, - activeOrganizationId: organizationIds[0] ?? null, + activeOrganizationId: + ctx.session.session.activeOrganizationId ?? + organizationIds[0] ?? + null, }, }); - } catch (error) { - if (error instanceof TRPCError) throw error; - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "JWT verification failed", - }); } + + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid bearer token", + }); }); export const adminProcedure = protectedProcedure.use(async ({ ctx, next }) => {