Improve invite handling
All checks were successful
Template Compliance / compliance (push) Successful in 5s

This commit is contained in:
ToxicCrzay270
2026-06-11 22:42:35 +02:00
parent abee76c9b1
commit 928a3815ed
9 changed files with 105 additions and 30 deletions

View File

@@ -14,6 +14,7 @@ import { getAppSettings, type AppSettings } from "@/lib/settings";
import { updateInstanceSettings, updateSecuritySettings } from "@/lib/settings-actions"; import { updateInstanceSettings, updateSecuritySettings } from "@/lib/settings-actions";
import { banUser, createInstanceInvite, disableUser, enableUser, grantAdminRole, removeUserFriendships, revokeAdminRole, revokeInvite } from "@/lib/admin-actions"; import { banUser, createInstanceInvite, disableUser, enableUser, grantAdminRole, removeUserFriendships, revokeAdminRole, revokeInvite } from "@/lib/admin-actions";
import { deleteRoom } from "@/lib/room-actions"; import { deleteRoom } from "@/lib/room-actions";
import { isInviteExpired } from "@/lib/invites";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -364,21 +365,28 @@ function InvitesPanel({
<EmptyState title="No invites yet" description="Create an invite to allow registration or room access workflows." /> <EmptyState title="No invites yet" description="Create an invite to allow registration or room access workflows." />
) : ( ) : (
<div className="settings-list"> <div className="settings-list">
{invites.map((invite) => ( {invites.map((invite) => {
<div className="setting-row" key={invite.id}> const expired = isInviteExpired(invite.expiresAt);
<span> const label = expired && invite.status === "ACTIVE" ? "expired" : invite.status.toLowerCase();
<strong>{invite.code}</strong> return (
<small>{invite.room ? `${invite.room.name} /${invite.room.slug}` : "Instance invite"} - {formatDate(invite.createdAt)}</small> <div className="setting-row" key={invite.id}>
</span> <span>
<StatusBadge tone={invite.status === "ACTIVE" ? "good" : "warn"}>{invite.status.toLowerCase()}</StatusBadge> <strong>{invite.code}</strong>
{invite.status === "ACTIVE" ? ( <small>
<form action={revokeInvite}> {invite.room ? `${invite.room.name} /${invite.room.slug}` : "Instance invite"} - created {formatDate(invite.createdAt)}
<input type="hidden" name="inviteId" value={invite.id} /> {invite.expiresAt ? ` - expires ${formatDate(invite.expiresAt)}` : " - no expiry"}
<button className="button compact-button danger" type="submit">Revoke</button> </small>
</form> </span>
) : null} <StatusBadge tone={invite.status === "ACTIVE" && !expired ? "good" : "warn"}>{label}</StatusBadge>
</div> {invite.status === "ACTIVE" && !expired ? (
))} <form action={revokeInvite}>
<input type="hidden" name="inviteId" value={invite.id} />
<button className="button compact-button danger" type="submit">Revoke</button>
</form>
) : null}
</div>
);
})}
</div> </div>
)} )}
</Panel> </Panel>

View File

