feat: add meeting creation and dashboard with join links
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>
This commit is contained in:
1 parent
6facce3d22
commit
e26cfe624d
14 files changed
+715
-1
No files matched your search
@@ -0,0 +1,87 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MeetingStatus } from "@prisma/client"
|
||||
|
||||
export const MEETING_STATUS_BADGE: Record<
|
||||
MeetingStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
active: {
|
||||
label: "Activa",
|
||||
className: "bg-(--meet-status-active-bg) text-(--meet-status-active-fg)",
|
||||
},
|
||||
scheduled: {
|
||||
label: "Programada",
|
||||
className:
|
||||
"bg-(--meet-status-scheduled-bg) text-(--meet-status-scheduled-fg)",
|
||||
},
|
||||
ended: {
|
||||
label: "Finalizada",
|
||||
className: "bg-(--meet-status-ended-bg) text-(--meet-status-ended-fg)",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const scheduledMeetingSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.min(1, "El título es obligatorio")
|
||||
.max(200, "El título es demasiado largo"),
|
||||
scheduledAt: z
|
||||
.string()
|
||||
.min(1, "La fecha y hora son obligatorias")
|
||||
.refine((value) => !Number.isNaN(new Date(value).getTime()), {
|
||||
message: "Fecha y hora inválidas",
|
||||
})
|
||||
.refine((value) => new Date(value).getTime() > Date.now(), {
|
||||
message: "La fecha debe ser en el futuro",
|
||||
}),
|
||||
})
|
||||
|
||||
export type ScheduledMeetingInput = z.infer<typeof scheduledMeetingSchema>
|
||||
Reference in new issue
Block a user