feat: build Vynte scheduler screens

Add Calendar (week grid + meeting composer), Availability (weekly hours +
date overrides), and Connections screens, plus server-side viewer resolution
for Server Components. Teammate busy blocks never reveal titles, demo-mode
auth bypass is refused in production, and the visible week range is shared
from the server so the first client fetch matches server-rendered data.
This commit is contained in:
Zachariah K. Sharma
2026-06-14 13:00:40 -06:00
parent b3fcfd0bf1
commit 82c6850cbf
8 changed files with 759 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
import { redirect } from "next/navigation";
import { AppShell } from "@scheduler/components/AppShell";
import { AvailabilityEditor } from "@scheduler/components/AvailabilityEditor";
import { getServerViewerId } from "@scheduler/lib/scheduler/auth";
import { getSchedule, getTeamContext } from "@scheduler/lib/scheduler/service";
export const dynamic = "force-dynamic";
export default async function AvailabilityPage() {
const viewerId = await getServerViewerId();
if (viewerId === null) {
redirect("/auth/login");
}
const [context, schedule] = await Promise.all([getTeamContext(viewerId), getSchedule(viewerId)]);
return (
<AppShell active="availability" user={{ name: context.viewer.name, timeZone: context.viewer.timeZone }}>
<AvailabilityEditor schedule={schedule} />
</AppShell>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { redirect } from "next/navigation";
import { AppShell } from "@scheduler/components/AppShell";
import { CalendarWorkspace } from "@scheduler/components/CalendarWorkspace";
import { getServerViewerId } from "@scheduler/lib/scheduler/auth";
import { getAvailability, getTeamContext } from "@scheduler/lib/scheduler/service";
export const dynamic = "force-dynamic";
const DEFAULT_DURATION_MINUTES = 30;
const VISIBLE_DAYS = 5;
const DAY_MS = 24 * 60 * 60 * 1000;
/** Monday 00:00 (server local) of the current week, and Monday + visible days. */
function currentWeekRange(): { rangeStart: string; rangeEnd: string } {
const start = new Date();
start.setHours(0, 0, 0, 0);
const weekday = start.getDay();
const mondayOffset = weekday === 0 ? -6 : 1 - weekday;
start.setDate(start.getDate() + mondayOffset);
return {
rangeStart: start.toISOString(),
rangeEnd: new Date(start.getTime() + VISIBLE_DAYS * DAY_MS).toISOString(),
};
}
export default async function CalendarPage() {
const viewerId = await getServerViewerId();
if (viewerId === null) {
redirect("/auth/login");
}
const { rangeStart, rangeEnd } = currentWeekRange();
const [context, availability] = await Promise.all([
getTeamContext(viewerId),
getAvailability(viewerId, [viewerId], rangeStart, rangeEnd, DEFAULT_DURATION_MINUTES),
]);
return (
<AppShell active="calendar" user={{ name: context.viewer.name, timeZone: context.viewer.timeZone }}>
<CalendarWorkspace
viewerId={context.viewer.id}
team={context.team}
initialBusy={availability.busy}
initialSlots={availability.mutualSlots}
weekStartIso={rangeStart}
/>
</AppShell>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { redirect } from "next/navigation";
import { AppShell } from "@scheduler/components/AppShell";
import { ConnectionsView } from "@scheduler/components/ConnectionsView";
import { getServerViewerId } from "@scheduler/lib/scheduler/auth";
import { getConnections, getTeamContext } from "@scheduler/lib/scheduler/service";
export const dynamic = "force-dynamic";
export default async function ConnectionsPage() {
const viewerId = await getServerViewerId();
if (viewerId === null) {
redirect("/auth/login");
}
const [context, { connections }] = await Promise.all([getTeamContext(viewerId), getConnections(viewerId)]);
return (
<AppShell active="connections" user={{ name: context.viewer.name, timeZone: context.viewer.timeZone }}>
<ConnectionsView connections={connections} />
</AppShell>
);
}
+64
View File
@@ -13,3 +13,67 @@ a { color: inherit; text-decoration: none; }
.profile-block span { color: #777c83; }
.main-surface { padding: 20px; min-width: 0; }
@media (max-width: 860px) { .app-frame { grid-template-columns: 1fr; } .sidebar { min-height: auto; border-right: 0; border-bottom: 1px solid #e3e5e8; } }
/* Shared primitives */
.panel-header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.panel-header h1 { font-size: 20px; font-weight: 700; margin: 0; }
.panel-header h2, .panel-header h1 + * { margin: 0; }
.muted { color: #777c83; font-size: 13px; }
.primary-button { background: #246b48; color: #fff; border: 1px solid #1f5c3e; border-radius: 6px; padding: 9px 16px; font-size: 13px; font-weight: 600; cursor: pointer; }
.primary-button:disabled { opacity: 0.6; cursor: default; }
.secondary-button { background: #fff; color: #333; border: 1px solid #d7dadf; border-radius: 6px; padding: 7px 12px; font-size: 13px; font-weight: 600; cursor: pointer; }
.secondary-button[data-active="true"] { border-color: #246b48; color: #204d39; background: #e9efec; }
.notice { font-size: 13px; margin: 8px 0 0; }
.notice-error { color: #a23434; }
.notice-warn { color: #8a6320; }
.notice-ok { color: #246b48; }
.field { display: grid; gap: 4px; margin: 12px 0; font-size: 13px; }
.field input { border: 1px solid #d7dadf; border-radius: 6px; padding: 8px 10px; }
.field-inline { display: flex; align-items: center; gap: 8px; font-size: 13px; margin: 12px 0; }
/* Calendar */
.calendar-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 18px; align-items: start; }
.calendar-panel { background: #fff; border: 1px solid #e3e5e8; border-radius: 8px; padding: 16px; }
.week-grid { display: grid; grid-template-columns: 56px repeat(5, minmax(0, 1fr)); gap: 0; }
.time-gutter { padding-top: 26px; }
.hour-label { font-size: 11px; color: #9aa0a6; border-top: 1px solid #f0f1f3; padding: 2px 4px; }
.day-column { border-left: 1px solid #f0f1f3; min-width: 0; }
.day-heading { font-size: 12px; font-weight: 600; text-align: center; padding: 6px 0; color: #555a61; }
.day-body { position: relative; background: #fff; }
.busy-block { position: absolute; left: 3px; right: 3px; background: #e7e9ec; border: 1px solid #d3d6db; border-radius: 4px; font-size: 11px; padding: 2px 4px; overflow: hidden; color: #555a61; }
.busy-block[data-own="true"] { background: #dce6e0; border-color: #b9cdc2; color: #2d5a42; }
.mutual-slot { position: absolute; left: 3px; right: 3px; background: rgba(36, 107, 72, 0.12); border: 1px solid #246b48; border-radius: 4px; font-size: 11px; color: #204d39; cursor: pointer; padding: 2px 4px; }
.mutual-slot[data-selected="true"] { background: #246b48; color: #fff; }
/* Composer */
.composer { background: #fff; border: 1px solid #e3e5e8; border-radius: 8px; padding: 16px; position: sticky; top: 20px; }
.composer h2 { font-size: 16px; margin: 0 0 12px; }
.composer-group { border: 1px solid #eceef0; border-radius: 6px; padding: 10px 12px; margin: 0 0 12px; }
.composer-group legend { font-size: 12px; font-weight: 600; color: #555a61; padding: 0 4px; }
.attendee-row { display: flex; align-items: center; gap: 8px; font-size: 13px; padding: 3px 0; }
.duration-presets { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.duration-custom { width: 64px; border: 1px solid #d7dadf; border-radius: 6px; padding: 7px 8px; }
.selected-slot { font-size: 13px; margin: 8px 0 12px; }
/* Availability + settings cards */
.availability-layout, .connections-layout { display: grid; gap: 16px; max-width: 640px; }
.settings-card { background: #fff; border: 1px solid #e3e5e8; border-radius: 8px; padding: 16px; }
.settings-card h2 { font-size: 16px; margin: 0 0 12px; }
.weekly-rows { display: grid; gap: 6px; }
.weekly-row { display: flex; align-items: center; gap: 16px; padding: 6px 0; border-top: 1px solid #f0f1f3; }
.weekly-row[data-enabled="false"] { opacity: 0.7; }
.weekly-toggle { min-width: 130px; }
.time-range { display: flex; align-items: center; gap: 8px; }
.time-range input { border: 1px solid #d7dadf; border-radius: 6px; padding: 6px 8px; }
.dash { color: #9aa0a6; }
.unavailable-label { font-style: italic; }
.override-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.override-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0; border-top: 1px solid #f0f1f3; font-size: 13px; }
.save-bar { display: flex; align-items: center; gap: 12px; }
/* Connections */
.provider-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid #f0f1f3; }
.provider-name { font-size: 13px; font-weight: 600; }
.provider-status.connected { font-size: 12px; color: #246b48; font-weight: 600; }
.browse-button { display: inline-block; margin-top: 12px; }
.privacy-note { font-size: 12px; color: #777c83; }
@@ -0,0 +1,151 @@
"use client";
import { useMemo, useState } from "react";
import type { DateOverride, SchedulerSchedule, WeeklyRange } from "@scheduler/lib/scheduler/types";
type AvailabilityEditorProps = {
schedule: SchedulerSchedule;
};
const DAY_LABELS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] as const;
const DEFAULT_START = "09:00";
const DEFAULT_END = "17:00";
type SaveState =
| { kind: "idle" }
| { kind: "saving" }
| { kind: "saved" }
| { kind: "error"; message: string };
/** Builds one editable row per weekday, seeded from the schedule's first range for that day. */
function buildRows(weekly: WeeklyRange[]): WeeklyRange[] {
return DAY_LABELS.map((_, day) => {
const existing = weekly.find((range) => range.day === day && range.enabled);
return {
day,
enabled: Boolean(existing),
startTime: existing?.startTime ?? DEFAULT_START,
endTime: existing?.endTime ?? DEFAULT_END,
};
});
}
export function AvailabilityEditor({ schedule }: AvailabilityEditorProps) {
const [rows, setRows] = useState<WeeklyRange[]>(() => buildRows(schedule.weekly));
const [overrides, setOverrides] = useState<DateOverride[]>(schedule.overrides);
const [saveState, setSaveState] = useState<SaveState>({ kind: "idle" });
const timeZone = schedule.timeZone;
const enabledCount = useMemo(() => rows.filter((row) => row.enabled).length, [rows]);
const updateRow = (day: number, patch: Partial<WeeklyRange>) => {
setRows((current) => current.map((row) => (row.day === day ? { ...row, ...patch } : row)));
};
const removeOverride = (date: string) => {
setOverrides((current) => current.filter((override) => override.date !== date));
};
const save = async () => {
setSaveState({ kind: "saving" });
try {
const response = await fetch("/api/scheduler/schedule", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
timeZone,
weekly: rows.filter((row) => row.enabled),
overrides,
}),
});
if (!response.ok) throw new Error("save failed");
const updated = (await response.json()) as SchedulerSchedule;
setRows(buildRows(updated.weekly));
setOverrides(updated.overrides);
setSaveState({ kind: "saved" });
} catch {
setSaveState({ kind: "error", message: "Could not save your schedule." });
}
};
return (
<div className="availability-layout">
<header className="panel-header">
<h1>Availability</h1>
<span className="muted">Timezone: {timeZone}</span>
</header>
<section className="settings-card">
<h2>Weekly hours</h2>
<p className="muted">
{enabledCount} day{enabledCount === 1 ? "" : "s"} available
</p>
<div className="weekly-rows">
{rows.map((row) => (
<div key={row.day} className="weekly-row" data-enabled={row.enabled}>
<label className="field-inline weekly-toggle">
<input
type="checkbox"
checked={row.enabled}
onChange={(event) => updateRow(row.day, { enabled: event.target.checked })}
/>
<span>{DAY_LABELS[row.day]}</span>
</label>
{row.enabled ? (
<div className="time-range">
<input
type="time"
value={row.startTime}
onChange={(event) => updateRow(row.day, { startTime: event.target.value })}
aria-label={`${DAY_LABELS[row.day]} start`}
/>
<span className="dash"></span>
<input
type="time"
value={row.endTime}
onChange={(event) => updateRow(row.day, { endTime: event.target.value })}
aria-label={`${DAY_LABELS[row.day]} end`}
/>
</div>
) : (
<span className="muted unavailable-label">Unavailable</span>
)}
</div>
))}
</div>
</section>
<section className="settings-card">
<h2>Date overrides</h2>
{overrides.length === 0 ? (
<p className="muted">No date overrides.</p>
) : (
<ul className="override-list">
{overrides.map((override) => (
<li key={override.date} className="override-row">
<span>{override.date}</span>
<span className="muted">
{override.unavailable
? "Unavailable all day"
: `${override.startTime ?? DEFAULT_START}${override.endTime ?? DEFAULT_END}`}
</span>
<button type="button" className="secondary-button" onClick={() => removeOverride(override.date)}>
Remove
</button>
</li>
))}
</ul>
)}
</section>
<div className="save-bar">
<button type="button" className="primary-button" onClick={save} disabled={saveState.kind === "saving"}>
{saveState.kind === "saving" ? "Saving…" : "Save schedule"}
</button>
{saveState.kind === "saved" && <span className="notice notice-ok">Saved.</span>}
{saveState.kind === "error" && <span className="notice notice-error">{saveState.message}</span>}
</div>
</div>
);
}
@@ -0,0 +1,344 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { MutualSlot, PublicBusyBlock, SchedulerUser } from "@scheduler/lib/scheduler/types";
type CalendarWorkspaceProps = {
viewerId: number;
team: SchedulerUser[];
initialBusy: PublicBusyBlock[];
initialSlots: MutualSlot[];
/** Server-computed Monday 00:00 ISO of the visible week — shared so the first
* client fetch range matches the data rendered on the server. */
weekStartIso: string;
};
const DURATION_PRESETS = [15, 30, 45, 60] as const;
const DAY_START_HOUR = 7;
const DAY_END_HOUR = 19;
const PX_PER_HOUR = 48;
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"] as const;
const REFETCH_DEBOUNCE_MS = 300;
type CreateState =
| { kind: "idle" }
| { kind: "saving" }
| { kind: "error"; message: string }
| { kind: "unavailable"; message: string }
| { kind: "created" };
/** Monday 00:00 (local) of the week containing `from`. */
function startOfWeek(from: Date): Date {
const date = new Date(from);
date.setHours(0, 0, 0, 0);
const weekday = date.getDay(); // 0 = Sun
const mondayOffset = weekday === 0 ? -6 : 1 - weekday;
date.setDate(date.getDate() + mondayOffset);
return date;
}
function addDays(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function minutesIntoDay(iso: string): number {
const date = new Date(iso);
return date.getHours() * 60 + date.getMinutes();
}
function sameDay(iso: string, day: Date): boolean {
const date = new Date(iso);
return (
date.getFullYear() === day.getFullYear() &&
date.getMonth() === day.getMonth() &&
date.getDate() === day.getDate()
);
}
function offsetTop(iso: string): number {
const minutes = minutesIntoDay(iso) - DAY_START_HOUR * 60;
return (minutes / 60) * PX_PER_HOUR;
}
function blockHeight(startIso: string, endIso: string): number {
const minutes = (new Date(endIso).getTime() - new Date(startIso).getTime()) / 60_000;
return Math.max((minutes / 60) * PX_PER_HOUR, 14);
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
export function CalendarWorkspace({
viewerId,
team,
initialBusy,
initialSlots,
weekStartIso,
}: CalendarWorkspaceProps) {
const weekStart = useMemo(() => startOfWeek(new Date(weekStartIso)), [weekStartIso]);
const days = useMemo(() => WEEKDAYS.map((_, index) => addDays(weekStart, index)), [weekStart]);
const hours = useMemo(() => {
const list: number[] = [];
for (let hour = DAY_START_HOUR; hour < DAY_END_HOUR; hour += 1) list.push(hour);
return list;
}, []);
const [selectedAttendeeIds, setSelectedAttendeeIds] = useState<number[]>([viewerId]);
const [busy, setBusy] = useState<PublicBusyBlock[]>(initialBusy);
const [slots, setSlots] = useState<MutualSlot[]>(initialSlots);
const [durationMinutes, setDurationMinutes] = useState<number>(30);
const [title, setTitle] = useState("");
const [location, setLocation] = useState("");
const [conferencing, setConferencing] = useState(false);
const [selectedSlotStart, setSelectedSlotStart] = useState<string | null>(null);
const [createState, setCreateState] = useState<CreateState>({ kind: "idle" });
const [loading, setLoading] = useState(false);
const isFirstRender = useRef(true);
const rangeStart = days[0].toISOString();
const rangeEnd = addDays(weekStart, WEEKDAYS.length).toISOString();
const toggleAttendee = useCallback(
(id: number) => {
if (id === viewerId) return; // organizer always attends
setSelectedAttendeeIds((current) =>
current.includes(id) ? current.filter((value) => value !== id) : [...current, id]
);
},
[viewerId]
);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
attendeeIds: selectedAttendeeIds.join(","),
rangeStart,
rangeEnd,
durationMinutes: String(durationMinutes),
});
const response = await fetch(`/api/scheduler/availability?${params.toString()}`, {
signal: controller.signal,
});
if (!response.ok) throw new Error("availability request failed");
const data = (await response.json()) as { busy: PublicBusyBlock[]; mutualSlots: MutualSlot[] };
setBusy(data.busy);
setSlots(data.mutualSlots);
setSelectedSlotStart(null);
} catch (error) {
if (!controller.signal.aborted) {
setCreateState({ kind: "error", message: "Could not refresh availability." });
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}, REFETCH_DEBOUNCE_MS);
return () => {
controller.abort();
clearTimeout(timer);
};
}, [selectedAttendeeIds, durationMinutes, rangeStart, rangeEnd]);
const createMeeting = useCallback(async () => {
if (!selectedSlotStart || !title.trim()) {
setCreateState({ kind: "error", message: "Pick a slot and enter a title first." });
return;
}
setCreateState({ kind: "saving" });
try {
const response = await fetch("/api/scheduler/meetings", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
title: title.trim(),
attendeeIds: selectedAttendeeIds,
start: selectedSlotStart,
durationMinutes,
location: location.trim() || undefined,
conferencing: conferencing ? "provider" : "none",
}),
});
if (response.status === 501) {
setCreateState({
kind: "unavailable",
message: "Meeting booking is not wired up yet (CAL_BOOKING_SERVICE_BOUNDARY).",
});
return;
}
if (response.status === 409) {
setCreateState({ kind: "unavailable", message: "That slot is no longer available." });
return;
}
if (!response.ok) throw new Error("create failed");
setCreateState({ kind: "created" });
} catch {
setCreateState({ kind: "error", message: "Could not create the meeting." });
}
}, [selectedSlotStart, title, selectedAttendeeIds, durationMinutes, location, conferencing]);
return (
<div className="calendar-layout">
<section className="calendar-panel" aria-label="Team week">
<header className="panel-header">
<h1>Calendar</h1>
<span className="muted">{loading ? "Updating availability…" : "Mutual free slots outlined"}</span>
</header>
<div className="week-grid">
<div className="time-gutter">
{hours.map((hour) => (
<div key={hour} className="hour-label" style={{ height: PX_PER_HOUR }}>
{hour}:00
</div>
))}
</div>
{days.map((day, index) => (
<div key={day.toISOString()} className="day-column">
<div className="day-heading">
{WEEKDAYS[index]} {day.getMonth() + 1}/{day.getDate()}
</div>
<div className="day-body" style={{ height: (DAY_END_HOUR - DAY_START_HOUR) * PX_PER_HOUR }}>
{busy
.filter((block) => sameDay(block.start, day))
.map((block) => {
// Teammate blocks never reveal a title, even if one leaks
// past the server-side privacy filter.
const label = block.userId === viewerId ? block.title ?? "Busy" : "Busy";
return (
<div
key={block.id}
className="busy-block"
role="img"
aria-label={`${WEEKDAYS[index]} ${formatTime(block.start)} ${label}`}
data-own={block.userId === viewerId}
style={{ top: offsetTop(block.start), height: blockHeight(block.start, block.end) }}
title={label}>
{label}
</div>
);
})}
{slots
.filter((slot) => sameDay(slot.start, day))
.map((slot) => (
<button
key={slot.start}
type="button"
className="mutual-slot"
data-selected={slot.start === selectedSlotStart}
aria-label={`Schedule ${WEEKDAYS[index]} at ${formatTime(slot.start)}`}
aria-pressed={slot.start === selectedSlotStart}
style={{ top: offsetTop(slot.start), height: blockHeight(slot.start, slot.end) }}
onClick={() => setSelectedSlotStart(slot.start)}>
{formatTime(slot.start)}
</button>
))}
</div>
</div>
))}
</div>
</section>
<aside className="composer" aria-label="Meeting composer">
<h2>Schedule meeting</h2>
<fieldset className="composer-group">
<legend>Attendees</legend>
{team.map((member) => (
<label key={member.id} className="attendee-row">
<input
type="checkbox"
checked={selectedAttendeeIds.includes(member.id)}
disabled={member.id === viewerId}
onChange={() => toggleAttendee(member.id)}
/>
<span>
{member.name}
{member.id === viewerId ? " (you)" : ""}
</span>
</label>
))}
</fieldset>
<label className="field">
<span>Title</span>
<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Sync" />
</label>
<fieldset className="composer-group">
<legend>Duration</legend>
<div className="duration-presets">
{DURATION_PRESETS.map((preset) => (
<button
key={preset}
type="button"
className="secondary-button"
data-active={durationMinutes === preset}
onClick={() => setDurationMinutes(preset)}>
{preset}m
</button>
))}
<input
type="number"
min={5}
step={5}
className="duration-custom"
value={durationMinutes}
onChange={(event) => setDurationMinutes(Math.max(5, Number(event.target.value) || 5))}
aria-label="Custom duration in minutes"
/>
</div>
</fieldset>
<label className="field">
<span>Location (optional)</span>
<input value={location} onChange={(event) => setLocation(event.target.value)} placeholder="Room or address" />
</label>
<label className="field-inline">
<input type="checkbox" checked={conferencing} onChange={(event) => setConferencing(event.target.checked)} />
<span>Add conferencing link (optional)</span>
</label>
<div className="selected-slot">
{selectedSlotStart ? (
<span>
Selected:{" "}
{new Date(selectedSlotStart).toLocaleString([], {
weekday: "short",
hour: "numeric",
minute: "2-digit",
})}
</span>
) : (
<span className="muted">Pick a mutual slot on the calendar.</span>
)}
</div>
<button
type="button"
className="primary-button"
onClick={createMeeting}
disabled={createState.kind === "saving"}>
{createState.kind === "saving" ? "Creating…" : "Create meeting"}
</button>
{createState.kind === "error" && <p className="notice notice-error">{createState.message}</p>}
{createState.kind === "unavailable" && <p className="notice notice-warn">{createState.message}</p>}
{createState.kind === "created" && <p className="notice notice-ok">Meeting created.</p>}
</aside>
</div>
);
}
@@ -0,0 +1,70 @@
import type { SchedulerConnection } from "@scheduler/lib/scheduler/service";
type ConnectionsViewProps = {
connections: SchedulerConnection[];
};
const CONFERENCING_CATEGORIES = new Set(["conferencing", "video"]);
function providerLabel(connection: SchedulerConnection): string {
return connection.appId ?? connection.type;
}
function ProviderRow({ connection }: { connection: SchedulerConnection }) {
return (
<div className="provider-row">
<span className="provider-name">{providerLabel(connection)}</span>
{connection.connected ? (
<span className="provider-status connected">Connected</span>
) : (
<a className="secondary-button" href="/apps/installed">
Connect
</a>
)}
</div>
);
}
export function ConnectionsView({ connections }: ConnectionsViewProps) {
const calendars = connections.filter((connection) => connection.category === "calendar");
const conferencing = connections.filter((connection) => CONFERENCING_CATEGORIES.has(connection.category));
return (
<div className="connections-layout">
<header className="panel-header">
<h1>Connections</h1>
<span className="muted">Reuses every provider supported by Cal.</span>
</header>
<section className="settings-card">
<h2>Calendars</h2>
{calendars.length === 0 ? (
<p className="muted">No calendars connected yet.</p>
) : (
calendars.map((connection) => (
<ProviderRow key={`${connection.type}-${connection.appId}`} connection={connection} />
))
)}
<a className="primary-button browse-button" href="/apps/categories/calendar">
Browse calendar providers
</a>
</section>
<section className="settings-card">
<h2>Conferencing</h2>
{conferencing.length === 0 ? (
<p className="muted">No conferencing providers connected yet.</p>
) : (
conferencing.map((connection) => (
<ProviderRow key={`${connection.type}-${connection.appId}`} connection={connection} />
))
)}
<a className="primary-button browse-button" href="/apps/categories/conferencing">
Browse conferencing providers
</a>
</section>
<p className="privacy-note">Only availability blocks are visible to teammates.</p>
</div>
);
}
+34
View File
@@ -1,13 +1,47 @@
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { cookies, headers } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { buildLegacyRequest } from "./legacy-request";
/** Viewer id used for the demo team's organizer when SCHEDULER_DEMO_MODE=1. */
const DEMO_VIEWER_ID = 1;
/**
* Demo mode bypasses Authentik and impersonates the organizer, so it must never
* be honored in production even if the env var is accidentally left set.
*/
function isDemoMode(): boolean {
return process.env.SCHEDULER_DEMO_MODE === "1" && process.env.NODE_ENV !== "production";
}
export async function getSchedulerSession(request: NextRequest) {
return getServerSession({ req: buildLegacyRequest(request) });
}
/**
* Resolves the current viewer id inside a Server Component (which has no
* `NextRequest`). Returns the demo organizer in demo mode, otherwise reads the
* Authentik session from request cookies/headers. Returns null when there is no
* valid session so the caller can redirect to login.
*/
export async function getServerViewerId(): Promise<number | null> {
if (isDemoMode()) {
return DEMO_VIEWER_ID;
}
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
const req = {
cookies: Object.fromEntries(cookieStore.getAll().map((cookie) => [cookie.name, cookie.value])),
headers: Object.fromEntries(headerStore.entries()),
} as unknown as Parameters<typeof getServerSession>[0]["req"];
const session = await getServerSession({ req });
const viewerId = Number(session?.user?.id);
return Number.isInteger(viewerId) && viewerId > 0 ? viewerId : null;
}
/**
* Returns either `{ session, userId }` for an authenticated viewer, or `{ response }`
* carrying a 401 the route handler should return directly. The `userId` is the