@@ -6,7 +6,7 @@ import { redirect } from "next/navigation";
export const dynamic = "force-dynamic"; 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())) { if (!(await hasAdminUser())) {
redirect("/setup"); redirect("/setup");
} }
@@ -14,7 +14,7 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro
if (settings.registrationMode === "DISABLED") { if (settings.registrationMode === "DISABLED") {
redirect("/login?error=registration-closed"); redirect("/login?error=registration-closed");
} }
const { error } = await searchParams; const { error, invite = "" } = await searchParams;
return ( return (
<main className="auth-page"> <main className="auth-page">
@@ -44,7 +44,7 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro
{settings.registrationMode === "INVITE_ONLY" ? ( {settings.registrationMode === "INVITE_ONLY" ? (
<label> <label>
Invite code Invite code
<input className="input" name="inviteCode" autoComplete="off" required /> <input className="input" name="inviteCode" autoComplete="off" defaultValue={invite} required />
</label> </label>
) : null} ) : null}
<button className="button primary" type="submit"> <button className="button primary" type="submit">

View File

@@ -31,11 +31,6 @@ export default async function RoomPage({
include: { include: {
owner: true, owner: true,
members: { include: { user: true } }, members: { include: { user: true } },
invites: {
include: { creator: true },
orderBy: { createdAt: "desc" },
take: 12
},
mediaSources: { include: { submitter: true }, orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }], take: 40 } mediaSources: { include: { submitter: true }, orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }], take: 40 }
} }
}); });
@@ -75,6 +70,15 @@ export default async function RoomPage({
redirect("/dashboard"); redirect("/dashboard");
} }
const roomInvites = canManageRoom
? await prisma.invite.findMany({
where: { roomId: room.id },
include: { creator: true },
orderBy: { createdAt: "desc" },
take: 12
})
: [];
const shell = await getShellContext(user); const shell = await getShellContext(user);
return ( return (
@@ -139,9 +143,9 @@ export default async function RoomPage({
avatarUrl: member.user.avatarUrl, avatarUrl: member.user.avatarUrl,
role: member.canManage ? "Manager" : "Member", role: member.canManage ? "Manager" : "Member",
status: member.userId === user.id ? "Online" : "Allowed" status: member.userId === user.id ? "Online" : "Allowed"
})) }))
]} ]}
invites={room.invites.map((invite) => ({ invites={roomInvites.map((invite) => ({
id: invite.id, id: invite.id,
code: invite.code, code: invite.code,
status: invite.status, status: invite.status,

View File

@@ -5,6 +5,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Check,
Clipboard,
ExternalLink, ExternalLink,
MessageSquareText, MessageSquareText,
Pause, Pause,
@@ -173,6 +175,7 @@ export function RoomConsole({
const [socketError, setSocketError] = useState(""); const [socketError, setSocketError] = useState("");
const [syncHealth, setSyncHealth] = useState<"idle" | "pending" | "confirmed" | "failed">("idle"); const [syncHealth, setSyncHealth] = useState<"idle" | "pending" | "confirmed" | "failed">("idle");
const [lastAckAt, setLastAckAt] = useState<number | null>(null); const [lastAckAt, setLastAckAt] = useState<number | null>(null);
const [copiedInviteId, setCopiedInviteId] = useState("");
const [syncBlocked, setSyncBlocked] = useState(false); const [syncBlocked, setSyncBlocked] = useState(false);
const [playerReady, setPlayerReady] = useState(false); const [playerReady, setPlayerReady] = useState(false);
const [providerIssue, setProviderIssue] = useState(""); const [providerIssue, setProviderIssue] = useState("");
@@ -339,6 +342,18 @@ export function RoomConsole({
form.reset(); form.reset();
} }
async function copyInvite(invite: RoomInvite) {
try {
const inviteUrl = new URL("/register", window.location.origin);
inviteUrl.searchParams.set("invite", invite.code);
await navigator.clipboard.writeText(inviteUrl.toString());
setCopiedInviteId(invite.id);
window.setTimeout(() => setCopiedInviteId((current) => (current === invite.id ? "" : current)), 2000);
} catch {
setSocketError("Invite link could not be copied.");
}
}
return ( return (
<section className="watch-console"> <section className="watch-console">
<div className="player-column"> <div className="player-column">
@@ -595,6 +610,9 @@ export function RoomConsole({
<StatusBadge tone={invite.status === "ACTIVE" && !isExpired(invite.expiresAt) ? "good" : "warn"}> <StatusBadge tone={invite.status === "ACTIVE" && !isExpired(invite.expiresAt) ? "good" : "warn"}>
{isExpired(invite.expiresAt) ? "expired" : invite.status.toLowerCase()} {isExpired(invite.expiresAt) ? "expired" : invite.status.toLowerCase()}
</StatusBadge> </StatusBadge>
<button className="icon-button compact animated-button" type="button" title="Copy invite link" onClick={() => void copyInvite(invite)}>
{copiedInviteId === invite.id ? <Check size={14} /> : <Clipboard size={14} />}
</button>
{invite.status === "ACTIVE" && !isExpired(invite.expiresAt) ? ( {invite.status === "ACTIVE" && !isExpired(invite.expiresAt) ? (
<form action={revokeRoomInvite}> <form action={revokeRoomInvite}>
<input type="hidden" name="inviteId" value={invite.id} /> <input type="hidden" name="inviteId" value={invite.id} />

View File

@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { requireCurrentUser, userIsAdmin } from "./session"; import { requireCurrentUser, userIsAdmin } from "./session";
import { inviteExpiresAt } from "./invites";
async function requireAdmin() { async function requireAdmin() {
const user = await requireCurrentUser(); const user = await requireCurrentUser();
@@ -101,8 +102,7 @@ export async function revokeAdminRole(formData: FormData) {
export async function createInstanceInvite(formData: FormData) { export async function createInstanceInvite(formData: FormData) {
const admin = await requireAdmin(); const admin = await requireAdmin();
const roomId = String(formData.get("roomId") || "") || null; const roomId = String(formData.get("roomId") || "") || null;
const expiresDays = Number(formData.get("expiresDays") || 0); const expiresAt = inviteExpiresAt(formData.get("expiresDays"));
const expiresAt = Number.isFinite(expiresDays) && expiresDays > 0 ? new Date(Date.now() + expiresDays * 24 * 60 * 60 * 1000) : null;
const invite = await prisma.invite.create({ const invite = await prisma.invite.create({
data: { data: {
code: randomBytes(12).toString("base64url"), code: randomBytes(12).toString("base64url"),

12
src/lib/invites.ts Normal file
View File

@@ -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;
}

View File

@@ -7,6 +7,7 @@ import { redirect } from "next/navigation";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { requireCurrentUser, userIsAdmin } from "./session"; import { requireCurrentUser, userIsAdmin } from "./session";
import { getAppSettings } from "./settings"; import { getAppSettings } from "./settings";
import { inviteExpiresAt } from "./invites";
function normalizeSlug(value: string) { function normalizeSlug(value: string) {
return value return value
@@ -175,7 +176,6 @@ export async function addRoomMember(formData: FormData) {
export async function createRoomInvite(formData: FormData) { export async function createRoomInvite(formData: FormData) {
const user = await requireCurrentUser(); const user = await requireCurrentUser();
const roomId = String(formData.get("roomId") || ""); const roomId = String(formData.get("roomId") || "");
const expiresDays = Number(formData.get("expiresDays") || 0);
if (!roomId) return; if (!roomId) return;
const room = await prisma.room.findUnique({ const room = await prisma.room.findUnique({
@@ -185,7 +185,7 @@ export async function createRoomInvite(formData: FormData) {
if (!room || !canManageRoom(user, room)) return; 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({ const invite = await prisma.invite.create({
data: { data: {
code: randomBytes(12).toString("base64url"), code: randomBytes(12).toString("base64url"),

View File

@@ -6,6 +6,7 @@ import { Prisma, type User } from "@prisma/client";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { clearSession, setSession } from "./session"; import { clearSession, setSession } from "./session";
import { getAppSettings } from "./settings"; import { getAppSettings } from "./settings";
import { isInviteExpired } from "./invites";
function normalizeUsername(value: FormDataEntryValue | null) { function normalizeUsername(value: FormDataEntryValue | null) {
return String(value || "") return String(value || "")
@@ -32,11 +33,15 @@ export async function registerUser(formData: FormData) {
settings.registrationMode === "INVITE_ONLY" settings.registrationMode === "INVITE_ONLY"
? await prisma.invite.findUnique({ where: { code: inviteCode } }) ? await prisma.invite.findUnique({ where: { code: inviteCode } })
: null; : null;
const inviteExpired = isInviteExpired(invite?.expiresAt || null);
if ( if (
settings.registrationMode === "INVITE_ONLY" && 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"); redirect("/register?error=invite");
} }

28
tests/invites.test.ts Normal file
View File

@@ -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);
});
});