Add group management and event-specific invitation onboarding
This commit is contained in:
@@ -74,13 +74,16 @@ export function GroupPeople({
|
|||||||
<p className="text-xs text-muted-foreground">{member.email}</p>
|
<p className="text-xs text-muted-foreground">{member.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="secondary">{member.role === "owner" ? "Owner" : "Member"}</Badge>
|
{canManage ? <Select value={member.role} disabled={setMember.isPending || remove.isPending} onValueChange={value => { if (window.confirm(`Change this member's group role to ${value}?`)) setMember.mutate({ groupId, userId: member.userId, role: value as GroupRole }); }}>
|
||||||
|
<SelectTrigger aria-label={`Group role for ${member.name}`}><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent><SelectGroup><SelectItem value="member">Member</SelectItem><SelectItem value="owner">Owner</SelectItem></SelectGroup></SelectContent>
|
||||||
|
</Select> : <Badge variant="secondary">{member.role === "owner" ? "Owner" : "Member"}</Badge>}
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
disabled={remove.isPending || setMember.isPending}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
remove.mutate({ groupId, userId: member.userId })
|
window.confirm("Remove this member from the group and all its events?") && remove.mutate({ groupId, userId: member.userId })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
@@ -150,7 +153,7 @@ export function GroupPeople({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{canManage ? <PendingInvites groupId={groupId} /> : null}
|
{canManage ? <PendingInvites groupId={groupId} canRevoke /> : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { SaveIcon } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { api } from "@/trpc/react";
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||||
|
import { Field, FieldGroup, FieldLabel, FieldDescription } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function GroupSettings({ groupId, initialName }: { groupId: string; initialName: string }) {
|
||||||
|
const [name, setName] = useState(initialName);
|
||||||
|
const [saved, setSaved] = useState(initialName);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const router = useRouter();
|
||||||
|
const rename = api.group.rename.useMutation({
|
||||||
|
onSuccess: async result => {
|
||||||
|
setSaved(result.name); setName(result.name); toast.success("Group updated");
|
||||||
|
await Promise.all([utils.group.invalidate(), utils.viewer.invalidate()]);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: error => toast.error(error.message),
|
||||||
|
});
|
||||||
|
return <Card>
|
||||||
|
<CardHeader><CardTitle>Group settings</CardTitle><CardDescription>Manage the shared workspace for your team and events.</CardDescription></CardHeader>
|
||||||
|
<CardContent><form onSubmit={event => { event.preventDefault(); rename.mutate({ groupId, name }); }}>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field><FieldLabel htmlFor="group-name">Group name</FieldLabel><Input id="group-name" required maxLength={100} value={name} disabled={rename.isPending} onChange={event => setName(event.target.value)} /><FieldDescription>Renaming does not change event links, invite codes, or access.</FieldDescription></Field>
|
||||||
|
<Field orientation="horizontal"><Button disabled={rename.isPending || !name.trim() || name.trim() === saved} type="submit"><SaveIcon data-icon="inline-start" />{rename.isPending ? "Saving…" : "Save group"}</Button></Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form></CardContent>
|
||||||
|
</Card>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createServerCaller } from "@/trpc/server";
|
import { createServerCaller } from "@/trpc/server";
|
||||||
import { GroupPeople } from "./group-people";
|
import { GroupPeople } from "./group-people";
|
||||||
|
import { GroupSettings } from "./group-settings";
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
EmptyDescription,
|
EmptyDescription,
|
||||||
@@ -30,7 +31,9 @@ export default async function DashboardPeoplePage() {
|
|||||||
Shared members of {group.name}. Event access is still assigned per event.
|
Shared members of {group.name}. Event access is still assigned per event.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{group.permissions.includes("group.manage") ? <GroupSettings key={group.id} groupId={group.id} initialName={group.name} /> : null}
|
||||||
<GroupPeople
|
<GroupPeople
|
||||||
|
key={group.id}
|
||||||
groupId={group.id}
|
groupId={group.id}
|
||||||
canManage={group.permissions.includes("group.people.manage")}
|
canManage={group.permissions.includes("group.people.manage")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { auth } from "@/server/auth";
|
import { auth } from "@/server/auth";
|
||||||
@@ -13,6 +12,8 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
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({
|
export default async function InvitationPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -37,25 +38,26 @@ export default async function InvitationPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
const callback = `/invitations/${encodeURIComponent(token)}`;
|
||||||
redirect(`/sign-up?invite=${encodeURIComponent(token)}`);
|
const role = preview.eventRole ?? preview.groupRole;
|
||||||
}
|
const roleLabel = role ? role.charAt(0).toUpperCase() + role.slice(1) : "team member";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page-pad mx-auto max-w-md py-16">
|
<main className="page-pad mx-auto max-w-md py-16">
|
||||||
<Card>
|
<Card>
|
||||||
|
{preview.bannerUrl ? <img src={preview.bannerUrl} alt="" width={1200} height={450} className="aspect-[8/3] w-full rounded-t-xl object-cover" /> : null}
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl font-semibold tracking-tight">Join this event</CardTitle>
|
<CardTitle className="text-2xl font-semibold tracking-tight">{preview.eventTitle ? `Help out with ${preview.eventTitle}` : `Join ${preview.groupName ?? "the team"}`}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{preview.eventRole
|
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."}
|
||||||
? `You'll join as event ${preview.eventRole}.`
|
|
||||||
: preview.groupRole
|
|
||||||
? `You'll join the group as ${preview.groupRole}.`
|
|
||||||
: "This invite grants access."}
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-3">
|
<CardContent className="flex flex-col gap-3">
|
||||||
<RedeemInviteButton token={token} />
|
<p className="text-sm text-muted-foreground">This invitation grants team access, not a guest RSVP. Your assigned role controls what you can view and manage.</p>
|
||||||
|
{session ? <RedeemInviteButton token={token} /> : <>
|
||||||
|
<Button asChild><Link href={`/sign-up?invite=${encodeURIComponent(token)}&callbackURL=${encodeURIComponent(callback)}`}>Create account to accept</Link></Button>
|
||||||
|
<Button asChild variant="outline"><Link href={`/sign-in?callbackURL=${encodeURIComponent(callback)}`}>Already have an account? Sign in</Link></Button>
|
||||||
|
</>}
|
||||||
<Button asChild variant="ghost">
|
<Button asChild variant="ghost">
|
||||||
<Link href="/dashboard">Skip</Link>
|
<Link href="/dashboard">Skip</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export default async function SignUpPage({
|
|||||||
searchParams: Promise<{ callbackURL?: string; invite?: string; code?: string }>;
|
searchParams: Promise<{ callbackURL?: string; invite?: string; code?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { callbackURL, invite, code } = await searchParams;
|
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 =
|
const safeCallback =
|
||||||
callbackURL?.startsWith("/") && !callbackURL.startsWith("//")
|
callbackURL?.startsWith("/") && !callbackURL.startsWith("//")
|
||||||
? callbackURL
|
? callbackURL
|
||||||
@@ -34,7 +35,7 @@ export default async function SignUpPage({
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl font-semibold tracking-tight">Create an account</CardTitle>
|
<CardTitle className="text-2xl font-semibold tracking-tight">Create an account</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{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."
|
? "Host an event and share a guest upload link."
|
||||||
: "This deployment is invite-only. Use a code or invite link."}
|
: "This deployment is invite-only. Use a code or invite link."}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function SignUpForm({
|
|||||||
/>
|
/>
|
||||||
<FieldDescription>Use at least 10 characters in production.</FieldDescription>
|
<FieldDescription>Use at least 10 characters in production.</FieldDescription>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
{initialCode.length > 4 ? <p className="text-sm text-muted-foreground">Your invitation is attached. You’ll confirm your access after creating your account.</p> : <Field>
|
||||||
<FieldLabel htmlFor="invite">
|
<FieldLabel htmlFor="invite">
|
||||||
Invite code {requireInvite ? "" : "(optional)"}
|
Invite code {requireInvite ? "" : "(optional)"}
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
@@ -115,7 +115,7 @@ export function SignUpForm({
|
|||||||
className="tap-target"
|
className="tap-target"
|
||||||
required={requireInvite}
|
required={requireInvite}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
{error ? (
|
{error ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { api } from "@/trpc/react";
|
import { api } from "@/trpc/react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
|
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);
|
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 });
|
const invites = api.group.pendingInvites.useQuery({ groupId, eventId }, { refetchInterval: 30000 });
|
||||||
return <section aria-label="Pending invites" className="flex flex-col gap-3">
|
return <section aria-label="Pending invites" className="flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -29,6 +36,7 @@ export function PendingInvites({ groupId, eventId }: { groupId: string; eventId?
|
|||||||
{invite.groupRole ? <Badge variant="outline">Workspace {label(invite.groupRole)}</Badge> : null}
|
{invite.groupRole ? <Badge variant="outline">Workspace {label(invite.groupRole)}</Badge> : null}
|
||||||
{invite.eventRole ? <Badge variant="outline">Event {label(invite.eventRole)}</Badge> : null}
|
{invite.eventRole ? <Badge variant="outline">Event {label(invite.eventRole)}</Badge> : null}
|
||||||
<Badge variant="secondary">{invite.kind === "email" ? "Awaiting acceptance" : "Active code"}</Badge>
|
<Badge variant="secondary">{invite.kind === "email" ? "Awaiting acceptance" : "Active code"}</Badge>
|
||||||
|
{canRevoke ? <Button type="button" variant="outline" disabled={revoke.isPending} onClick={() => { if (window.confirm("Revoke this invitation? It will stop granting access. Existing members keep their access.")) revoke.mutate({ groupId, inviteId: invite.id }); }}>Revoke</Button> : null}
|
||||||
</div>
|
</div>
|
||||||
</li>)}</ul>}
|
</li>)}</ul>}
|
||||||
</section>;
|
</section>;
|
||||||
|
|||||||
@@ -55,6 +55,33 @@ async function validateInviteGrants(userId: string, input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const groupRouter = createTRPCRouter({
|
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
|
pendingInvites: protectedProcedure
|
||||||
.input(z.object({ groupId: z.string().uuid(), eventId: z.string().uuid().optional() }))
|
.input(z.object({ groupId: z.string().uuid(), eventId: z.string().uuid().optional() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { redeemInviteInputSchema } from "@album/contracts";
|
import { redeemInviteInputSchema } from "@album/contracts";
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
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 { TRPCError } from "@trpc/server";
|
||||||
import { getDeploymentSettings } from "@/server/settings";
|
import { getDeploymentSettings } from "@/server/settings";
|
||||||
import { consumeRateLimit } from "@/server/rate-limit";
|
import { consumeRateLimit } from "@/server/rate-limit";
|
||||||
@@ -23,7 +27,16 @@ export const invitesRouter = createTRPCRouter({
|
|||||||
if (!invite) {
|
if (!invite) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
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 {
|
return {
|
||||||
|
eventTitle: event?.title ?? null,
|
||||||
|
groupName: group?.name ?? null,
|
||||||
|
bannerUrl,
|
||||||
kind: invite.kind,
|
kind: invite.kind,
|
||||||
status: invite.status,
|
status: invite.status,
|
||||||
groupRole: invite.groupRole,
|
groupRole: invite.groupRole,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { hashToken } from "./tokens";
|
|||||||
import { redeemInviteForUser } from "./invites";
|
import { redeemInviteForUser } from "./invites";
|
||||||
import { signsRouter } from "./api/routers/signs";
|
import { signsRouter } from "./api/routers/signs";
|
||||||
import { groupRouter } from "./api/routers/group";
|
import { groupRouter } from "./api/routers/group";
|
||||||
|
import { invitesRouter } from "./api/routers/invites";
|
||||||
import type { TrpcContext } from "./api/trpc";
|
import type { TrpcContext } from "./api/trpc";
|
||||||
|
|
||||||
test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomic redemption, and saved sign roundtrip/conflicts", async () => {
|
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)));
|
const [used] = await db.select().from(invites).where(eq(invites.tokenHash, hashToken(token)));
|
||||||
expect(used!.usedCount).toBe(1);
|
expect(used!.usedCount).toBe(1);
|
||||||
const workspace = groupRouter.createCaller(ctx(0));
|
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([
|
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, expiresAt: new Date(0) },
|
||||||
{ kind: "email", tokenHash: hashToken(crypto.randomUUID()), groupId: group!.id, usedCount: 1, maxUses: 1 },
|
{ 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);
|
expect(await workspace.pendingInvites({ groupId: group!.id })).toHaveLength(1);
|
||||||
await expect(groupRouter.createCaller(ctx(1)).pendingInvites({ groupId: group!.id })).rejects.toThrow();
|
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(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");
|
await expect(workspace.createCode({ groupId: group!.id, grantUnlimitedEvents: true })).rejects.toThrow("platform administrators");
|
||||||
const signs = signsRouter.createCaller(ctx(0));
|
const signs = signsRouter.createCaller(ctx(0));
|
||||||
await expect(signsRouter.createCaller(ctx(1)).prepare({ eventId: event!.id })).rejects.toThrow();
|
await expect(signsRouter.createCaller(ctx(1)).prepare({ eventId: event!.id })).rejects.toThrow();
|
||||||
|
|||||||
Reference in New Issue
Block a user