Improve invite handling
All checks were successful
Template Compliance / compliance (push) Successful in 5s
All checks were successful
Template Compliance / compliance (push) Successful in 5s
This commit is contained in:
@@ -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({
|
||||
<EmptyState title="No invites yet" description="Create an invite to allow registration or room access workflows." />
|
||||
) : (
|
||||
<div className="settings-list">
|
||||
{invites.map((invite) => (
|
||||
{invites.map((invite) => {
|
||||
const expired = isInviteExpired(invite.expiresAt);
|
||||
const label = expired && invite.status === "ACTIVE" ? "expired" : invite.status.toLowerCase();
|
||||
return (
|
||||
<div className="setting-row" key={invite.id}>
|
||||
<span>
|
||||
<strong>{invite.code}</strong>
|
||||
<small>{invite.room ? `${invite.room.name} /${invite.room.slug}` : "Instance invite"} - {formatDate(invite.createdAt)}</small>
|
||||
<small>
|
||||
{invite.room ? `${invite.room.name} /${invite.room.slug}` : "Instance invite"} - created {formatDate(invite.createdAt)}
|
||||
{invite.expiresAt ? ` - expires ${formatDate(invite.expiresAt)}` : " - no expiry"}
|
||||
</small>
|
||||
</span>
|
||||
<StatusBadge tone={invite.status === "ACTIVE" ? "good" : "warn"}>{invite.status.toLowerCase()}</StatusBadge>
|
||||
{invite.status === "ACTIVE" ? (
|
||||
<StatusBadge tone={invite.status === "ACTIVE" && !expired ? "good" : "warn"}>{label}</StatusBadge>
|
||||
{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>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
@@ -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 (
|
||||
<main className="auth-page">
|
||||
@@ -44,7 +44,7 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro
|
||||
{settings.registrationMode === "INVITE_ONLY" ? (
|
||||
<label>
|
||||
Invite code
|
||||
<input className="input" name="inviteCode" autoComplete="off" required />
|
||||
<input className="input" name="inviteCode" autoComplete="off" defaultValue={invite} required />
|
||||
</label>
|
||||
) : null}
|
||||
<button className="button primary" type="submit">
|
||||
|
||||
@@ -31,11 +31,6 @@ export default async function RoomPage({
|
||||
include: {
|
||||
owner: 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 }
|
||||
}
|
||||
});
|
||||
@@ -75,6 +70,15 @@ export default async function RoomPage({
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -141,7 +145,7 @@ export default async function RoomPage({
|
||||
status: member.userId === user.id ? "Online" : "Allowed"
|
||||
}))
|
||||
]}
|
||||
invites={room.invites.map((invite) => ({
|
||||
invites={roomInvites.map((invite) => ({
|
||||
id: invite.id,
|
||||
code: invite.code,
|
||||
status: invite.status,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Check,
|
||||
Clipboard,
|
||||
ExternalLink,
|
||||
MessageSquareText,
|
||||
Pause,
|
||||
@@ -173,6 +175,7 @@ export function RoomConsole({
|
||||
const [socketError, setSocketError] = useState("");
|
||||
const [syncHealth, setSyncHealth] = useState<"idle" | "pending" | "confirmed" | "failed">("idle");
|
||||
const [lastAckAt, setLastAckAt] = useState<number | null>(null);
|
||||
const [copiedInviteId, setCopiedInviteId] = useState("");
|
||||
const [syncBlocked, setSyncBlocked] = useState(false);
|
||||
const [playerReady, setPlayerReady] = useState(false);
|
||||
const [providerIssue, setProviderIssue] = useState("");
|
||||
@@ -339,6 +342,18 @@ export function RoomConsole({
|
||||
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 (
|
||||
<section className="watch-console">
|
||||
<div className="player-column">
|
||||
@@ -595,6 +610,9 @@ export function RoomConsole({
|
||||
<StatusBadge tone={invite.status === "ACTIVE" && !isExpired(invite.expiresAt) ? "good" : "warn"}>
|
||||
{isExpired(invite.expiresAt) ? "expired" : invite.status.toLowerCase()}
|
||||
</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) ? (
|
||||
<form action={revokeRoomInvite}>
|
||||
<input type="hidden" name="inviteId" value={invite.id} />
|
||||
|
||||
@@ -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"),
|
||||
|
||||
12
src/lib/invites.ts
Normal file
12
src/lib/invites.ts
Normal 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;
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
28
tests/invites.test.ts
Normal file
28
tests/invites.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user