Separate invite resend and regeneration and add copy link
This commit is contained in:
@@ -9,14 +9,32 @@ 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";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Mail } from "lucide-react";
|
import { Copy, Mail, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
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, canRevoke = false }: { groupId: string; eventId?: string; canRevoke?: boolean }) {
|
export function PendingInvites({ groupId, eventId, canRevoke = false }: { groupId: string; eventId?: string; canRevoke?: boolean }) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const confirmation = useConfirmAction();
|
const confirmation = useConfirmAction();
|
||||||
const resend = api.group.resendInvite.useMutation({ onSuccess: () => toast.success("Invitation resent. Previous link replaced."), onError: error => toast.error(error.message) });
|
const copyLink = api.group.copyInviteLink.useMutation();
|
||||||
|
async function copyInvite(inviteId: string) {
|
||||||
|
try {
|
||||||
|
// Start the clipboard operation in the click gesture, including on Safari.
|
||||||
|
if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) {
|
||||||
|
const content = copyLink.mutateAsync({ groupId, inviteId }).then(({ url }) => new Blob([url], { type: "text/plain" }));
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ "text/plain": content })]);
|
||||||
|
} else {
|
||||||
|
const { url } = await copyLink.mutateAsync({ groupId, inviteId });
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
}
|
||||||
|
toast.success("Invitation link copied");
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not copy the link. Check clipboard permissions and try again.");
|
||||||
|
} finally {
|
||||||
|
copyLink.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resend = api.group.resendInvite.useMutation({ onSuccess: async (_, input) => { toast.success(input.regenerate ? "New invitation link emailed. Previous link disabled." : "Invitation resent with the same link."); await utils.group.pendingInvites.invalidate(); }, onError: error => toast.error(error.message) });
|
||||||
const revoke = api.group.revokeInvite.useMutation({
|
const revoke = api.group.revokeInvite.useMutation({
|
||||||
onSuccess: async () => { toast.success("Invite revoked"); await utils.group.pendingInvites.invalidate(); },
|
onSuccess: async () => { toast.success("Invite revoked"); await utils.group.pendingInvites.invalidate(); },
|
||||||
onError: error => toast.error(error.message),
|
onError: error => toast.error(error.message),
|
||||||
@@ -42,11 +60,13 @@ export function PendingInvites({ groupId, eventId, canRevoke = false }: { groupI
|
|||||||
{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={() => confirmation.ask("Revoke this invitation? It will stop granting access. Existing members keep their access.", () => revoke.mutate({ groupId, inviteId: invite.id }))}>Revoke</Button> : null}
|
{canRevoke && invite.kind === "email" ? <Button type="button" variant="outline" disabled={!invite.canResend || copyLink.isPending || resend.isPending || revoke.isPending} title={!invite.canResend ? "Older invite: regenerate once to enable copying" : undefined} onClick={() => void copyInvite(invite.id)}><Copy data-icon="inline-start" />{copyLink.isPending && copyLink.variables?.inviteId === invite.id ? "Copying…" : "Copy link"}</Button> : null}
|
||||||
{canRevoke && invite.kind === "email" ? <Button type="button" variant="outline" disabled={resend.isPending || revoke.isPending} onClick={() => confirmation.ask("Send a new invitation email? This replaces the previous link without extending its expiry.", () => resend.mutate({ groupId, inviteId: invite.id }))}>
|
{canRevoke ? <Button type="button" variant="outline" disabled={revoke.isPending || resend.isPending} onClick={() => confirmation.ask("Revoke this invitation? It will stop granting access. Existing members keep their access.", () => revoke.mutate({ groupId, inviteId: invite.id }))}>Revoke</Button> : null}
|
||||||
|
{canRevoke && invite.kind === "email" ? <Button type="button" variant="outline" disabled={!invite.canResend || resend.isPending || revoke.isPending} title={!invite.canResend ? "Older invite: regenerate once to enable same-link resending" : undefined} onClick={() => resend.mutate({ groupId, inviteId: invite.id })}>
|
||||||
{resend.isPending && resend.variables?.inviteId === invite.id ? <Spinner data-icon="inline-start" /> : <Mail data-icon="inline-start" />}
|
{resend.isPending && resend.variables?.inviteId === invite.id ? <Spinner data-icon="inline-start" /> : <Mail data-icon="inline-start" />}
|
||||||
{resend.isPending && resend.variables?.inviteId === invite.id ? "Sending…" : "Resend invite"}
|
{resend.isPending && resend.variables?.inviteId === invite.id ? "Sending…" : "Resend invite"}
|
||||||
</Button> : null}
|
</Button> : null}
|
||||||
|
{canRevoke && invite.kind === "email" ? <Button type="button" variant="outline" disabled={resend.isPending || revoke.isPending} onClick={() => confirmation.ask("Replace this invitation link and email the new one? The previous link will stop working. Its expiry stays the same.", () => resend.mutate({ groupId, inviteId: invite.id, regenerate: true }))}><RefreshCw data-icon="inline-start" />Regenerate & email</Button> : null}
|
||||||
</div>
|
</div>
|
||||||
</li>)}</ul>}
|
</li>)}</ul>}
|
||||||
</section>;
|
</section>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { and, desc, eq, gt, isNull, lt, or } from "drizzle-orm";
|
import { and, desc, eq, gt, isNull, lt, or, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
auditEvents,
|
auditEvents,
|
||||||
eventMemberships,
|
eventMemberships,
|
||||||
@@ -29,6 +29,7 @@ import { resolveGroupQuota } from "@/server/entitlements";
|
|||||||
import { countGroupOwners } from "@/server/membership";
|
import { countGroupOwners } from "@/server/membership";
|
||||||
import { writeAudit } from "@/server/audit";
|
import { writeAudit } from "@/server/audit";
|
||||||
import { hashToken, newToken } from "@/server/tokens";
|
import { hashToken, newToken } from "@/server/tokens";
|
||||||
|
import { decryptInviteToken, encryptInviteToken } from "@/server/invite-token";
|
||||||
import { createInviteCode } from "@/server/invites";
|
import { createInviteCode } from "@/server/invites";
|
||||||
import { sendStaffInviteEmail } from "@album/email";
|
import { sendStaffInviteEmail } from "@album/email";
|
||||||
import { publicAppOrigin } from "@/server/public-app-url";
|
import { publicAppOrigin } from "@/server/public-app-url";
|
||||||
@@ -57,7 +58,23 @@ async function validateInviteGrants(userId: string, input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const groupRouter = createTRPCRouter({
|
export const groupRouter = createTRPCRouter({
|
||||||
resendInvite: protectedProcedure.input(z.object({ groupId: z.string().uuid(), inviteId: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
copyInviteLink: 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);
|
||||||
|
const [invite] = await getDb().select().from(invites).where(and(eq(invites.id, input.inviteId), eq(invites.groupId, input.groupId))).limit(1);
|
||||||
|
if (!invite || invite.kind !== "email") throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
assertInviteRedeemable(invite);
|
||||||
|
await validateInviteGrants(ctx.session.user.id, { groupId: input.groupId, eventId: invite.eventId ?? undefined, eventRole: invite.eventRole ?? undefined, grantUnlimitedEvents: invite.grantUnlimitedEvents, grantEventLimit: invite.grantEventLimit, grantComplimentary: invite.grantComplimentary });
|
||||||
|
if (!invite.tokenEncrypted) throw new TRPCError({ code: "BAD_REQUEST", message: "This older invitation needs an explicit regeneration before its link can be copied." });
|
||||||
|
try {
|
||||||
|
const token = decryptInviteToken(invite.tokenEncrypted);
|
||||||
|
if (hashToken(token) !== invite.tokenHash) throw new Error("Token mismatch");
|
||||||
|
return { url: `${publicAppOrigin()}/invitations/${token}` };
|
||||||
|
} catch {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "The saved link cannot be recovered. Regenerate this invitation explicitly." });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
resendInvite: protectedProcedure.input(z.object({ groupId: z.string().uuid(), inviteId: z.string().uuid(), regenerate: z.boolean().default(false) })).mutation(async ({ ctx, input }) => {
|
||||||
const { access } = await loadGroupAccess(ctx.session.user.id, input.groupId, await getPlatformRole(ctx.session.user.id));
|
const { access } = await loadGroupAccess(ctx.session.user.id, input.groupId, await getPlatformRole(ctx.session.user.id));
|
||||||
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
|
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
|
||||||
const limit = await consumeRateLimit({ namespace: "invite-resend", identifier: `${input.groupId}:${input.inviteId}`, limit: 1, windowMs: 60000 });
|
const limit = await consumeRateLimit({ namespace: "invite-resend", identifier: `${input.groupId}:${input.inviteId}`, limit: 1, windowMs: 60000 });
|
||||||
@@ -67,10 +84,21 @@ export const groupRouter = createTRPCRouter({
|
|||||||
if (!invite || invite.kind !== "email" || !invite.email) throw new TRPCError({ code: "NOT_FOUND" });
|
if (!invite || invite.kind !== "email" || !invite.email) throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
assertInviteRedeemable(invite);
|
assertInviteRedeemable(invite);
|
||||||
await validateInviteGrants(ctx.session.user.id, { groupId: input.groupId, eventId: invite.eventId ?? undefined, eventRole: invite.eventRole ?? undefined, grantUnlimitedEvents: invite.grantUnlimitedEvents, grantEventLimit: invite.grantEventLimit, grantComplimentary: invite.grantComplimentary });
|
await validateInviteGrants(ctx.session.user.id, { groupId: input.groupId, eventId: invite.eventId ?? undefined, eventRole: invite.eventRole ?? undefined, grantUnlimitedEvents: invite.grantUnlimitedEvents, grantEventLimit: invite.grantEventLimit, grantComplimentary: invite.grantComplimentary });
|
||||||
const token = newToken();
|
let token: string;
|
||||||
await tx.update(invites).set({ tokenHash: hashToken(token), updatedAt: new Date() }).where(and(eq(invites.id, invite.id), eq(invites.groupId, input.groupId)));
|
if (input.regenerate) {
|
||||||
|
token = newToken();
|
||||||
|
await tx.update(invites).set({ tokenHash: hashToken(token), tokenEncrypted: encryptInviteToken(token), updatedAt: new Date() }).where(and(eq(invites.id, invite.id), eq(invites.groupId, input.groupId)));
|
||||||
|
} else {
|
||||||
|
if (!invite.tokenEncrypted) throw new TRPCError({ code: "BAD_REQUEST", message: "This older invitation needs one explicit regeneration before it can be resent." });
|
||||||
|
try {
|
||||||
|
token = decryptInviteToken(invite.tokenEncrypted);
|
||||||
|
if (hashToken(token) !== invite.tokenHash) throw new Error("Token mismatch");
|
||||||
|
} catch {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "The saved link cannot be recovered. Regenerate this invitation explicitly." });
|
||||||
|
}
|
||||||
|
}
|
||||||
await sendStaffInviteEmail({ to: invite.email, inviterName: ctx.session.user.name, inviteUrl: `${publicAppOrigin()}/invitations/${token}` });
|
await sendStaffInviteEmail({ to: invite.email, inviterName: ctx.session.user.name, inviteUrl: `${publicAppOrigin()}/invitations/${token}` });
|
||||||
await tx.insert(auditEvents).values({ groupId: input.groupId, eventId: invite.eventId, actorUserId: ctx.session.user.id, action: "invite.resend", subjectType: "invite", subjectId: invite.id });
|
await tx.insert(auditEvents).values({ groupId: input.groupId, eventId: invite.eventId, actorUserId: ctx.session.user.id, action: input.regenerate ? "invite.regenerate" : "invite.resend", subjectType: "invite", subjectId: invite.id });
|
||||||
});
|
});
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
@@ -119,6 +147,7 @@ export const groupRouter = createTRPCRouter({
|
|||||||
eventId: invites.eventId, eventTitle: events.title,
|
eventId: invites.eventId, eventTitle: events.title,
|
||||||
reusable: invites.reusable, usedCount: invites.usedCount, maxUses: invites.maxUses,
|
reusable: invites.reusable, usedCount: invites.usedCount, maxUses: invites.maxUses,
|
||||||
createdAt: invites.createdAt, expiresAt: invites.expiresAt,
|
createdAt: invites.createdAt, expiresAt: invites.expiresAt,
|
||||||
|
canResend: sql<boolean>`${invites.tokenEncrypted} is not null`,
|
||||||
}).from(invites).leftJoin(events, and(eq(events.id, invites.eventId), eq(events.groupId, input.groupId)))
|
}).from(invites).leftJoin(events, and(eq(events.id, invites.eventId), eq(events.groupId, input.groupId)))
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(invites.groupId, input.groupId),
|
eq(invites.groupId, input.groupId),
|
||||||
@@ -322,6 +351,7 @@ export const groupRouter = createTRPCRouter({
|
|||||||
kind: "email",
|
kind: "email",
|
||||||
email: input.email,
|
email: input.email,
|
||||||
tokenHash: hashToken(token),
|
tokenHash: hashToken(token),
|
||||||
|
tokenEncrypted: encryptInviteToken(token),
|
||||||
groupId: input.groupId,
|
groupId: input.groupId,
|
||||||
eventId: input.eventId ?? null,
|
eventId: input.eventId ?? null,
|
||||||
groupRole: input.groupRole ?? "member",
|
groupRole: input.groupRole ?? "member",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
function key() {
|
||||||
|
const secret = process.env.BETTER_AUTH_SECRET;
|
||||||
|
if (!secret) throw new Error("BETTER_AUTH_SECRET is required");
|
||||||
|
return createHash("sha256").update(`manyangles:invite-token:v1:${secret}`).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptInviteToken(token: string) {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", key(), iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
|
||||||
|
return ["v1", iv.toString("base64url"), cipher.getAuthTag().toString("base64url"), encrypted.toString("base64url")].join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptInviteToken(value: string) {
|
||||||
|
const [version, iv, tag, encrypted] = value.split(".");
|
||||||
|
if (version !== "v1" || !iv || !tag || !encrypted) throw new Error("Invalid encrypted invitation");
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64url"));
|
||||||
|
decipher.setAuthTag(Buffer.from(tag, "base64url"));
|
||||||
|
return Buffer.concat([decipher.update(Buffer.from(encrypted, "base64url")), decipher.final()]).toString("utf8");
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { getDb, user, groups, events, eventMemberships, groupMemberships, invite
|
|||||||
import { bannersRouter } from "./api/routers/banners";
|
import { bannersRouter } from "./api/routers/banners";
|
||||||
import { deletePrefix } from "@album/storage";
|
import { deletePrefix } from "@album/storage";
|
||||||
import { hashToken } from "./tokens";
|
import { hashToken } from "./tokens";
|
||||||
|
import { encryptInviteToken } from "./invite-token";
|
||||||
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";
|
||||||
@@ -64,11 +65,20 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomi
|
|||||||
const [revoked] = await db.select().from(invites).where(and(eq(invites.groupId, group!.id), eq(invites.id, pending!.id)));
|
const [revoked] = await db.select().from(invites).where(and(eq(invites.groupId, group!.id), eq(invites.id, pending!.id)));
|
||||||
expect(revoked!.status).toBe("revoked");
|
expect(revoked!.status).toBe("revoked");
|
||||||
const resendToken = crypto.randomUUID();
|
const resendToken = crypto.randomUUID();
|
||||||
const [resendTarget] = await db.insert(invites).values({ groupId: group!.id, kind: "email", email: people[1]!.email, tokenHash: hashToken(resendToken) }).returning();
|
const [resendTarget] = await db.insert(invites).values({ groupId: group!.id, kind: "email", email: people[1]!.email, tokenHash: hashToken(resendToken), tokenEncrypted: encryptInviteToken(resendToken) }).returning();
|
||||||
await expect(groupRouter.createCaller(ctx(1)).resendInvite({ groupId: group!.id, inviteId: resendTarget!.id })).rejects.toThrow();
|
await expect(groupRouter.createCaller(ctx(1)).resendInvite({ groupId: group!.id, inviteId: resendTarget!.id })).rejects.toThrow();
|
||||||
await workspace.resendInvite({ groupId: group!.id, inviteId: resendTarget!.id });
|
await workspace.resendInvite({ groupId: group!.id, inviteId: resendTarget!.id });
|
||||||
expect(await db.select().from(invites).where(and(eq(invites.groupId, group!.id), eq(invites.tokenHash, hashToken(resendToken))))).toHaveLength(0);
|
const copied = await workspace.copyInviteLink({ groupId: group!.id, inviteId: resendTarget!.id });
|
||||||
|
expect(new URL(copied.url).pathname).toBe(`/invitations/${resendToken}`);
|
||||||
|
await expect(groupRouter.createCaller(ctx(1)).copyInviteLink({ groupId: group!.id, inviteId: resendTarget!.id })).rejects.toThrow();
|
||||||
|
expect(await db.select().from(invites).where(and(eq(invites.groupId, group!.id), eq(invites.tokenHash, hashToken(resendToken))))).toHaveLength(1);
|
||||||
|
const rotateToken = crypto.randomUUID();
|
||||||
|
const [rotateTarget] = await db.insert(invites).values({ groupId: group!.id, kind: "email", email: people[1]!.email, tokenHash: hashToken(rotateToken) }).returning();
|
||||||
|
await workspace.resendInvite({ groupId: group!.id, inviteId: rotateTarget!.id, regenerate: true });
|
||||||
|
expect(await db.select().from(invites).where(and(eq(invites.groupId, group!.id), eq(invites.tokenHash, hashToken(rotateToken))))).toHaveLength(0);
|
||||||
await expect(workspace.resendInvite({ groupId: group!.id, inviteId: resendTarget!.id })).rejects.toThrow("Wait one minute");
|
await expect(workspace.resendInvite({ groupId: group!.id, inviteId: resendTarget!.id })).rejects.toThrow("Wait one minute");
|
||||||
|
await workspace.revokeInvite({ groupId: group!.id, inviteId: resendTarget!.id });
|
||||||
|
await expect(workspace.copyInviteLink({ groupId: group!.id, inviteId: resendTarget!.id })).rejects.toThrow();
|
||||||
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();
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "invites" ADD COLUMN "token_encrypted" text;
|
||||||
@@ -79,6 +79,7 @@
|
|||||||
"tag": "0010_banner_crop",
|
"tag": "0010_banner_crop",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{ "idx": 11, "version": "7", "when": 1789086000000, "tag": "0011_event_signs", "breakpoints": true }
|
{ "idx": 11, "version": "7", "when": 1789086000000, "tag": "0011_event_signs", "breakpoints": true },
|
||||||
|
{ "idx": 12, "version": "7", "when": 1789086100000, "tag": "0012_invite_token", "breakpoints": true }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ export const invites = pgTable(
|
|||||||
status: inviteStatus("status").notNull().default("pending"),
|
status: inviteStatus("status").notNull().default("pending"),
|
||||||
email: text("email"),
|
email: text("email"),
|
||||||
tokenHash: text("token_hash").notNull(),
|
tokenHash: text("token_hash").notNull(),
|
||||||
|
tokenEncrypted: text("token_encrypted"),
|
||||||
reusable: boolean("reusable").notNull().default(false),
|
reusable: boolean("reusable").notNull().default(false),
|
||||||
maxUses: integer("max_uses").notNull().default(1),
|
maxUses: integer("max_uses").notNull().default(1),
|
||||||
usedCount: integer("used_count").notNull().default(0),
|
usedCount: integer("used_count").notNull().default(0),
|
||||||
|
|||||||
Reference in New Issue
Block a user