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 ? (
+ {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();