diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 1aefcd9..df78617 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -14,6 +14,7 @@ import { getAppSettings, type AppSettings } from "@/lib/settings"; import { updateInstanceSettings, updateSecuritySettings } from "@/lib/settings-actions"; import { banUser, createInstanceInvite, disableUser, enableUser, grantAdminRole, removeUserFriendships, revokeAdminRole, revokeInvite } from "@/lib/admin-actions"; import { deleteRoom } from "@/lib/room-actions"; +import { isInviteExpired } from "@/lib/invites"; export const dynamic = "force-dynamic"; @@ -364,21 +365,28 @@ function InvitesPanel({ ) : (
- {invites.map((invite) => ( -
- - {invite.code} - {invite.room ? `${invite.room.name} /${invite.room.slug}` : "Instance invite"} - {formatDate(invite.createdAt)} - - {invite.status.toLowerCase()} - {invite.status === "ACTIVE" ? ( -
- - -
- ) : null} -
- ))} + {invites.map((invite) => { + const expired = isInviteExpired(invite.expiresAt); + const label = expired && invite.status === "ACTIVE" ? "expired" : invite.status.toLowerCase(); + return ( +
+ + {invite.code} + + {invite.room ? `${invite.room.name} /${invite.room.slug}` : "Instance invite"} - created {formatDate(invite.createdAt)} + {invite.expiresAt ? ` - expires ${formatDate(invite.expiresAt)}` : " - no expiry"} + + + {label} + {invite.status === "ACTIVE" && !expired ? ( +
+ + +
+ ) : null} +
+ ); + })}
)} diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 193f1f4..3997970 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -6,7 +6,7 @@ import { redirect } from "next/navigation"; export const dynamic = "force-dynamic"; -export default async function RegisterPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) { +export default async function RegisterPage({ searchParams }: { searchParams: Promise<{ error?: string; invite?: string }> }) { if (!(await hasAdminUser())) { redirect("/setup"); } @@ -14,7 +14,7 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro if (settings.registrationMode === "DISABLED") { redirect("/login?error=registration-closed"); } - const { error } = await searchParams; + const { error, invite = "" } = await searchParams; return (
@@ -44,7 +44,7 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro {settings.registrationMode === "INVITE_ONLY" ? ( ) : null} {invite.status === "ACTIVE" && !isExpired(invite.expiresAt) ? (
diff --git a/src/lib/admin-actions.ts b/src/lib/admin-actions.ts index 95fd806..1e5cb10 100644 --- a/src/lib/admin-actions.ts +++ b/src/lib/admin-actions.ts @@ -6,6 +6,7 @@ import { redirect } from "next/navigation"; import { Prisma } from "@prisma/client"; import { prisma } from "./prisma"; import { requireCurrentUser, userIsAdmin } from "./session"; +import { inviteExpiresAt } from "./invites"; async function requireAdmin() { const user = await requireCurrentUser(); @@ -101,8 +102,7 @@ export async function revokeAdminRole(formData: FormData) { export async function createInstanceInvite(formData: FormData) { const admin = await requireAdmin(); const roomId = String(formData.get("roomId") || "") || null; - const expiresDays = Number(formData.get("expiresDays") || 0); - const expiresAt = Number.isFinite(expiresDays) && expiresDays > 0 ? new Date(Date.now() + expiresDays * 24 * 60 * 60 * 1000) : null; + const expiresAt = inviteExpiresAt(formData.get("expiresDays")); const invite = await prisma.invite.create({ data: { code: randomBytes(12).toString("base64url"), diff --git a/src/lib/invites.ts b/src/lib/invites.ts new file mode 100644 index 0000000..8edc62e --- /dev/null +++ b/src/lib/invites.ts @@ -0,0 +1,12 @@ +const maxInviteExpirationDays = 365; + +export function inviteExpiresAt(input: FormDataEntryValue | null, now = Date.now()) { + const days = Math.trunc(Number(input || 0)); + if (!Number.isFinite(days) || days <= 0) return null; + return new Date(now + Math.min(days, maxInviteExpirationDays) * 24 * 60 * 60 * 1000); +} + +export function isInviteExpired(expiresAt: Date | string | null, now = Date.now()) { + if (!expiresAt) return false; + return new Date(expiresAt).getTime() < now; +} diff --git a/src/lib/room-actions.ts b/src/lib/room-actions.ts index bb8ce0f..fa16f12 100644 --- a/src/lib/room-actions.ts +++ b/src/lib/room-actions.ts @@ -7,6 +7,7 @@ import { redirect } from "next/navigation"; import { prisma } from "./prisma"; import { requireCurrentUser, userIsAdmin } from "./session"; import { getAppSettings } from "./settings"; +import { inviteExpiresAt } from "./invites"; function normalizeSlug(value: string) { return value @@ -175,7 +176,6 @@ export async function addRoomMember(formData: FormData) { export async function createRoomInvite(formData: FormData) { const user = await requireCurrentUser(); const roomId = String(formData.get("roomId") || ""); - const expiresDays = Number(formData.get("expiresDays") || 0); if (!roomId) return; const room = await prisma.room.findUnique({ @@ -185,7 +185,7 @@ export async function createRoomInvite(formData: FormData) { if (!room || !canManageRoom(user, room)) return; - const expiresAt = Number.isFinite(expiresDays) && expiresDays > 0 ? new Date(Date.now() + expiresDays * 24 * 60 * 60 * 1000) : null; + const expiresAt = inviteExpiresAt(formData.get("expiresDays")); const invite = await prisma.invite.create({ data: { code: randomBytes(12).toString("base64url"), diff --git a/src/lib/user-actions.ts b/src/lib/user-actions.ts index 0d3a04f..7755779 100644 --- a/src/lib/user-actions.ts +++ b/src/lib/user-actions.ts @@ -6,6 +6,7 @@ import { Prisma, type User } from "@prisma/client"; import { prisma } from "./prisma"; import { clearSession, setSession } from "./session"; import { getAppSettings } from "./settings"; +import { isInviteExpired } from "./invites"; function normalizeUsername(value: FormDataEntryValue | null) { return String(value || "") @@ -32,11 +33,15 @@ export async function registerUser(formData: FormData) { settings.registrationMode === "INVITE_ONLY" ? await prisma.invite.findUnique({ where: { code: inviteCode } }) : null; + const inviteExpired = isInviteExpired(invite?.expiresAt || null); if ( settings.registrationMode === "INVITE_ONLY" && - (!invite || invite.status !== "ACTIVE" || (invite.expiresAt && invite.expiresAt.getTime() < Date.now())) + (!invite || invite.status !== "ACTIVE" || inviteExpired) ) { + if (invite?.status === "ACTIVE" && inviteExpired) { + await prisma.invite.update({ where: { id: invite.id }, data: { status: "EXPIRED" } }); + } redirect("/register?error=invite"); } diff --git a/tests/invites.test.ts b/tests/invites.test.ts new file mode 100644 index 0000000..3dd4c98 --- /dev/null +++ b/tests/invites.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { inviteExpiresAt, isInviteExpired } from "../src/lib/invites"; + +const now = Date.UTC(2026, 0, 1); +const day = 24 * 60 * 60 * 1000; + +describe("inviteExpiresAt", () => { + it("returns null for invalid or non-expiring values", () => { + expect(inviteExpiresAt(null, now)).toBeNull(); + expect(inviteExpiresAt("0", now)).toBeNull(); + expect(inviteExpiresAt("-3", now)).toBeNull(); + expect(inviteExpiresAt("not-a-number", now)).toBeNull(); + }); + + it("creates an expiration date for valid day counts", () => { + expect(inviteExpiresAt("7", now)?.getTime()).toBe(now + 7 * day); + }); + + it("caps expiration at one year", () => { + expect(inviteExpiresAt("9999", now)?.getTime()).toBe(now + 365 * day); + }); + + it("detects expired invite timestamps", () => { + expect(isInviteExpired(new Date(now - day), now)).toBe(true); + expect(isInviteExpired(new Date(now + day), now)).toBe(false); + expect(isInviteExpired(null, now)).toBe(false); + }); +});