Remove scheduler connections and meeting extras
This commit is contained in:
@@ -1,12 +0,0 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
|
||||||
|
|
||||||
import { requireSchedulerSession } from "@scheduler/lib/scheduler/auth";
|
|
||||||
import { getConnections } from "@scheduler/lib/scheduler/service";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const auth = await requireSchedulerSession(request);
|
|
||||||
if ("response" in auth) return auth.response;
|
|
||||||
return NextResponse.json(await getConnections(auth.userId));
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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("/login?callbackUrl=/connections");
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -725,8 +725,7 @@ input[type="number"]::-webkit-inner-spin-button { filter: invert(0.5); }
|
|||||||
AVAILABILITY + SETTINGS
|
AVAILABILITY + SETTINGS
|
||||||
================================================================ */
|
================================================================ */
|
||||||
|
|
||||||
.availability-layout,
|
.availability-layout {
|
||||||
.connections-layout {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
@@ -886,54 +885,6 @@ input[type="number"]::-webkit-inner-spin-button { filter: invert(0.5); }
|
|||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================
|
|
||||||
CONNECTIONS
|
|
||||||
================================================================ */
|
|
||||||
|
|
||||||
.provider-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 0;
|
|
||||||
border-top: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.provider-name {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.provider-status.connected {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
background: var(--accent-bg);
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
border: 1px solid var(--accent-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.browse-button {
|
|
||||||
display: inline-flex;
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.privacy-note {
|
|
||||||
font-size: 10.5px;
|
|
||||||
color: var(--text-3);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
padding: 14px 0 2px;
|
|
||||||
border-top: 1px solid var(--border-subtle);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
RESPONSIVE
|
RESPONSIVE
|
||||||
================================================================ */
|
================================================================ */
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
type ActiveNavItem = "calendar" | "availability" | "connections";
|
type ActiveNavItem = "calendar" | "availability";
|
||||||
type NavItem = { id: ActiveNavItem; href: string; label: string };
|
type NavItem = { id: ActiveNavItem; href: string; label: string };
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ id: "calendar", href: "/calendar", label: "Calendar" },
|
{ id: "calendar", href: "/calendar", label: "Calendar" },
|
||||||
{ id: "availability", href: "/availability", label: "Availability" },
|
{ id: "availability", href: "/availability", label: "Availability" },
|
||||||
{ id: "connections", href: "/connections", label: "Connections" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppShell({
|
export function AppShell({
|
||||||
|
|||||||
@@ -92,8 +92,6 @@ export function CalendarWorkspace({
|
|||||||
const [slots, setSlots] = useState<MutualSlot[]>(initialSlots);
|
const [slots, setSlots] = useState<MutualSlot[]>(initialSlots);
|
||||||
const [durationMinutes, setDurationMinutes] = useState<number>(30);
|
const [durationMinutes, setDurationMinutes] = useState<number>(30);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [location, setLocation] = useState("");
|
|
||||||
const [conferencing, setConferencing] = useState(false);
|
|
||||||
const [selectedSlotStart, setSelectedSlotStart] = useState<string | null>(null);
|
const [selectedSlotStart, setSelectedSlotStart] = useState<string | null>(null);
|
||||||
const [createState, setCreateState] = useState<CreateState>({ kind: "idle" });
|
const [createState, setCreateState] = useState<CreateState>({ kind: "idle" });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -167,8 +165,6 @@ export function CalendarWorkspace({
|
|||||||
attendeeIds: selectedAttendeeIds,
|
attendeeIds: selectedAttendeeIds,
|
||||||
start: selectedSlotStart,
|
start: selectedSlotStart,
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
location: location.trim() || undefined,
|
|
||||||
conferencing: conferencing ? "provider" : "none",
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,7 +184,7 @@ export function CalendarWorkspace({
|
|||||||
} catch {
|
} catch {
|
||||||
setCreateState({ kind: "error", message: "Could not create the meeting." });
|
setCreateState({ kind: "error", message: "Could not create the meeting." });
|
||||||
}
|
}
|
||||||
}, [selectedSlotStart, title, selectedAttendeeIds, durationMinutes, location, conferencing]);
|
}, [selectedSlotStart, title, selectedAttendeeIds, durationMinutes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="calendar-layout">
|
<div className="calendar-layout">
|
||||||
@@ -304,16 +300,6 @@ export function CalendarWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</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">
|
<div className="selected-slot">
|
||||||
{selectedSlotStart ? (
|
{selectedSlotStart ? (
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import type { SchedulerConnection } from "@scheduler/lib/scheduler/service";
|
|
||||||
|
|
||||||
type ConnectionsViewProps = {
|
|
||||||
connections: SchedulerConnection[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONFERENCING_CATEGORIES = new Set(["conferencing", "video"]);
|
|
||||||
const CAL_SETTINGS_URL = process.env.CAL_SETTINGS_URL ?? "https://scheduler.internal.vyntehome.com";
|
|
||||||
|
|
||||||
function settingsHref(path: string) {
|
|
||||||
return `${CAL_SETTINGS_URL.replace(/\/$/, "")}${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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={settingsHref("/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={settingsHref("/apps/installed/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={settingsHref("/apps/installed/conferencing")}>
|
|
||||||
Browse conferencing providers
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p className="privacy-note">Only availability blocks are visible to teammates.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,8 @@ describe("safeSchedulerCallback", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes redirects for already-authenticated users", () => {
|
it("normalizes redirects for already-authenticated users", () => {
|
||||||
expect(safeSchedulerCallback("/connections")).toBe("/connections");
|
expect(safeSchedulerCallback("/connections")).toBe("/calendar");
|
||||||
|
expect(safeSchedulerCallback("/availability")).toBe("/availability");
|
||||||
expect(safeSchedulerCallback("https://example.com")).toBe("/calendar");
|
expect(safeSchedulerCallback("https://example.com")).toBe("/calendar");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
const DEFAULT_CALLBACK = "/calendar";
|
const DEFAULT_CALLBACK = "/calendar";
|
||||||
|
const REMOVED_CALLBACKS = new Set(["/connections"]);
|
||||||
|
|
||||||
export function safeSchedulerCallback(value: string | string[] | undefined): string {
|
export function safeSchedulerCallback(value: string | string[] | undefined): string {
|
||||||
const candidate = Array.isArray(value) ? value[0] : value;
|
const candidate = Array.isArray(value) ? value[0] : value;
|
||||||
if (!candidate || !candidate.startsWith("/") || candidate.startsWith("//")) {
|
if (!candidate || !candidate.startsWith("/") || candidate.startsWith("//") || REMOVED_CALLBACKS.has(candidate)) {
|
||||||
return DEFAULT_CALLBACK;
|
return DEFAULT_CALLBACK;
|
||||||
}
|
}
|
||||||
return candidate;
|
return candidate;
|
||||||
|
|||||||
@@ -71,22 +71,6 @@ export const demoBusyBlocks: BusyBlock[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type DemoConnection = {
|
|
||||||
type: string;
|
|
||||||
appId: string;
|
|
||||||
category: string;
|
|
||||||
connected: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const demoConnectionsByUser: Record<number, DemoConnection[]> = {
|
|
||||||
1: [
|
|
||||||
{ type: "google_calendar", appId: "google-calendar", category: "calendar", connected: true },
|
|
||||||
{ type: "google_video", appId: "google-meet", category: "conferencing", connected: true },
|
|
||||||
],
|
|
||||||
2: [{ type: "office365_calendar", appId: "office365-calendar", category: "calendar", connected: true }],
|
|
||||||
3: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DemoMeeting = {
|
export type DemoMeeting = {
|
||||||
id: number;
|
id: number;
|
||||||
uid: string;
|
uid: string;
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import { computeMutualSlots } from "./availability";
|
|||||||
import { collapseDateOverrides, expandDateOverride } from "./overrides";
|
import { collapseDateOverrides, expandDateOverride } from "./overrides";
|
||||||
import {
|
import {
|
||||||
demoBusyBlocks,
|
demoBusyBlocks,
|
||||||
demoConnectionsByUser,
|
|
||||||
demoMeetings,
|
demoMeetings,
|
||||||
demoSchedules,
|
demoSchedules,
|
||||||
demoUsers,
|
demoUsers,
|
||||||
type DemoConnection,
|
|
||||||
} from "./demo-data";
|
} from "./demo-data";
|
||||||
import { isDemoMode } from "./demo-mode";
|
import { isDemoMode } from "./demo-mode";
|
||||||
import { filterBusyBlocksForViewer } from "./privacy";
|
import { filterBusyBlocksForViewer } from "./privacy";
|
||||||
@@ -304,54 +302,6 @@ export async function updateSchedule(userId: number, body: unknown): Promise<Sch
|
|||||||
return loadSchedule(userId);
|
return loadSchedule(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Connections (no secrets exposed)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type SchedulerConnection = {
|
|
||||||
type: string;
|
|
||||||
appId: string | null;
|
|
||||||
category: string;
|
|
||||||
connected: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getConnections(
|
|
||||||
userId: number
|
|
||||||
): Promise<{ connections: SchedulerConnection[] }> {
|
|
||||||
if (isDemoMode()) {
|
|
||||||
const demo: DemoConnection[] = demoConnectionsByUser[userId] ?? [];
|
|
||||||
return {
|
|
||||||
connections: demo.map((connection) => ({
|
|
||||||
type: connection.type,
|
|
||||||
appId: connection.appId,
|
|
||||||
category: connection.category,
|
|
||||||
connected: connection.connected,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only non-secret metadata is selected: `key`/`encryptedKey` are never read.
|
|
||||||
const credentials = await prisma.credential.findMany({
|
|
||||||
where: { userId },
|
|
||||||
select: {
|
|
||||||
type: true,
|
|
||||||
appId: true,
|
|
||||||
invalid: true,
|
|
||||||
app: { select: { categories: true } },
|
|
||||||
},
|
|
||||||
orderBy: { id: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const connections: SchedulerConnection[] = credentials.map((credential) => ({
|
|
||||||
type: credential.type,
|
|
||||||
appId: credential.appId,
|
|
||||||
category: credential.app?.categories?.[0] ?? "other",
|
|
||||||
connected: credential.invalid !== true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { connections };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Availability
|
// Availability
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -497,8 +447,6 @@ const createMeetingSchema = z.object({
|
|||||||
attendeeIds: z.array(z.number().int().positive()).min(1),
|
attendeeIds: z.array(z.number().int().positive()).min(1),
|
||||||
start: z.string().datetime(),
|
start: z.string().datetime(),
|
||||||
durationMinutes: z.number().int().positive(),
|
durationMinutes: z.number().int().positive(),
|
||||||
location: z.string().optional(),
|
|
||||||
conferencing: z.enum(["none", "provider"]).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateMeetingResult =
|
export type CreateMeetingResult =
|
||||||
|
|||||||
@@ -52,6 +52,4 @@ export type MeetingDraft = {
|
|||||||
attendeeIds: number[];
|
attendeeIds: number[];
|
||||||
start: string;
|
start: string;
|
||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
location?: string;
|
|
||||||
conferencing?: "none" | "provider";
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user