ubiquitous-invention/apps/web/server/trpc.ts
Randall Stillwell f3c118c9f6 feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).

apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
  select (member/admin; owner deliberately excluded — single-owner
  model means ownership transfer is a separate flow, not a fresh
  invite). On success surfaces the accept URL with a copy-to-clipboard
  affordance and a "your email isn't wired up yet, paste this directly"
  hint. Plain text input in this commit; the smart recipient
  autocomplete combobox from Task 3 will swap it in via a follow-up
  edit to this same file (subagent is working that in parallel).

apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
  InviteDialog. Adds:
  - Pending invites section (admin/owner only) listing each open
    invite with email, role, expiry-relative time, and Copy link /
    Revoke actions.
  - Per-member kebab menu with role-change actions and Remove. Only
    owners can promote anyone to owner; admins can move people
    between admin/member only. The "demote to member" item disables
    on the last-owner row (the server enforces this anyway with a
    clear error; UI just avoids surfacing a click that'd 400).
  - "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
  round-trip) — the membership row IS the source of truth for who
  can manage what.
* Mutation errors surface inline at the page level with a Dismiss
  action — kebab/copy actions that hit the last-owner guard, expired-
  token error, etc. don't fail silently.

apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
  1. No session yet -> "Sign in to continue" with callbackUrl set so
     the user lands back here after auth.
  2. Authenticated, accepting -> spinner.
  3. Success -> redirect to the workspace's slug-rooted URL.
  4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
     explainer page showing both the invited email AND the user's
     current sign-in email, with deep links to link the invited email
     via OAuth and try again. (This is the Task 1 invariant
     surfacing through the UI: we never silently accept an invite
     under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
  message verbatim with a "Go home" button.

apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
  client when it's a plain object. Required for the invite-accept
  explainer page to read `cause.invitedEmail` off the TRPCError. The
  cause-payload contract is "small, pure data, no secrets" — anything
  the server throws as a cause is also visible client-side.

End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:

1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
   -> sign-in prompt. After auth -> invite accepts and you land in
   the workspace. With a session whose email doesn't match -> the
   email-mismatch explainer renders.

Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 10:34:56 -05:00

99 lines
3 KiB
TypeScript

import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";
import superjson from "superjson";
import type { Session } from "next-auth";
import { db } from "@tasks/database";
import { auth } from "@/lib/auth";
import { resolveWorkspace, type WorkspaceContext } from "@/server/lib/resolve-workspace";
export type Context = {
db: typeof db;
session: Session | null;
};
export async function createContext(): Promise<Context> {
try {
const session = await auth();
return { db, session };
} catch (error) {
console.error("[trpc] createContext failed:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create request context",
cause: error,
});
}
}
const t = initTRPC.context<Context>().create({
transformer: superjson,
// Expose `error.cause` to the client. Procedures that need to surface
// structured failure modes (e.g. `invites.accept` returning the invited
// email so the explainer page can render) pass a `{ reason, ... }` object
// as the cause and the client reads it from `error.shape.data.cause`.
// Anything thrown should still be safe to serialize (no class instances,
// no DB rows, no secrets) — keep cause payloads small and pure data.
errorFormatter: ({ shape, error }) => ({
...shape,
data: {
...shape.data,
cause:
error.cause && typeof error.cause === "object" && !(error.cause instanceof Error)
? (error.cause as Record<string, unknown>)
: undefined,
},
}),
});
export const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
...ctx,
session: ctx.session,
},
});
});
export const router = t.router;
export const createCallerFactory = t.createCallerFactory;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);
/**
* Procedure for any tenant-scoped operation. Caller must:
* - Be authenticated.
* - Pass `workspace` (UUID or slug) in the input. The middleware resolves it
* to a full `WorkspaceContext` (id, slug, name, owner, role) and exposes it
* on `ctx.workspace`. Procedures can then scope queries by `ctx.workspace.id`.
*
* Example:
* workspaceProcedure
* .input(z.object({ workspace: z.string(), title: z.string() }))
* .mutation(({ ctx, input }) => {
* return ctx.db.insert(objects).values({
* workspaceId: ctx.workspace.id,
* title: input.title,
* type: "task",
* });
* });
*/
export const workspaceProcedure = protectedProcedure
.input(z.object({ workspace: z.string().min(1) }))
.use(async ({ ctx, input, next }) => {
const ws = await resolveWorkspace({
handle: input.workspace,
userId: ctx.session.user.id,
db: ctx.db,
});
return next({
ctx: {
...ctx,
workspace: ws,
},
});
});
export type WorkspaceProcedureContext = Context & { session: Session; workspace: WorkspaceContext };