Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6fc365278 | |||
| 782a578492 | |||
| e283a9dfa1 | |||
| 26a18e0275 | |||
| 635935c3ba | |||
| 63dc5431d8 | |||
| a635be2c71 | |||
| 240e0a7309 | |||
| 0cba09ddb4 | |||
| 4cc0a00aac | |||
| 893def4d08 | |||
| 2fa50b067b | |||
| 641dd40822 | |||
| a3472a8e5a | |||
| 2bd706d439 | |||
| 10bdafafee | |||
| 7039f242e9 |
@@ -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 };
|
||||
@@ -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()}`);
|
||||
}
|
||||
@@ -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()}`);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AuthLogoutPage() {
|
||||
redirect("/logout");
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AuthVerifyPage() {
|
||||
redirect("/login");
|
||||
}
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+926
-72
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user