17 Commits

Author SHA1 Message Date
Zachariah K. Sharma e6fc365278 Remove scheduler connections and meeting extras
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
2026-06-15 15:10:39 -06:00
Zachariah K. Sharma 782a578492 Make Authentik login navigation reliable
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
2026-06-15 11:53:34 -06:00
Zachariah K. Sharma e283a9dfa1 Align scheduler secure auth cookies
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
2026-06-15 11:48:33 -06:00
Zachariah K. Sharma 26a18e0275 Clear scheduler session cookie variants
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
2026-06-15 11:30:43 -06:00
Zachariah K. Sharma 635935c3ba Use server logout link in scheduler
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
2026-06-15 11:12:11 -06:00
Zachariah K. Sharma 63dc5431d8 Trim scheduler logout cookie clearing
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
2026-06-15 11:07:06 -06:00
Zachariah K. Sharma a635be2c71 Clear scheduler auth cookies on logout
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
2026-06-15 11:02:03 -06:00
Zachariah K. Sharma 240e0a7309 Make scheduler logout clear session
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
2026-06-15 10:35:39 -06:00
Zachariah K. Sharma 0cba09ddb4 Fix scheduler auth redirects
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
2026-06-15 10:22:41 -06:00
Zachariah K. Sharma 4cc0a00aac Simplify scheduler stack and mount Authentik auth
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
2026-06-15 10:04:12 -06:00
Zachariah K. Sharma 893def4d08 Improve scheduler scrolling and date range overrides
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
2026-06-15 09:43:41 -06:00
Zachariah K. Sharma 2fa50b067b Use NextAuth client flow for scheduler Authentik login
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
2026-06-14 17:39:45 -06:00
Zachariah K. Sharma 641dd40822 Add scheduler Authentik login and logout UI
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
2026-06-14 17:33:25 -06:00
Zachariah K. Sharma a3472a8e5a feat: Obsidian dark theme, Vynte brand mark, and favicon/logo
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
- Full CSS design system rewrite with dark "Obsidian" theme and sage
  green accent (#52b583) using DM Serif Display + DM Sans + JetBrains Mono
- Inline SVG brand mark in AppShell sidebar (V-mark with calendar squares)
- New favicon.svg and logo.svg with glowing convergence dot motif
- Google Fonts loaded via next/font for proper SSR font handling
2026-06-14 17:12:08 -06:00
Zachariah K. Sharma 2bd706d439 Fix scheduler connections and date overrides
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
2026-06-14 15:48:47 -06:00
Zachariah K. Sharma 10bdafafee Fix scheduler Docker build context
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
2026-06-14 13:51:48 -06:00
Zachariah Sharma 7039f242e9 Merge pull request 'Codex/vynte scheduler replacement' (#1) from codex/vynte-scheduler-replacement into main
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
Reviewed-on: https://git.internal.vyntehome.com/ZachariahSharma/cal-diy-oidc/pulls/1
2026-06-14 19:30:07 +00:00
35 changed files with 1838 additions and 506 deletions
+3 -1
View File
@@ -19,10 +19,12 @@ ENV NODE_ENV=production \
COPY package.json yarn.lock .yarnrc.yml turbo.json i18n.json ./
COPY .yarn ./.yarn
COPY apps/scheduler ./apps/scheduler
COPY apps/web ./apps/web
COPY packages ./packages
RUN yarn config set httpTimeout 1200000
RUN yarn install
RUN yarn install --mode=skip-build
RUN yarn workspace @calcom/prisma post-install
RUN NEXTAUTH_SECRET=${NEXTAUTH_SECRET} \
CALENDSO_ENCRYPTION_KEY=${CALENDSO_ENCRYPTION_KEY} \
DATABASE_URL=${DATABASE_URL} \
@@ -0,0 +1,21 @@
import { getOptions } from "@calcom/features/auth/lib/next-auth-options";
import { defaultCookies } from "@calcom/lib/default-cookies";
import type { TrackingData } from "@calcom/lib/tracking";
import NextAuth from "next-auth";
export const dynamic = "force-dynamic";
const nextAuthUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_WEBAPP_URL || "";
const options = getOptions({
getDubId: () => undefined,
getTrackingData: (): TrackingData => ({}),
});
const handler = NextAuth(
{
...options,
cookies: defaultCookies(nextAuthUrl.startsWith("https://")),
}
);
export { handler as GET, handler as POST };
@@ -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));
}
@@ -0,0 +1,61 @@
import { NextResponse, type NextRequest } from "next/server";
export const dynamic = "force-dynamic";
const COOKIE_NAMES = [
"next-auth.session-token",
"__Secure-next-auth.session-token",
];
const CHUNK_SUFFIXES = ["", ".0", ".1", ".2", ".3", ".4"];
function clearCookie(response: NextResponse, name: string, domain?: string) {
const parts = [
`${name}=`,
"Path=/",
"Expires=Thu, 01 Jan 1970 00:00:00 GMT",
"Max-Age=0",
"Secure",
"HttpOnly",
"SameSite=None",
];
if (domain) {
parts.splice(4, 0, `Domain=${domain}`);
}
response.headers.append("Set-Cookie", parts.join("; "));
}
function getCookieDomains() {
const configuredDomain = process.env.NEXTAUTH_COOKIE_DOMAIN || undefined;
if (!configuredDomain) {
return [undefined];
}
return [undefined, configuredDomain.replace(/^\./, "")];
}
export function GET(request: NextRequest) {
const response = NextResponse.redirect(new URL("/login", process.env.NEXTAUTH_URL || request.url));
const domains = getCookieDomains();
const requestSessionCookies = request.cookies
.getAll()
.map((cookie) => cookie.name)
.filter((name) => name.includes("next-auth.session-token"));
const sessionCookieNames = Array.from(new Set([
...requestSessionCookies,
...COOKIE_NAMES.flatMap((cookieName) => CHUNK_SUFFIXES.map((suffix) => `${cookieName}${suffix}`)),
]));
for (const name of sessionCookieNames) {
for (const domain of domains) {
clearCookie(response, name, domain);
}
}
return response;
}
export { GET as POST };
+27
View File
@@ -0,0 +1,27 @@
import { redirect } from "next/navigation";
import { safeSchedulerCallback } from "@scheduler/lib/scheduler/auth-links";
export const dynamic = "force-dynamic";
type AuthErrorPageProps = {
searchParams: Promise<{ callbackUrl?: string | string[]; error?: string | string[] }>;
};
function firstParam(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
export default async function AuthErrorPage({ searchParams }: AuthErrorPageProps) {
const params = await searchParams;
const target = new URLSearchParams({
callbackUrl: safeSchedulerCallback(params.callbackUrl),
});
const error = firstParam(params.error);
if (error) {
target.set("error", error);
}
redirect(`/login?${target.toString()}`);
}
+27
View File
@@ -0,0 +1,27 @@
import { redirect } from "next/navigation";
import { safeSchedulerCallback } from "@scheduler/lib/scheduler/auth-links";
export const dynamic = "force-dynamic";
type AuthLoginPageProps = {
searchParams: Promise<{ callbackUrl?: string | string[]; error?: string | string[] }>;
};
function firstParam(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
export default async function AuthLoginPage({ searchParams }: AuthLoginPageProps) {
const params = await searchParams;
const target = new URLSearchParams({
callbackUrl: safeSchedulerCallback(params.callbackUrl),
});
const error = firstParam(params.error);
if (error) {
target.set("error", error);
}
redirect(`/login?${target.toString()}`);
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
export default function AuthLogoutPage() {
redirect("/logout");
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
export default function AuthVerifyPage() {
redirect("/login");
}
+1 -1
View File
@@ -10,7 +10,7 @@ export const dynamic = "force-dynamic";
export default async function AvailabilityPage() {
const viewerId = await getServerViewerId();
if (viewerId === null) {
redirect("/auth/login");
redirect("/login?callbackUrl=/availability");
}
const [context, schedule] = await Promise.all([getTeamContext(viewerId), getSchedule(viewerId)]);
+1 -1
View File
@@ -27,7 +27,7 @@ function currentWeekRange(): { rangeStart: string; rangeEnd: string } {
export default async function CalendarPage() {
const viewerId = await getServerViewerId();
if (viewerId === null) {
redirect("/auth/login");
redirect("/login?callbackUrl=/calendar");
}
const { rangeStart, rangeEnd } = currentWeekRange();
-23
View File
@@ -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("/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>
);
}
File diff suppressed because it is too large Load Diff
+26 -1
View File
@@ -1,14 +1,39 @@
import type { Metadata } from "next";
import { DM_Sans, DM_Serif_Display, JetBrains_Mono } from "next/font/google";
import "./globals.css";
const dmSans = DM_Sans({
subsets: ["latin"],
variable: "--font-dm-sans",
display: "swap",
});
const dmSerifDisplay = DM_Serif_Display({
subsets: ["latin"],
weight: "400",
style: ["normal", "italic"],
variable: "--font-serif",
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
weight: ["400", "500"],
variable: "--font-jb-mono",
display: "swap",
});
export const metadata: Metadata = {
title: "Vynte Schedule",
description: "Internal Vynte team scheduling",
icons: {
icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<html lang="en" className={`${dmSans.variable} ${dmSerifDisplay.variable} ${jetbrainsMono.variable}`}>
<body>{children}</body>
</html>
);
+21
View File
@@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { AuthCard } from "@scheduler/components/AuthCard";
import { getServerViewerId } from "@scheduler/lib/scheduler/auth";
import { safeSchedulerCallback } from "@scheduler/lib/scheduler/auth-links";
export const dynamic = "force-dynamic";
type LoginPageProps = {
searchParams: Promise<{ callbackUrl?: string | string[] }>;
};
export default async function LoginPage({ searchParams }: LoginPageProps) {
const params = await searchParams;
const viewerId = await getServerViewerId();
if (viewerId !== null) {
redirect(safeSchedulerCallback(params.callbackUrl));
}
return <AuthCard mode="login" callbackUrl={params.callbackUrl} />;
}
+16
View File
@@ -0,0 +1,16 @@
import { redirect } from "next/navigation";
import { AuthCard } from "@scheduler/components/AuthCard";
import { getServerViewerId } from "@scheduler/lib/scheduler/auth";
export const dynamic = "force-dynamic";
export default async function LogoutPage() {
const viewerId = await getServerViewerId();
if (viewerId === null) {
redirect("/login");
}
return <AuthCard mode="logout" />;
}
+19 -5
View File
@@ -1,12 +1,11 @@
import Link from "next/link";
type ActiveNavItem = "calendar" | "availability" | "connections";
type ActiveNavItem = "calendar" | "availability";
type NavItem = { id: ActiveNavItem; href: string; label: string };
const navItems: NavItem[] = [
{ id: "calendar", href: "/calendar", label: "Calendar" },
{ id: "availability", href: "/availability", label: "Availability" },
{ id: "connections", href: "/connections", label: "Connections" },
];
export function AppShell({
@@ -21,7 +20,17 @@ export function AppShell({
return (
<div className="app-frame">
<aside className="sidebar">
<div className="brand">Vynte Schedule</div>
<div className="brand">
<svg width="22" height="22" viewBox="0 0 32 32" aria-hidden="true" style={{ flexShrink: 0 }}>
<rect width="32" height="32" rx="7.5" fill="#0d0f11"/>
<rect x="6" y="7" width="4" height="4" rx="1" fill="#52b583" opacity="0.5"/>
<rect x="22" y="7" width="4" height="4" rx="1" fill="#52b583" opacity="0.5"/>
<line x1="8" y1="11" x2="16" y2="22.5" stroke="#52b583" strokeWidth="2.5" strokeLinecap="round"/>
<line x1="24" y1="11" x2="16" y2="22.5" stroke="#52b583" strokeWidth="2.5" strokeLinecap="round"/>
<circle cx="16" cy="22.5" r="2.5" fill="#52b583"/>
</svg>
Vynte Schedule
</div>
<nav className="nav-list" aria-label="Scheduler navigation">
{navItems.map((item) => (
<Link key={item.id} className="nav-item" data-active={item.id === active} href={item.href}>
@@ -30,8 +39,13 @@ export function AppShell({
))}
</nav>
<div className="profile-block">
<strong>{user.name}</strong>
<span>{user.timeZone}</span>
<div>
<strong>{user.name}</strong>
<span>{user.timeZone}</span>
</div>
<a className="session-button" href="/api/scheduler/logout">
Sign out
</a>
</div>
</aside>
<main className="main-surface">{children}</main>
+49
View File
@@ -0,0 +1,49 @@
import Link from "next/link";
import { AuthentikLoginButton } from "@scheduler/components/AuthentikLoginButton";
import { AuthentikLogoutButton } from "@scheduler/components/AuthentikLogoutButton";
import { safeSchedulerCallback } from "@scheduler/lib/scheduler/auth-links";
type AuthCardProps = {
mode: "login" | "logout";
callbackUrl?: string | string[];
};
export function AuthCard({ mode, callbackUrl }: AuthCardProps) {
const isLogin = mode === "login";
const safeCallbackUrl = safeSchedulerCallback(callbackUrl);
return (
<main className="auth-screen">
<section className="auth-card" aria-labelledby="auth-title">
<div className="auth-mark" aria-hidden="true">
<svg width="34" height="34" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7.5" fill="#0d0f11" />
<rect x="6" y="7" width="4" height="4" rx="1" fill="#52b583" opacity="0.5" />
<rect x="22" y="7" width="4" height="4" rx="1" fill="#52b583" opacity="0.5" />
<line x1="8" y1="11" x2="16" y2="22.5" stroke="#52b583" strokeWidth="2.5" strokeLinecap="round" />
<line x1="24" y1="11" x2="16" y2="22.5" stroke="#52b583" strokeWidth="2.5" strokeLinecap="round" />
<circle cx="16" cy="22.5" r="2.5" fill="#52b583" />
</svg>
</div>
<p className="auth-kicker">Vynte Schedule</p>
<h1 id="auth-title">{isLogin ? "Sign in" : "Sign out"}</h1>
<p className="auth-copy">
{isLogin
? "Use your Vynte Authentik account to access scheduling."
: "End this browser session for Vynte scheduling."}
</p>
{isLogin ? (
<AuthentikLoginButton callbackUrl={safeCallbackUrl} />
) : (
<AuthentikLogoutButton />
)}
{!isLogin && (
<Link className="auth-secondary" href="/calendar">
Back to calendar
</Link>
)}
</section>
</main>
);
}
@@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
type AuthentikLoginButtonProps = {
callbackUrl: string;
};
export function AuthentikLoginButton({ callbackUrl }: AuthentikLoginButtonProps) {
const [isLoading, setIsLoading] = useState(false);
async function startAuthentikLogin() {
setIsLoading(true);
try {
const csrfResponse = await fetch("/api/auth/csrf");
const { csrfToken } = (await csrfResponse.json()) as { csrfToken: string };
const signInResponse = await fetch("/api/auth/signin/authentik", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
callbackUrl,
csrfToken,
json: "true",
}),
});
const { url } = (await signInResponse.json()) as { url: string };
window.location.assign(url);
} catch {
setIsLoading(false);
}
}
return (
<button
className="auth-primary"
disabled={isLoading}
type="button"
onClick={() => {
void startAuthentikLogin();
}}>
{isLoading ? "Opening Authentik..." : "Continue with Authentik"}
</button>
);
}
@@ -0,0 +1,7 @@
export function AuthentikLogoutButton() {
return (
<a className="auth-primary" href="/api/scheduler/logout">
Sign out
</a>
);
}
+150 -14
View File
@@ -2,6 +2,7 @@
import { useMemo, useState } from "react";
import { overrideEndDate, upsertDateOverride } from "@scheduler/lib/scheduler/overrides";
import type { DateOverride, SchedulerSchedule, WeeklyRange } from "@scheduler/lib/scheduler/types";
type AvailabilityEditorProps = {
@@ -34,6 +35,13 @@ function buildRows(weekly: WeeklyRange[]): WeeklyRange[] {
export function AvailabilityEditor({ schedule }: AvailabilityEditorProps) {
const [rows, setRows] = useState<WeeklyRange[]>(() => buildRows(schedule.weekly));
const [overrides, setOverrides] = useState<DateOverride[]>(schedule.overrides);
const [isAddingOverride, setIsAddingOverride] = useState(false);
const [overrideDate, setOverrideDate] = useState("");
const [overrideEndDateValue, setOverrideEndDateValue] = useState("");
const [overrideUnavailable, setOverrideUnavailable] = useState(true);
const [overrideStart, setOverrideStart] = useState(DEFAULT_START);
const [overrideEnd, setOverrideEnd] = useState(DEFAULT_END);
const [overrideError, setOverrideError] = useState("");
const [saveState, setSaveState] = useState<SaveState>({ kind: "idle" });
const timeZone = schedule.timeZone;
@@ -47,6 +55,45 @@ export function AvailabilityEditor({ schedule }: AvailabilityEditorProps) {
setOverrides((current) => current.filter((override) => override.date !== date));
};
const addOverride = () => {
if (!overrideDate) {
setOverrideError("Choose a start date.");
return;
}
if (overrideEndDateValue && overrideEndDateValue < overrideDate) {
setOverrideError("End date must be after the start date.");
return;
}
if (!overrideUnavailable && overrideStart >= overrideEnd) {
setOverrideError("End time must be after start time.");
return;
}
const next: DateOverride = overrideUnavailable
? { date: overrideDate, ...(overrideEndDateValue && { endDate: overrideEndDateValue }), unavailable: true }
: {
date: overrideDate,
...(overrideEndDateValue && { endDate: overrideEndDateValue }),
unavailable: false,
startTime: overrideStart,
endTime: overrideEnd,
};
setOverrides((current) => upsertDateOverride(current, next));
setOverrideDate("");
setOverrideEndDateValue("");
setOverrideError("");
setIsAddingOverride(false);
};
const overrideSummary = (override: DateOverride) => {
const endDate = overrideEndDate(override);
const dates = endDate === override.date ? override.date : `${override.date} to ${endDate}`;
const hours = override.unavailable
? "Unavailable all day"
: `${override.startTime ?? DEFAULT_START} - ${override.endTime ?? DEFAULT_END}`;
return { dates, hours };
};
const save = async () => {
setSaveState({ kind: "saving" });
try {
@@ -117,24 +164,113 @@ export function AvailabilityEditor({ schedule }: AvailabilityEditorProps) {
</section>
<section className="settings-card">
<h2>Date overrides</h2>
<div className="settings-card-heading">
<div>
<h2>Date overrides</h2>
<p className="muted">Change one date or a short date range without touching weekly hours.</p>
</div>
<button
type="button"
className="icon-button"
aria-label="Add date override"
aria-expanded={isAddingOverride}
onClick={() => {
setIsAddingOverride((current) => !current);
setOverrideError("");
}}>
+
</button>
</div>
{isAddingOverride && (
<div className="override-editor" aria-label="New date override">
<div className="override-date-grid">
<label className="field override-date" htmlFor="override-start-date">
<span>Start date</span>
<input
id="override-start-date"
type="date"
value={overrideDate}
onChange={(event) => setOverrideDate(event.target.value)}
/>
</label>
<label className="field override-date" htmlFor="override-end-date">
<span>End date</span>
<input
id="override-end-date"
type="date"
value={overrideEndDateValue}
min={overrideDate || undefined}
onChange={(event) => setOverrideEndDateValue(event.target.value)}
/>
</label>
</div>
<div className="override-mode-row" role="group" aria-label="Override type">
<button
type="button"
className="secondary-button"
data-active={overrideUnavailable}
onClick={() => setOverrideUnavailable(true)}>
Unavailable
</button>
<button
type="button"
className="secondary-button"
data-active={!overrideUnavailable}
onClick={() => setOverrideUnavailable(false)}>
Custom hours
</button>
</div>
{!overrideUnavailable && (
<div className="time-range override-time-range">
<input
type="time"
value={overrideStart}
onChange={(event) => setOverrideStart(event.target.value)}
aria-label="Override start"
/>
<span className="dash"></span>
<input
type="time"
value={overrideEnd}
onChange={(event) => setOverrideEnd(event.target.value)}
aria-label="Override end"
/>
</div>
)}
<div className="override-actions">
<button type="button" className="primary-button" onClick={addOverride}>
Add override
</button>
<button type="button" className="secondary-button" onClick={() => setIsAddingOverride(false)}>
Cancel
</button>
</div>
</div>
)}
{overrideError && <p className="notice notice-error">{overrideError}</p>}
{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>
))}
{overrides.map((override) => {
const summary = overrideSummary(override);
return (
<li key={`${override.date}-${override.endDate ?? override.date}`} className="override-row">
<span>
<strong>{summary.dates}</strong>
<span className="muted">{summary.hours}</span>
</span>
<button type="button" className="secondary-button" onClick={() => removeOverride(override.date)}>
Remove
</button>
</li>
);
})}
</ul>
)}
</section>
+51 -63
View File
@@ -92,8 +92,6 @@ export function CalendarWorkspace({
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);
@@ -167,8 +165,6 @@ export function CalendarWorkspace({
attendeeIds: selectedAttendeeIds,
start: selectedSlotStart,
durationMinutes,
location: location.trim() || undefined,
conferencing: conferencing ? "provider" : "none",
}),
});
@@ -188,7 +184,7 @@ export function CalendarWorkspace({
} catch {
setCreateState({ kind: "error", message: "Could not create the meeting." });
}
}, [selectedSlotStart, title, selectedAttendeeIds, durationMinutes, location, conferencing]);
}, [selectedSlotStart, title, selectedAttendeeIds, durationMinutes]);
return (
<div className="calendar-layout">
@@ -197,57 +193,59 @@ export function CalendarWorkspace({
<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 className="week-scroller">
<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>
{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>
@@ -302,16 +300,6 @@ export function CalendarWorkspace({
</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>
@@ -1,70 +0,0 @@
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>
);
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { safeSchedulerCallback } from "./auth-links";
describe("safeSchedulerCallback", () => {
it("falls back to calendar for unsafe callback values", () => {
expect(safeSchedulerCallback("https://example.com")).toBe("/calendar");
expect(safeSchedulerCallback("//example.com")).toBe("/calendar");
});
it("normalizes redirects for already-authenticated users", () => {
expect(safeSchedulerCallback("/connections")).toBe("/calendar");
expect(safeSchedulerCallback("/availability")).toBe("/availability");
expect(safeSchedulerCallback("https://example.com")).toBe("/calendar");
});
});
@@ -0,0 +1,10 @@
const DEFAULT_CALLBACK = "/calendar";
const REMOVED_CALLBACKS = new Set(["/connections"]);
export function safeSchedulerCallback(value: string | string[] | undefined): string {
const candidate = Array.isArray(value) ? value[0] : value;
if (!candidate || !candidate.startsWith("/") || candidate.startsWith("//") || REMOVED_CALLBACKS.has(candidate)) {
return DEFAULT_CALLBACK;
}
return candidate;
}
@@ -89,4 +89,53 @@ describe("computeMutualSlots", () => {
slots[0].attendeeIds.push(999);
expect(slots[1].attendeeIds).toEqual(originalSecondIds);
});
it("removes slots on a date marked unavailable", () => {
const slots = computeMutualSlots({
schedules: schedules.map((schedule) => ({
...schedule,
overrides: [{ date: "2026-06-15", unavailable: true }],
})),
busy: [],
rangeStart: "2026-06-15T00:00:00.000Z",
rangeEnd: "2026-06-16T00:00:00.000Z",
durationMinutes: 30,
});
expect(slots).toEqual([]);
});
it("uses custom override hours instead of weekly hours", () => {
const slots = computeMutualSlots({
schedules: schedules.map((schedule) => ({
...schedule,
overrides: [{ date: "2026-06-15", unavailable: false, startTime: "13:00", endTime: "14:00" }],
})),
busy: [],
rangeStart: "2026-06-15T00:00:00.000Z",
rangeEnd: "2026-06-16T00:00:00.000Z",
durationMinutes: 30,
});
expect(slots.map((slot) => slot.start)).toEqual(["2026-06-15T19:00:00.000Z", "2026-06-15T19:30:00.000Z"]);
});
it("applies date range overrides to each date in the range", () => {
const slots = computeMutualSlots({
schedules: schedules.map((schedule) => ({
...schedule,
weekly: [
{ day: 1, enabled: true, startTime: "09:00", endTime: "11:00" },
{ day: 2, enabled: true, startTime: "09:00", endTime: "11:00" },
],
overrides: [{ date: "2026-06-15", endDate: "2026-06-16", unavailable: true }],
})),
busy: [],
rangeStart: "2026-06-15T00:00:00.000Z",
rangeEnd: "2026-06-17T00:00:00.000Z",
durationMinutes: 30,
});
expect(slots).toEqual([]);
});
});
+10 -3
View File
@@ -1,5 +1,6 @@
import { addMinutes, overlaps, toIso } from "./time";
import type { BusyBlock, MutualSlot, SchedulerSchedule } from "./types";
import { overrideEndDate } from "./overrides";
type ComputeInput = {
schedules: SchedulerSchedule[];
@@ -22,12 +23,18 @@ function dateAtLocalMinutes(day: Date, minutes: number) {
}
function windowsForSchedule(schedule: SchedulerSchedule, rangeStart: Date, rangeEnd: Date) {
// v1: weekly ranges only. TODO: apply schedule.overrides (unavailable dates / one-off hours).
const windows: { start: Date; end: Date; userId: number }[] = [];
for (let cursor = new Date(rangeStart); cursor < rangeEnd; cursor = addMinutes(cursor, 24 * 60)) {
const day = cursor.getUTCDay();
const weekly = schedule.weekly.filter((range) => range.day === day && range.enabled);
for (const range of weekly) {
const date = cursor.toISOString().slice(0, 10);
const override = schedule.overrides.find((item) => item.date <= date && overrideEndDate(item) >= date);
if (override?.unavailable) continue;
const ranges =
override?.startTime && override.endTime
? [{ startTime: override.startTime, endTime: override.endTime }]
: schedule.weekly.filter((range) => range.day === day && range.enabled);
for (const range of ranges) {
windows.push({
userId: schedule.userId,
start: dateAtLocalMinutes(cursor, minutesFromTime(range.startTime)),
-16
View File
@@ -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 = {
id: number;
uid: string;
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { collapseDateOverrides, expandDateOverride, upsertDateOverride } from "./overrides";
describe("upsertDateOverride", () => {
it("adds a new override in date order", () => {
expect(
upsertDateOverride(
[{ date: "2026-06-20", unavailable: true }],
{ date: "2026-06-18", unavailable: false, startTime: "10:00", endTime: "14:00" }
)
).toEqual([
{ date: "2026-06-18", unavailable: false, startTime: "10:00", endTime: "14:00" },
{ date: "2026-06-20", unavailable: true },
]);
});
it("replaces an existing override for the same date", () => {
expect(
upsertDateOverride(
[{ date: "2026-06-18", unavailable: false, startTime: "09:00", endTime: "17:00" }],
{ date: "2026-06-18", unavailable: true }
)
).toEqual([{ date: "2026-06-18", unavailable: true }]);
});
it("replaces overrides that overlap a new date range", () => {
expect(
upsertDateOverride(
[
{ date: "2026-06-17", unavailable: true },
{ date: "2026-06-18", unavailable: true },
{ date: "2026-06-23", unavailable: true },
],
{ date: "2026-06-18", endDate: "2026-06-20", unavailable: false, startTime: "10:00", endTime: "14:00" }
)
).toEqual([
{ date: "2026-06-17", unavailable: true },
{ date: "2026-06-18", endDate: "2026-06-20", unavailable: false, startTime: "10:00", endTime: "14:00" },
{ date: "2026-06-23", unavailable: true },
]);
});
});
describe("expandDateOverride", () => {
it("expands a date range to one override per day", () => {
expect(expandDateOverride({ date: "2026-06-18", endDate: "2026-06-20", unavailable: true })).toEqual([
{ date: "2026-06-18", unavailable: true },
{ date: "2026-06-19", unavailable: true },
{ date: "2026-06-20", unavailable: true },
]);
});
});
describe("collapseDateOverrides", () => {
it("collapses adjacent overrides with the same rule", () => {
expect(
collapseDateOverrides([
{ date: "2026-06-18", unavailable: true },
{ date: "2026-06-19", unavailable: true },
{ date: "2026-06-20", unavailable: true },
{ date: "2026-06-21", unavailable: false, startTime: "10:00", endTime: "14:00" },
])
).toEqual([
{ date: "2026-06-18", endDate: "2026-06-20", unavailable: true },
{ date: "2026-06-21", unavailable: false, startTime: "10:00", endTime: "14:00" },
]);
});
});
+89
View File
@@ -0,0 +1,89 @@
import type { DateOverride } from "./types";
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
function parseDateOnly(value: string): Date {
return new Date(`${value}T00:00:00.000Z`);
}
function formatDateOnly(value: Date): string {
return value.toISOString().slice(0, 10);
}
function addDays(date: Date, days: number): Date {
const next = new Date(date);
next.setUTCDate(next.getUTCDate() + days);
return next;
}
export function overrideEndDate(override: DateOverride): string {
return override.endDate && override.endDate >= override.date ? override.endDate : override.date;
}
function rangesOverlap(a: DateOverride, b: DateOverride): boolean {
return a.date <= overrideEndDate(b) && b.date <= overrideEndDate(a);
}
export function upsertDateOverride(overrides: DateOverride[], next: DateOverride): DateOverride[] {
return [...overrides.filter((override) => !rangesOverlap(override, next)), next].sort((a, b) =>
a.date.localeCompare(b.date)
);
}
export function expandDateOverride(override: DateOverride): DateOverride[] {
const start = parseDateOnly(override.date);
const end = parseDateOnly(overrideEndDate(override));
const expanded: DateOverride[] = [];
for (let cursor = start; cursor <= end; cursor = addDays(cursor, 1)) {
const date = formatDateOnly(cursor);
expanded.push({
date,
unavailable: override.unavailable,
...(!override.unavailable && { startTime: override.startTime, endTime: override.endTime }),
});
}
return expanded;
}
function sameOverrideRule(left: DateOverride, right: DateOverride): boolean {
return (
left.unavailable === right.unavailable &&
left.startTime === right.startTime &&
left.endTime === right.endTime
);
}
export function collapseDateOverrides(overrides: DateOverride[]): DateOverride[] {
const sorted = overrides
.filter((override) => DATE_ONLY.test(override.date))
.map((override) => ({ ...override, endDate: undefined }))
.sort((a, b) => a.date.localeCompare(b.date));
const collapsed: DateOverride[] = [];
for (const override of sorted) {
const previous = collapsed[collapsed.length - 1];
if (!previous || !sameOverrideRule(previous, override)) {
collapsed.push(override);
continue;
}
const previousEnd = parseDateOnly(overrideEndDate(previous));
const nextDay = formatDateOnly(addDays(previousEnd, 1));
if (nextDay === override.date) {
previous.endDate = override.date;
continue;
}
collapsed.push(override);
}
return collapsed.map((override) => {
if (override.endDate === override.date) {
const { endDate: _endDate, ...singleDay } = override;
return singleDay;
}
return override;
});
}
+28 -69
View File
@@ -1,13 +1,12 @@
import prisma from "@calcom/prisma";
import { computeMutualSlots } from "./availability";
import { collapseDateOverrides, expandDateOverride } from "./overrides";
import {
demoBusyBlocks,
demoConnectionsByUser,
demoMeetings,
demoSchedules,
demoUsers,
type DemoConnection,
} from "./demo-data";
import { isDemoMode } from "./demo-mode";
import { filterBusyBlocksForViewer } from "./privacy";
@@ -71,11 +70,13 @@ function buildSchedule(
for (const row of schedule?.availability ?? []) {
if (row.date) {
const startTime = timeToHhMm(row.startTime);
const endTime = timeToHhMm(row.endTime);
const unavailable = startTime === "00:00" && endTime === "00:00";
overrides.push({
date: row.date.toISOString().slice(0, 10),
unavailable: false,
startTime: timeToHhMm(row.startTime),
endTime: timeToHhMm(row.endTime),
unavailable,
...(!unavailable && { startTime, endTime }),
});
continue;
}
@@ -94,7 +95,7 @@ function buildSchedule(
userId,
timeZone: schedule?.timeZone ?? fallbackTimeZone,
weekly,
overrides,
overrides: collapseDateOverrides(overrides),
};
}
@@ -205,12 +206,20 @@ const weeklyRangeSchema = z.object({
endTime: hhMm,
});
const dateOverrideSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD date"),
unavailable: z.boolean(),
startTime: hhMm.optional(),
endTime: hhMm.optional(),
});
const dateOverrideSchema = z
.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD date"),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD date").optional(),
unavailable: z.boolean(),
startTime: hhMm.optional(),
endTime: hhMm.optional(),
})
.refine((override) => !override.endDate || override.endDate >= override.date, {
message: "End date must be on or after start date",
})
.refine((override) => override.unavailable || (override.startTime && override.endTime), {
message: "Available overrides require start and end times",
});
const updateScheduleSchema = z.object({
timeZone: z.string().min(1).optional(),
@@ -257,15 +266,15 @@ export async function updateSchedule(userId: number, body: unknown): Promise<Sch
date: null as Date | null,
userId,
})),
...parsed.overrides
.filter((override) => !override.unavailable && override.startTime && override.endTime)
.map((override) => ({
...parsed.overrides.flatMap((override) =>
expandDateOverride(override).map((dateOverride) => ({
days: [] as number[],
startTime: hhMmToTime(override.startTime as string),
endTime: hhMmToTime(override.endTime as string),
date: new Date(`${override.date}T00:00:00.000Z`),
startTime: hhMmToTime(dateOverride.unavailable ? "00:00" : (dateOverride.startTime as string)),
endTime: hhMmToTime(dateOverride.unavailable ? "00:00" : (dateOverride.endTime as string)),
date: new Date(`${dateOverride.date}T00:00:00.000Z`),
userId,
})),
}))
),
];
// Replace the schedule's availability atomically; immutable from the caller's view.
@@ -293,54 +302,6 @@ export async function updateSchedule(userId: number, body: unknown): Promise<Sch
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
// ---------------------------------------------------------------------------
@@ -486,8 +447,6 @@ const createMeetingSchema = z.object({
attendeeIds: z.array(z.number().int().positive()).min(1),
start: z.string().datetime(),
durationMinutes: z.number().int().positive(),
location: z.string().optional(),
conferencing: z.enum(["none", "provider"]).optional(),
});
export type CreateMeetingResult =
+1 -2
View File
@@ -27,6 +27,7 @@ export type WeeklyRange = {
export type DateOverride = {
date: string;
endDate?: string;
unavailable: boolean;
startTime?: string;
endTime?: string;
@@ -51,6 +52,4 @@ export type MeetingDraft = {
attendeeIds: number[];
start: string;
durationMinutes: number;
location?: string;
conferencing?: "none" | "provider";
};
+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Background -->
<rect width="32" height="32" rx="7.5" fill="#0d0f11"/>
<!-- Calendar squares at the V tips — events to be scheduled -->
<rect x="6" y="7" width="4" height="4" rx="1" fill="#52b583" opacity="0.5"/>
<rect x="22" y="7" width="4" height="4" rx="1" fill="#52b583" opacity="0.5"/>
<!-- V arms — the convergence path -->
<line x1="8" y1="11" x2="16" y2="22.5" stroke="#52b583" stroke-width="2.5" stroke-linecap="round"/>
<line x1="24" y1="11" x2="16" y2="22.5" stroke="#52b583" stroke-width="2.5" stroke-linecap="round"/>
<!-- Convergence dot — the meeting point -->
<circle cx="16" cy="22.5" r="2.5" fill="#52b583" filter="url(#glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1015 B

+56
View File
@@ -0,0 +1,56 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 52" width="210" height="52">
<defs>
<style>
.wm { font-family: 'DM Serif Display', Georgia, 'Times New Roman', serif; }
.sub { font-family: system-ui, -apple-system, 'Helvetica Neue', sans-serif; }
</style>
<filter id="dot-glow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="2.5" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- ── Mark icon (48 × 48) centred vertically in 52px height ── -->
<g transform="translate(0, 2)">
<!-- Background tile -->
<rect width="48" height="48" rx="11" fill="#0d0f11"/>
<!-- Subtle inner border -->
<rect x="0.5" y="0.5" width="47" height="47" rx="10.5"
fill="none" stroke="#1e2226" stroke-width="1"/>
<!-- Calendar squares at the top of each arm -->
<rect x="9" y="10" width="6" height="6" rx="1.5" fill="#52b583" opacity="0.45"/>
<rect x="33" y="10" width="6" height="6" rx="1.5" fill="#52b583" opacity="0.45"/>
<!-- V arms -->
<line x1="12" y1="16" x2="24" y2="33"
stroke="#52b583" stroke-width="3.2" stroke-linecap="round"/>
<line x1="36" y1="16" x2="24" y2="33"
stroke="#52b583" stroke-width="3.2" stroke-linecap="round"/>
<!-- Convergence dot with glow -->
<circle cx="24" cy="33" r="3.5" fill="#52b583" filter="url(#dot-glow)"/>
</g>
<!-- ── Wordmark ── -->
<!-- "Vynte" in DM Serif Display -->
<text x="62" y="34"
class="wm"
font-family="'DM Serif Display', Georgia, serif"
font-size="28"
fill="#ddd8d2"
letter-spacing="-0.6">Vynte</text>
<!-- "SCHEDULE" small-caps label -->
<text x="63" y="47"
class="sub"
font-family="system-ui, -apple-system, sans-serif"
font-size="8.5"
fill="#4d545d"
letter-spacing="3.2"
font-weight="600">SCHEDULE</text>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+12 -114
View File
@@ -2,7 +2,6 @@ name: cal-diy-oidc
volumes:
postgres-data:
redis-data:
networks:
cal-diy:
@@ -25,118 +24,7 @@ services:
networks:
- cal-diy
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
networks:
- cal-diy
calcom:
image: ${CALCOM_IMAGE:-10.0.3.6:4000/zachariahsharma/cal-diy-oidc:latest}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "${CALCOM_PORT:-3030}:3000"
environment:
NODE_ENV: production
NEXT_PUBLIC_WEBAPP_URL: ${NEXT_PUBLIC_WEBAPP_URL}
NEXT_PUBLIC_WEBSITE_URL: ${NEXT_PUBLIC_WEBSITE_URL:-${NEXT_PUBLIC_WEBAPP_URL}}
NEXT_PUBLIC_EMBED_LIB_URL: ${NEXT_PUBLIC_WEBAPP_URL}/embed/embed.js
NEXTAUTH_URL: ${NEXTAUTH_URL}
NEXTAUTH_URL_INTERNAL: ${NEXTAUTH_URL_INTERNAL:-http://calcom:3000/api/auth}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_COOKIE_DOMAIN: ${NEXTAUTH_COOKIE_DOMAIN:-}
CALENDSO_ENCRYPTION_KEY: ${CALENDSO_ENCRYPTION_KEY}
DATABASE_HOST: postgres:5432
DATABASE_URL: postgresql://${POSTGRES_USER:-calcom}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-calcom}
DATABASE_DIRECT_URL: postgresql://${POSTGRES_USER:-calcom}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-calcom}
REDIS_URL: redis://redis:6379
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET}
ALLOWED_HOSTNAMES: '"${ALLOWED_HOSTNAMES}"'
RESERVED_SUBDOMAINS: ${RESERVED_SUBDOMAINS:-"app","auth","docs","design","console","go","status","api","saml","www","matrix","developer","cal","my","team","support","security","blog","learn","admin"}
CALCOM_TELEMETRY_DISABLED: ${CALCOM_TELEMETRY_DISABLED:-1}
CRON_API_KEY: ${CRON_API_KEY}
CRON_ENABLE_APP_SYNC: ${CRON_ENABLE_APP_SYNC:-false}
EMAIL_FROM: ${EMAIL_FROM:-notifications@example.com}
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-Cal.diy}
EMAIL_SERVER_HOST: ${EMAIL_SERVER_HOST:-}
EMAIL_SERVER_PORT: ${EMAIL_SERVER_PORT:-587}
EMAIL_SERVER_USER: ${EMAIL_SERVER_USER:-}
EMAIL_SERVER_PASSWORD: ${EMAIL_SERVER_PASSWORD:-}
EMAIL_SERVER_SECURE: ${EMAIL_SERVER_SECURE:-false}
NODE_TLS_REJECT_UNAUTHORIZED: ${NODE_TLS_REJECT_UNAUTHORIZED:-}
NEXT_PUBLIC_DISABLE_SIGNUP: "true"
networks:
- cal-diy
healthcheck:
test: ["CMD-SHELL", "wget --spider -q http://$$(hostname):3000"]
interval: 30s
timeout: 10s
retries: 10
cal-api-v2:
image: ${CALCOM_API_V2_IMAGE:-10.0.3.6:4000/zachariahsharma/cal-diy-oidc-api-v2:latest}
build:
context: .
dockerfile: apps/api/v2/Dockerfile
args:
DATABASE_URL: postgresql://${POSTGRES_USER:-calcom}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-calcom}
DATABASE_DIRECT_URL: postgresql://${POSTGRES_USER:-calcom}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-calcom}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "${CAL_API_V2_PORT:-3031}:5555"
environment:
NODE_ENV: production
API_PORT: 5555
API_URL: ${API_URL:-http://cal-api-v2:5555}
WEB_APP_URL: ${NEXT_PUBLIC_WEBAPP_URL}
DATABASE_URL: postgresql://${POSTGRES_USER:-calcom}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-calcom}
DATABASE_DIRECT_URL: postgresql://${POSTGRES_USER:-calcom}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-calcom}
DATABASE_READ_URL: postgresql://${POSTGRES_USER:-calcom}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-calcom}
DATABASE_WRITE_URL: postgresql://${POSTGRES_USER:-calcom}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-calcom}
REDIS_URL: redis://redis:6379
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
JWT_SECRET: ${JWT_SECRET}
CALENDSO_ENCRYPTION_KEY: ${CALENDSO_ENCRYPTION_KEY}
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY: ${CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY}
API_KEY_PREFIX: ${API_KEY_PREFIX:-cal_}
REWRITE_API_V2_PREFIX: "1"
STRIPE_API_KEY: ${STRIPE_API_KEY:-sk_test_placeholder}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-whsec_placeholder}
NEXT_PUBLIC_SENTRY_DSN: ""
CALCOM_TELEMETRY_DISABLED: ${CALCOM_TELEMETRY_DISABLED:-1}
networks:
- cal-diy
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:5555/v2/me').then(r=>process.exit(r.ok||r.status===401?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 10
# Optional slim scheduler UI. Gated behind the "scheduler" profile so it only
# starts with `docker compose --profile scheduler up`.
scheduler:
profiles: ["scheduler"]
image: ${SCHEDULER_IMAGE:-10.0.3.6:4000/zachariahsharma/vynte-scheduler:latest}
build:
context: .
dockerfile: apps/scheduler/Dockerfile
@@ -145,14 +33,24 @@ services:
postgres:
condition: service_healthy
ports:
- "${SCHEDULER_PORT:-3040}:3040"
- "${SCHEDULER_PORT:-3030}:3040"
environment:
NODE_ENV: production
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_URL: ${NEXTAUTH_URL}
NEXTAUTH_URL: ${NEXTAUTH_URL:-https://cal.internal.vyntehome.com}
NEXT_PUBLIC_WEBAPP_URL: ${NEXT_PUBLIC_WEBAPP_URL:-https://cal.internal.vyntehome.com}
NEXTAUTH_COOKIE_DOMAIN: ${NEXTAUTH_COOKIE_DOMAIN:-}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET}
DATABASE_URL: postgresql://${POSTGRES_USER:-calcom}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-calcom}
DATABASE_DIRECT_URL: postgresql://${POSTGRES_USER:-calcom}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-calcom}
CALENDSO_ENCRYPTION_KEY: ${CALENDSO_ENCRYPTION_KEY}
SCHEDULER_DEMO_MODE: ${SCHEDULER_DEMO_MODE:-0}
networks:
- cal-diy
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3040/login').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 10
+6 -39
View File
@@ -1,54 +1,21 @@
# Required public URL settings
NEXT_PUBLIC_WEBAPP_URL=https://cal.example.com
NEXTAUTH_URL=https://cal.example.com/api/auth
ALLOWED_HOSTNAMES=cal.example.com
NEXTAUTH_URL=https://cal.example.com
NEXTAUTH_COOKIE_DOMAIN=
SCHEDULER_PORT=3030
# Required secrets
POSTGRES_PASSWORD=replace-with-a-long-random-password
NEXTAUTH_SECRET=replace-with-openssl-rand-base64-32
CALENDSO_ENCRYPTION_KEY=replace-with-openssl-rand-base64-24
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=replace-with-random-service-account-key
JWT_SECRET=replace-with-a-long-random-jwt-secret
CRON_API_KEY=replace-with-a-long-random-token
# Authentik OIDC
AUTHENTIK_ISSUER=https://auth.example.com/application/o/cal/
AUTHENTIK_CLIENT_ID=replace-with-authentik-client-id
AUTHENTIK_CLIENT_SECRET=replace-with-authentik-client-secret
# Optional stack settings
CALCOM_IMAGE=10.0.3.6:4000/zachariahsharma/cal-diy-oidc:latest
CALCOM_API_V2_IMAGE=10.0.3.6:4000/zachariahsharma/cal-diy-oidc-api-v2:latest
CALCOM_PORT=3030
CAL_API_V2_PORT=3031
# Optional scheduler service (enable with the "scheduler" compose profile).
# Keep SCHEDULER_DEMO_MODE=0 in production: demo mode bypasses Authentik login.
SCHEDULER_IMAGE=10.0.3.6:4000/zachariahsharma/vynte-scheduler:latest
SCHEDULER_PORT=3040
SCHEDULER_DEMO_MODE=0
# Database
POSTGRES_USER=calcom
POSTGRES_DB=calcom
CALCOM_TELEMETRY_DISABLED=1
CRON_ENABLE_APP_SYNC=false
ORGANIZATIONS_ENABLED=0
NEXT_PUBLIC_SINGLE_ORG_SLUG=
NEXT_PUBLIC_API_V2_URL=
API_URL=http://cal-api-v2:5555
API_KEY_PREFIX=cal_
STRIPE_API_KEY=sk_test_placeholder
STRIPE_WEBHOOK_SECRET=whsec_placeholder
NEXT_PUBLIC_WEBSITE_URL=
NEXTAUTH_URL_INTERNAL=http://calcom:3000/api/auth
NEXTAUTH_COOKIE_DOMAIN=
NODE_TLS_REJECT_UNAUTHORIZED=
# Optional SMTP settings. Leave blank until you wire email.
EMAIL_FROM=notifications@example.com
EMAIL_FROM_NAME=Cal.diy
EMAIL_SERVER_HOST=
EMAIL_SERVER_PORT=587
EMAIL_SERVER_USER=
EMAIL_SERVER_PASSWORD=
EMAIL_SERVER_SECURE=false
# Keep SCHEDULER_DEMO_MODE=0 in production: demo mode bypasses Authentik login.
SCHEDULER_DEMO_MODE=0