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>
99 lines
3 KiB
TypeScript
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 };
|