Adds the management/CRUD layer for meetings: a signed JWT join-link
token service (jose), instant/scheduled meeting creation endpoints,
and a protected /dashboard page listing the host's active, upcoming,
and past meetings with a "Nueva reunión" action area (instant button +
scheduling dialog). Also fixes a Base UI console warning (missing
nativeButton={false}) on Button instances rendered as Link.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
88 lines
2.8 KiB
TypeScript
88 lines
2.8 KiB
TypeScript
import "server-only"
|
|
import { SignJWT, jwtVerify, errors as joseErrors } from "jose"
|
|
|
|
// Signed, time-limited join-link tokens: mirror the original Laravel
|
|
// version's approach of encoding meeting access in a token rather than
|
|
// relying on a guessable/incrementing URL. The token carries just enough
|
|
// info (the meeting's UUID) for the future /join/[meetingUuid] page to
|
|
// resolve the meeting and admit the bearer.
|
|
|
|
const secret = process.env.JOIN_LINK_SECRET
|
|
|
|
if (!secret) {
|
|
throw new Error(
|
|
"JOIN_LINK_SECRET environment variable is not set. Add it to .env (see .env.example)."
|
|
)
|
|
}
|
|
|
|
const encodedSecret = new TextEncoder().encode(secret)
|
|
|
|
// Grace period a join link stays valid for, counted either from issuance
|
|
// (instant meetings) or from the scheduled start time (scheduled
|
|
// meetings), whichever is further in the future.
|
|
const GRACE_PERIOD_MS = 6 * 60 * 60 * 1000
|
|
|
|
interface JoinLinkTokenPayload {
|
|
meetingUuid: string
|
|
}
|
|
|
|
/**
|
|
* Creates a signed JWT encoding access to a meeting by UUID.
|
|
*
|
|
* For instant meetings, pass no `scheduledAt` and the token expires 6 hours
|
|
* from now. For scheduled meetings, pass the meeting's `scheduledAt` so the
|
|
* token stays valid through the grace period that follows the scheduled
|
|
* start time, even if that's further out than 6 hours from now.
|
|
*/
|
|
export async function createJoinLinkToken(
|
|
meetingUuid: string,
|
|
options?: { scheduledAt?: Date | null }
|
|
): Promise<string> {
|
|
const now = Date.now()
|
|
let expiresAtMs = now + GRACE_PERIOD_MS
|
|
|
|
if (options?.scheduledAt) {
|
|
const scheduledExpiry = options.scheduledAt.getTime() + GRACE_PERIOD_MS
|
|
expiresAtMs = Math.max(expiresAtMs, scheduledExpiry)
|
|
}
|
|
|
|
return new SignJWT({ meetingUuid } satisfies JoinLinkTokenPayload)
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
.setIssuedAt()
|
|
.setExpirationTime(Math.floor(expiresAtMs / 1000))
|
|
.sign(encodedSecret)
|
|
}
|
|
|
|
/**
|
|
* Verifies a join-link token. Returns `null` on any invalid/expired/
|
|
* malformed token rather than throwing, so callers (e.g. the future
|
|
* /join/[meetingUuid] page) can handle it gracefully (show "link expired",
|
|
* fall back to requesting admission, etc.) instead of crashing.
|
|
*/
|
|
export async function verifyJoinLinkToken(
|
|
token: string
|
|
): Promise<{ meetingUuid: string } | null> {
|
|
try {
|
|
const { payload } = await jwtVerify(token, encodedSecret, {
|
|
algorithms: ["HS256"],
|
|
})
|
|
|
|
if (typeof payload.meetingUuid !== "string" || !payload.meetingUuid) {
|
|
return null
|
|
}
|
|
|
|
return { meetingUuid: payload.meetingUuid }
|
|
} catch (error) {
|
|
if (
|
|
error instanceof joseErrors.JWTExpired ||
|
|
error instanceof joseErrors.JWSSignatureVerificationFailed ||
|
|
error instanceof joseErrors.JWTInvalid
|
|
) {
|
|
return null
|
|
}
|
|
|
|
// Any other unexpected verification failure: still fail closed.
|
|
return null
|
|
}
|
|
}
|