diff --git a/apps/web/src/app/dashboard/people/group-people.tsx b/apps/web/src/app/dashboard/people/group-people.tsx index 8b37bb8..76e0be7 100644 --- a/apps/web/src/app/dashboard/people/group-people.tsx +++ b/apps/web/src/app/dashboard/people/group-people.tsx @@ -74,13 +74,16 @@ export function GroupPeople({

{member.email}

- {member.role === "owner" ? "Owner" : "Member"} + {canManage ? : {member.role === "owner" ? "Owner" : "Member"}} {canManage ? ( + + + ; +} diff --git a/apps/web/src/app/dashboard/people/page.tsx b/apps/web/src/app/dashboard/people/page.tsx index 1b7d39f..3a38597 100644 --- a/apps/web/src/app/dashboard/people/page.tsx +++ b/apps/web/src/app/dashboard/people/page.tsx @@ -1,5 +1,6 @@ import { createServerCaller } from "@/trpc/server"; import { GroupPeople } from "./group-people"; +import { GroupSettings } from "./group-settings"; import { Empty, EmptyDescription, @@ -30,7 +31,9 @@ export default async function DashboardPeoplePage() { Shared members of {group.name}. Event access is still assigned per event.

+ {group.permissions.includes("group.manage") ? : null} diff --git a/apps/web/src/app/invitations/[token]/page.tsx b/apps/web/src/app/invitations/[token]/page.tsx index 43305ad..81e95b0 100644 --- a/apps/web/src/app/invitations/[token]/page.tsx +++ b/apps/web/src/app/invitations/[token]/page.tsx @@ -1,4 +1,3 @@ -import { redirect } from "next/navigation"; import { headers } from "next/headers"; import Link from "next/link"; import { auth } from "@/server/auth"; @@ -13,6 +12,8 @@ import { } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +export const metadata = { title: "Invitation", robots: { index: false, follow: false }, referrer: "no-referrer" as const }; + export default async function InvitationPage({ params, }: { @@ -37,25 +38,26 @@ export default async function InvitationPage({ ); } - if (!session) { - redirect(`/sign-up?invite=${encodeURIComponent(token)}`); - } + const callback = `/invitations/${encodeURIComponent(token)}`; + const role = preview.eventRole ?? preview.groupRole; + const roleLabel = role ? role.charAt(0).toUpperCase() + role.slice(1) : "team member"; return (
+ {preview.bannerUrl ? : null} - Join this event + {preview.eventTitle ? `Help out with ${preview.eventTitle}` : `Join ${preview.groupName ?? "the team"}`} - {preview.eventRole - ? `You'll join as event ${preview.eventRole}.` - : preview.groupRole - ? `You'll join the group as ${preview.groupRole}.` - : "This invite grants access."} + You’ve been invited to join as a {roleLabel}. {session ? "Accept below to join the team." : "Create an account or sign in to accept your invitation. Use the email address that received the invite."} - +

This invitation grants team access, not a guest RSVP. Your assigned role controls what you can view and manage.

+ {session ? : <> + + + } diff --git a/apps/web/src/app/sign-up/page.tsx b/apps/web/src/app/sign-up/page.tsx index cb0f34d..a407fa0 100644 --- a/apps/web/src/app/sign-up/page.tsx +++ b/apps/web/src/app/sign-up/page.tsx @@ -17,7 +17,8 @@ export default async function SignUpPage({ searchParams: Promise<{ callbackURL?: string; invite?: string; code?: string }>; }) { const { callbackURL, invite, code } = await searchParams; - const token = invite ?? code; + const callbackInvite = callbackURL?.match(/^\/invitations\/([^/?#]+)$/)?.[1]; + const token = invite ?? code ?? (callbackInvite ? decodeURIComponent(callbackInvite) : undefined); const safeCallback = callbackURL?.startsWith("/") && !callbackURL.startsWith("//") ? callbackURL @@ -34,7 +35,7 @@ export default async function SignUpPage({ Create an account - {settings.openSignup + {token ? "Create your account with the email address that received the invitation. You’ll return to accept it next." : settings.openSignup ? "Host an event and share a guest upload link." : "This deployment is invite-only. Use a code or invite link."} diff --git a/apps/web/src/app/sign-up/sign-up-form.tsx b/apps/web/src/app/sign-up/sign-up-form.tsx index 64e5a4e..ddd08fa 100644 --- a/apps/web/src/app/sign-up/sign-up-form.tsx +++ b/apps/web/src/app/sign-up/sign-up-form.tsx @@ -96,7 +96,7 @@ export function SignUpForm({ /> Use at least 10 characters in production. - + {initialCode.length > 4 ?

Your invitation is attached. You’ll confirm your access after creating your account.

: Invite code {requireInvite ? "" : "(optional)"} @@ -115,7 +115,7 @@ export function SignUpForm({ className="tap-target" required={requireInvite} /> - +
} {error ? ( diff --git a/apps/web/src/components/pending-invites.tsx b/apps/web/src/components/pending-invites.tsx index 0882865..07b9ac7 100644 --- a/apps/web/src/components/pending-invites.tsx +++ b/apps/web/src/components/pending-invites.tsx @@ -1,6 +1,8 @@ "use client"; import { api } from "@/trpc/react"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty"; @@ -8,7 +10,12 @@ import { Skeleton } from "@/components/ui/skeleton"; const label = (role: string) => role.charAt(0).toUpperCase() + role.slice(1); -export function PendingInvites({ groupId, eventId }: { groupId: string; eventId?: string }) { +export function PendingInvites({ groupId, eventId, canRevoke = false }: { groupId: string; eventId?: string; canRevoke?: boolean }) { + const utils = api.useUtils(); + const revoke = api.group.revokeInvite.useMutation({ + onSuccess: async () => { toast.success("Invite revoked"); await utils.group.pendingInvites.invalidate(); }, + onError: error => toast.error(error.message), + }); const invites = api.group.pendingInvites.useQuery({ groupId, eventId }, { refetchInterval: 30000 }); return
@@ -29,6 +36,7 @@ export function PendingInvites({ groupId, eventId }: { groupId: string; eventId? {invite.groupRole ? Workspace {label(invite.groupRole)} : null} {invite.eventRole ? Event {label(invite.eventRole)} : null} {invite.kind === "email" ? "Awaiting acceptance" : "Active code"} + {canRevoke ? : null}
)}}
; diff --git a/apps/web/src/server/api/routers/group.ts b/apps/web/src/server/api/routers/group.ts index f05a8f2..2e70d56 100644 --- a/apps/web/src/server/api/routers/group.ts +++ b/apps/web/src/server/api/routers/group.ts @@ -55,6 +55,33 @@ async function validateInviteGrants(userId: string, input: { } export const groupRouter = createTRPCRouter({ + rename: protectedProcedure + .input(z.object({ groupId: z.string().uuid(), name: z.string().trim().min(1).max(100) })) + .mutation(async ({ ctx, input }) => { + const { access } = await loadGroupAccess(ctx.session.user.id, input.groupId, await getPlatformRole(ctx.session.user.id)); + requireGroupPermission(access.permissions, GROUP_PERMISSIONS.GROUP_MANAGE); + await getDb().transaction(async tx => { + await tx.update(groups).set({ name: input.name, updatedAt: new Date() }).where(eq(groups.id, input.groupId)); + await tx.insert(auditEvents).values({ groupId: input.groupId, actorUserId: ctx.session.user.id, action: "group.rename", subjectType: "group", subjectId: input.groupId }); + }); + return { name: input.name }; + }), + + revokeInvite: protectedProcedure + .input(z.object({ groupId: z.string().uuid(), inviteId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const { access } = await loadGroupAccess(ctx.session.user.id, input.groupId, await getPlatformRole(ctx.session.user.id)); + requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE); + await getDb().transaction(async tx => { + const [invite] = await tx.update(invites).set({ status: "revoked", updatedAt: new Date() }) + .where(and(eq(invites.id, input.inviteId), eq(invites.groupId, input.groupId), eq(invites.status, "pending"))) + .returning({ id: invites.id, eventId: invites.eventId }); + if (!invite) throw new TRPCError({ code: "NOT_FOUND", message: "Invite is no longer pending." }); + await tx.insert(auditEvents).values({ groupId: input.groupId, eventId: invite.eventId, actorUserId: ctx.session.user.id, action: "invite.revoke", subjectType: "invite", subjectId: invite.id }); + }); + return { ok: true }; + }), + pendingInvites: protectedProcedure .input(z.object({ groupId: z.string().uuid(), eventId: z.string().uuid().optional() })) .query(async ({ ctx, input }) => { diff --git a/apps/web/src/server/api/routers/invites.ts b/apps/web/src/server/api/routers/invites.ts index 6bdfdcd..8776c35 100644 --- a/apps/web/src/server/api/routers/invites.ts +++ b/apps/web/src/server/api/routers/invites.ts @@ -1,6 +1,10 @@ import { redeemInviteInputSchema } from "@album/contracts"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; -import { findInviteByToken, redeemInviteForUser } from "@/server/invites"; +import { assertInviteRedeemable, findInviteByToken, redeemInviteForUser } from "@/server/invites"; +import { events, groups, getDb } from "@album/database"; +import { and, eq } from "drizzle-orm"; +import { customBannerUrl, eventBannerUrl } from "@/server/event-banner"; +import { galleryIsPublic } from "@/lib/publishing"; import { TRPCError } from "@trpc/server"; import { getDeploymentSettings } from "@/server/settings"; import { consumeRateLimit } from "@/server/rate-limit"; @@ -23,7 +27,16 @@ export const invitesRouter = createTRPCRouter({ if (!invite) { throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" }); } + assertInviteRedeemable(invite); + const [event] = invite.eventId && invite.groupId ? await getDb().select().from(events) + .where(and(eq(events.id, invite.eventId), eq(events.groupId, invite.groupId))).limit(1) : []; + const [group] = invite.groupId ? await getDb().select({ name: groups.name }).from(groups).where(eq(groups.id, invite.groupId)).limit(1) : []; + const bannerUrl = event?.customBannerId ? await customBannerUrl(event.id, event.customBannerId) + : event && galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt) ? await eventBannerUrl(event.id, event.bannerPhotoId) : null; return { + eventTitle: event?.title ?? null, + groupName: group?.name ?? null, + bannerUrl, kind: invite.kind, status: invite.status, groupRole: invite.groupRole, diff --git a/apps/web/src/server/polish.integration.test.ts b/apps/web/src/server/polish.integration.test.ts index 32e982c..0594534 100644 --- a/apps/web/src/server/polish.integration.test.ts +++ b/apps/web/src/server/polish.integration.test.ts @@ -7,6 +7,7 @@ import { hashToken } from "./tokens"; import { redeemInviteForUser } from "./invites"; import { signsRouter } from "./api/routers/signs"; import { groupRouter } from "./api/routers/group"; +import { invitesRouter } from "./api/routers/invites"; import type { TrpcContext } from "./api/trpc"; test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomic redemption, and saved sign roundtrip/conflicts", async () => { @@ -28,7 +29,12 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomi const [used] = await db.select().from(invites).where(eq(invites.tokenHash, hashToken(token))); expect(used!.usedCount).toBe(1); const workspace = groupRouter.createCaller(ctx(0)); - const [pending] = await db.insert(invites).values({ kind: "email", email: people[1]!.email, tokenHash: hashToken(crypto.randomUUID()), groupId: group!.id, eventId: event!.id, eventRole: "manager" }).returning(); + const pendingToken = crypto.randomUUID(); + const [pending] = await db.insert(invites).values({ kind: "email", email: people[1]!.email, tokenHash: hashToken(pendingToken), groupId: group!.id, eventId: event!.id, eventRole: "manager" }).returning(); + const preview = await invitesRouter.createCaller(ctx(1)).preview({ token: pendingToken }); + expect(preview.eventTitle).toBe("Polish test"); + expect(preview.eventRole).toBe("manager"); + expect(preview).not.toHaveProperty("email"); await db.insert(invites).values([ { kind: "email", tokenHash: hashToken(crypto.randomUUID()), groupId: group!.id, expiresAt: new Date(0) }, { kind: "email", tokenHash: hashToken(crypto.randomUUID()), groupId: group!.id, usedCount: 1, maxUses: 1 }, @@ -40,6 +46,17 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomi expect(await workspace.pendingInvites({ groupId: group!.id })).toHaveLength(1); await expect(groupRouter.createCaller(ctx(1)).pendingInvites({ groupId: group!.id })).rejects.toThrow(); await expect(workspace.pendingInvites({ groupId: crypto.randomUUID(), eventId: event!.id })).rejects.toThrow(); + await expect(groupRouter.createCaller(ctx(1)).rename({ groupId: group!.id, name: "Denied" })).rejects.toThrow(); + expect(await workspace.rename({ groupId: group!.id, name: " Updated group " })).toEqual({ name: "Updated group" }); + expect((await workspace.get({ groupId: group!.id })).name).toBe("Updated group"); + await expect(workspace.setMember({ groupId: group!.id, userId: people[0]!.id, role: "member" })).rejects.toThrow("at least one owner"); + await expect(groupRouter.createCaller(ctx(1)).revokeInvite({ groupId: group!.id, inviteId: pending!.id })).rejects.toThrow(); + await expect(workspace.revokeInvite({ groupId: group!.id, inviteId: crypto.randomUUID() })).rejects.toThrow(); + await workspace.revokeInvite({ groupId: group!.id, inviteId: pending!.id }); + await expect(invitesRouter.createCaller(ctx(1)).preview({ token: pendingToken })).rejects.toThrow("revoked"); + expect(await workspace.pendingInvites({ groupId: group!.id })).toHaveLength(0); + const [revoked] = await db.select().from(invites).where(and(eq(invites.groupId, group!.id), eq(invites.id, pending!.id))); + expect(revoked!.status).toBe("revoked"); await expect(workspace.createCode({ groupId: group!.id, grantUnlimitedEvents: true })).rejects.toThrow("platform administrators"); const signs = signsRouter.createCaller(ctx(0)); await expect(signsRouter.createCaller(ctx(1)).prepare({ eventId: event!.id })).rejects.toThrow();