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 { 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) => {
|
||||||
|
const expired = isInviteExpired(invite.expiresAt);
|
||||||
|
const label = expired && invite.status === "ACTIVE" ? "expired" : invite.status.toLowerCase();
|
||||||
|
return (
|
||||||
<div className="setting-row" key={invite.id}>
|
<div className="setting-row" key={invite.id}>
|
||||||
<span>
|
<span>
|
||||||
<strong>{invite.code}</strong>
|
<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>
|
</span>
|
||||||
<StatusBadge tone={invite.status === "ACTIVE" ? "good" : "warn"}>{invite.status.toLowerCase()}</StatusBadge>
|
<StatusBadge tone={invite.status === "ACTIVE" && !expired ? "good" : "warn"}>{label}</StatusBadge>
|
||||||
{invite.status === "ACTIVE" ? (
|
{invite.status === "ACTIVE" && !expired ? (
|
||||||
<form action={revokeInvite}>
|
<form action={revokeInvite}>
|
||||||
<input type="hidden" name="inviteId" value={invite.id} />
|
<input type="hidden" name="inviteId" value={invite.id} />
|
||||||
<button className="button compact-button danger" type="submit">Revoke</button>
|
<button className="button compact-button danger" type="submit">Revoke</button>
|
||||||
</form>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -141,7 +145,7 @@ export default async function RoomPage({
|
|||||||
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,
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
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 { 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"),
|
||||||
|
|||||||
@@ -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
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