/** * — admin dashboard for rSchedule (passkey-gated). * * Tabs: Overview · Availability · Bookings · Google Calendar · Settings. * All mutations go through `/api/admin/*` which enforces moderator role * server-side via resolveCallerRole. */ interface Settings { displayName: string; email: string; bookingMessage: string; slotDurationMin: number; bufferBeforeMin: number; bufferAfterMin: number; minNoticeHours: number; maxAdvanceDays: number; timezone: string; autoShiftTimezone: boolean; } interface Rule { id: string; dayOfWeek: number; startTime: string; endTime: string; isActive: boolean; } interface Override { id: string; date: string; isBlocked: boolean; startTime: string | null; endTime: string | null; reason: string; } interface BookingRow { id: string; guestName: string; guestEmail: string; startTime: number; endTime: number; timezone: string; status: string; attendeeCount: number; } interface GcalStatus { connected: boolean; email: string | null; calendarIds: string[]; syncToken: string | null; lastSyncAt: number | null; lastSyncStatus: "ok" | "error" | null; lastSyncError: string | null; } interface InvitationRow { id: string; bookingId: string; host: { kind: string; id: string; label?: string }; title: string; startTime: number; endTime: number; timezone: string; status: string; response: "invited" | "accepted" | "declined"; meetingLink: string | null; } type Tab = "overview" | "availability" | "bookings" | "invitations" | "calendar" | "settings"; class FolkScheduleAdmin extends HTMLElement { private shadow: ShadowRoot; private space = ""; private tab: Tab = "overview"; private err = ""; private info = ""; private authOk = false; private settings: Settings | null = null; private rules: Rule[] = []; private overrides: Override[] = []; private bookings: BookingRow[] = []; private invitations: InvitationRow[] = []; private gcal: GcalStatus | null = null; private busy = false; private browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; void this.init(); } private apiBase(): string { const path = window.location.pathname; const m = path.match(/^(\/[^/]+)?\/rschedule/); return `${m?.[0] || "/rschedule"}/api/admin`; } private authHeaders(): Record { try { const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}"); const token = sess?.token; return token ? { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } : { "Content-Type": "application/json" }; } catch { return { "Content-Type": "application/json" }; } } private async api(path: string, init: RequestInit = {}): Promise { const res = await fetch(`${this.apiBase()}${path}`, { ...init, headers: { ...this.authHeaders(), ...(init.headers || {}) }, }); if (res.status === 401) { this.authOk = false; this.err = "Sign in with a passkey to manage this booking page."; throw new Error("unauth"); } if (res.status === 403) { this.authOk = false; this.err = "Moderator or admin role required for this space."; throw new Error("forbidden"); } if (!res.ok) { const e = await res.json().catch(() => ({})); this.err = e.error || `Error ${res.status}`; throw new Error(this.err); } return res.json(); } private async init() { try { const { settings } = await this.api<{ settings: Settings }>("/settings"); this.authOk = true; this.settings = settings; await Promise.all([this.loadRules(), this.loadOverrides(), this.loadBookings(), this.loadInvitations(), this.loadGcal()]); } catch { /* err set in api() */ } this.render(); } private async loadRules() { try { const { rules } = await this.api<{ rules: Rule[] }>("/availability"); this.rules = rules; } catch {} } private async loadOverrides() { try { const { overrides } = await this.api<{ overrides: Override[] }>("/overrides"); this.overrides = overrides; } catch {} } private async loadBookings() { try { const { bookings } = await this.api<{ bookings: BookingRow[] }>("/bookings"); this.bookings = bookings; } catch {} } private async loadGcal() { try { this.gcal = await this.api("/google/status"); } catch {} } private async loadInvitations() { try { // Public endpoint — no /admin prefix const base = this.apiBase().replace(/\/api\/admin$/, "/api"); const res = await fetch(`${base}/invitations`); if (res.ok) { const data = await res.json() as { invitations: InvitationRow[] }; this.invitations = data.invitations; } } catch {} } private async respondToInvitation(id: string, response: "accepted" | "declined") { try { await this.api(`/../invitations/${id}`, { method: "PATCH", body: JSON.stringify({ response }) }); } catch { // Fallback: use the public invitations endpoint with admin auth try { const base = this.apiBase().replace(/\/admin$/, ""); await fetch(`${base}/invitations/${id}`, { method: "PATCH", headers: this.authHeaders(), body: JSON.stringify({ response }), }); } catch {} } await this.loadInvitations(); this.info = `Invitation ${response}.`; this.render(); } private async shiftTimezone() { if (!this.settings) return; const newTz = this.browserTz; const relabel = confirm(`Change schedule timezone from ${this.settings.timezone} to ${newTz} and relabel existing bookings?`); try { await this.api("/timezone/shift", { method: "POST", body: JSON.stringify({ timezone: newTz, relabelBookings: relabel }), }); this.info = `Timezone changed to ${newTz}.`; await this.init(); } catch {} } // ── Settings ── private async saveSettings(patch: Partial) { this.busy = true; this.render(); try { const { settings } = await this.api<{ settings: Settings }>("/settings", { method: "PUT", body: JSON.stringify(patch), }); this.settings = settings; this.info = "Settings saved."; } catch {} this.busy = false; this.render(); } // ── Rules ── private async addRule(r: Omit) { this.busy = true; this.render(); try { await this.api("/availability", { method: "POST", body: JSON.stringify(r) }); await this.loadRules(); this.info = "Rule added."; } catch {} this.busy = false; this.render(); } private async updateRule(id: string, patch: Partial) { try { await this.api(`/availability/${id}`, { method: "PUT", body: JSON.stringify(patch) }); await this.loadRules(); } catch {} this.render(); } private async deleteRule(id: string) { try { await this.api(`/availability/${id}`, { method: "DELETE" }); await this.loadRules(); } catch {} this.render(); } // ── Overrides ── private async addOverride(o: Omit) { try { await this.api("/overrides", { method: "POST", body: JSON.stringify(o) }); await this.loadOverrides(); this.info = "Override added."; } catch {} this.render(); } private async deleteOverride(id: string) { try { await this.api(`/overrides/${id}`, { method: "DELETE" }); await this.loadOverrides(); } catch {} this.render(); } // ── Gcal ── private async syncGcal() { this.busy = true; this.render(); try { const r = await this.api<{ ok: boolean; eventsUpdated: number; deleted: number; error?: string }>("/google/sync", { method: "POST" }); this.info = r.ok ? `Synced. +${r.eventsUpdated} events, -${r.deleted} deleted.` : `Sync failed: ${r.error}`; await this.loadGcal(); } catch {} this.busy = false; this.render(); } private async disconnectGcal() { if (!confirm("Disconnect Google Calendar from this rSchedule? Tokens remain (use OAuth disconnect to fully revoke).")) return; try { await this.api("/google/disconnect", { method: "POST" }); await this.loadGcal(); this.info = "Disconnected."; } catch {} this.render(); } private render() { this.shadow.innerHTML = `

rSchedule admin · ${escapeHtml(this.space)}

Configure your bookable availability, Google Calendar sync, and booking policies.

${this.err ? `
${escapeHtml(this.err)}
` : ""} ${this.info ? `
${escapeHtml(this.info)}
` : ""} ${this.authOk ? ` ${this.renderTzBanner()}
${this.renderPanel()}
` : `
Sign in with a passkey to continue. Admin pages are restricted to moderators and admins of this space.
`}
`; this.wire(); } private renderPanel(): string { switch (this.tab) { case "overview": return this.renderOverview(); case "availability": return this.renderAvailability(); case "bookings": return this.renderBookings(); case "invitations": return this.renderInvitations(); case "calendar": return this.renderCalendar(); case "settings": return this.renderSettings(); } } private renderTzBanner(): string { if (!this.settings) return ""; if (this.browserTz && this.browserTz !== this.settings.timezone) { return `
Your browser is on ${escapeHtml(this.browserTz)} but this schedule is configured for ${escapeHtml(this.settings.timezone)}.
`; } return ""; } private renderInvitations(): string { return `

Bookings you're invited to

Meetings hosted by other spaces or users where ${escapeHtml(this.space)} is an attendee.

${this.invitations.length === 0 ? `

No invitations.

` : ` ${this.invitations.map(inv => ``).join("")}
WhenHostTitleStatusResponse
${escapeHtml(new Date(inv.startTime).toLocaleString())} ${escapeHtml(inv.host.label || inv.host.id)} ${escapeHtml(inv.title)} ${inv.status} ${inv.response} ${inv.response !== "accepted" ? `` : ""} ${inv.response !== "declined" ? `` : ""}
`} `; } private renderOverview(): string { const upcoming = this.bookings.filter((b) => b.status === "confirmed" && b.startTime > Date.now()).slice(0, 5); const base = this.apiBase().replace(/\/api\/admin$/, ""); return `

Share your booking link

Send this URL to anyone who wants to book time with you.

${location.origin}${base}

Next up

${upcoming.length === 0 ? `

No upcoming bookings.

` : `
    ${upcoming.map((b) => `
  • ${escapeHtml(b.guestName)} ${escapeHtml(new Date(b.startTime).toLocaleString())}
  • `).join("")}
`}

Google Calendar

${this.gcal?.connected ? `

Connected as ${escapeHtml(this.gcal.email || "(unknown)")}. Last sync: ${this.gcal.lastSyncAt ? new Date(this.gcal.lastSyncAt).toLocaleString() : "never"}.

` : `

Not connected. Bookings will be created without Google Calendar events.

`}

Availability

${this.rules.filter(r => r.isActive).length} active weekly rules, ${this.overrides.length} date overrides.

`; } private renderAvailability(): string { const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; return `

Weekly availability rules

Add one or more time windows per weekday. Leave a day empty to block it entirely.

${this.rules.length === 0 ? `` : this.rules .slice() .sort((a, b) => a.dayOfWeek - b.dayOfWeek || a.startTime.localeCompare(b.startTime)) .map(r => ``).join("")}
DayStartEndActive
No rules yet. Add one below.
${DOW[r.dayOfWeek]}

Date overrides

Block specific dates or set custom hours for a single day.

${this.overrides.length === 0 ? `` : this.overrides .slice() .sort((a, b) => a.date.localeCompare(b.date)) .map(o => ``).join("")}
DateBlocked?Custom windowReason
No overrides.
${o.date} ${o.isBlocked ? "✓ blocked" : "custom"} ${o.isBlocked ? "—" : `${o.startTime || "?"} – ${o.endTime || "?"}`} ${escapeHtml(o.reason || "")}
`; } private renderBookings(): string { return `

All bookings

${this.bookings.length === 0 ? `

No bookings yet.

` : ` ${this.bookings.map(b => ``).join("")}
WhenGuestEmailStatusAttendees
${escapeHtml(new Date(b.startTime).toLocaleString())} ${escapeHtml(b.guestName)} ${escapeHtml(b.guestEmail)} ${b.status} ${b.attendeeCount}
`} `; } private renderCalendar(): string { const path = window.location.pathname; const connectUrl = `/api/oauth/google/authorize?space=${encodeURIComponent(this.space)}&returnTo=rschedule%2Fadmin`; const base = this.apiBase().replace(/\/api\/admin$/, ""); return `

Google Calendar

${this.gcal?.connected ? `

Connected to Google as ${escapeHtml(this.gcal.email || "(unknown)")}.

Calendar ID: ${escapeHtml(this.gcal.calendarIds[0] || "primary")}

Last sync: ${this.gcal.lastSyncAt ? new Date(this.gcal.lastSyncAt).toLocaleString() : "never"} ${this.gcal.lastSyncStatus ? ` · ${this.gcal.lastSyncStatus}` : ""}

${this.gcal.lastSyncError ? `

${escapeHtml(this.gcal.lastSyncError)}

` : ""}

Tokens are shared across rSpace modules and live in the encrypted connections doc. Fully revoke the OAuth grant via /api/oauth/google/disconnect?space=${escapeHtml(this.space)}.

` : `

Connecting Google Calendar blocks bookings when you're busy on gcal and creates a calendar event for every confirmed booking.

Connect Google Calendar `} `; } private renderSettings(): string { const s = this.settings; if (!s) return `

Loading…

`; return `

Booking policies

`; } private tabBtn(id: Tab, label: string): string { return ``; } private wire() { const $ = (id: string) => this.shadow.getElementById(id); this.shadow.querySelectorAll("[data-tab]").forEach((el) => { el.addEventListener("click", () => { this.tab = (el.dataset.tab as Tab) || "overview"; this.err = ""; this.info = ""; this.render(); }); }); // Copy link $("copy-link")?.addEventListener("click", () => { const base = this.apiBase().replace(/\/api\/admin$/, ""); navigator.clipboard?.writeText(`${location.origin}${base}`); this.info = "Link copied."; this.render(); }); // Rule handlers this.shadow.querySelectorAll("tr[data-rule-id]").forEach((row) => { const id = row.dataset.ruleId!; const start = row.querySelector(".rule-start"); const end = row.querySelector(".rule-end"); const active = row.querySelector(".rule-active"); start?.addEventListener("change", () => void this.updateRule(id, { startTime: start.value })); end?.addEventListener("change", () => void this.updateRule(id, { endTime: end.value })); active?.addEventListener("change", () => void this.updateRule(id, { isActive: active.checked })); row.querySelector(".rule-del")?.addEventListener("click", () => void this.deleteRule(id)); }); (this.shadow.getElementById("add-rule") as HTMLFormElement | null)?.addEventListener("submit", (e) => { e.preventDefault(); const dow = Number(($("nr-dow") as HTMLSelectElement).value); const startTime = ($("nr-start") as HTMLInputElement).value; const endTime = ($("nr-end") as HTMLInputElement).value; void this.addRule({ dayOfWeek: dow, startTime, endTime, isActive: true }); }); // Override handlers this.shadow.querySelectorAll("tr[data-ov-id]").forEach((row) => { const id = row.dataset.ovId!; row.querySelector(".ov-del")?.addEventListener("click", () => void this.deleteOverride(id)); }); (this.shadow.getElementById("add-override") as HTMLFormElement | null)?.addEventListener("submit", (e) => { e.preventDefault(); const date = ($("no-date") as HTMLInputElement).value; const blocked = ($("no-blocked") as HTMLInputElement).checked; const start = ($("no-start") as HTMLInputElement).value || null; const end = ($("no-end") as HTMLInputElement).value || null; const reason = ($("no-reason") as HTMLInputElement).value; if (!date) return; void this.addOverride({ date, isBlocked: blocked, startTime: start, endTime: end, reason }); }); // Gcal $("sync-gcal")?.addEventListener("click", () => void this.syncGcal()); $("disconnect-gcal")?.addEventListener("click", () => void this.disconnectGcal()); // Timezone shift $("tz-shift")?.addEventListener("click", () => void this.shiftTimezone()); // Invitations this.shadow.querySelectorAll("tr[data-inv-id]").forEach((row) => { const id = row.dataset.invId!; row.querySelector(".inv-accept")?.addEventListener("click", () => void this.respondToInvitation(id, "accepted")); row.querySelector(".inv-decline")?.addEventListener("click", () => void this.respondToInvitation(id, "declined")); }); // Settings form (this.shadow.getElementById("settings-form") as HTMLFormElement | null)?.addEventListener("submit", (e) => { e.preventDefault(); const form = e.target as HTMLFormElement; const fd = new FormData(form); const patch: Partial = { displayName: String(fd.get("displayName") || ""), email: String(fd.get("email") || ""), bookingMessage: String(fd.get("bookingMessage") || ""), slotDurationMin: Number(fd.get("slotDurationMin")), timezone: String(fd.get("timezone") || "UTC"), bufferBeforeMin: Number(fd.get("bufferBeforeMin")), bufferAfterMin: Number(fd.get("bufferAfterMin")), minNoticeHours: Number(fd.get("minNoticeHours")), maxAdvanceDays: Number(fd.get("maxAdvanceDays")), }; void this.saveSettings(patch); }); } } function escapeHtml(s: string): string { return String(s ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } const STYLES = ` :host { display:block; color:#e2e8f0; font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background:#0b1120; min-height:100vh; } .wrap { max-width:1100px; margin:0 auto; padding:32px 24px; } header h1 { margin:0 0 4px; font-size:1.5rem; } .sub { color:#94a3b8; margin:0 0 20px; font-size:0.9rem; } .err, .err-inline { padding:12px 14px; border:1px solid rgba(239,68,68,0.3); background:rgba(239,68,68,0.08); border-radius:8px; color:#fca5a5; margin-bottom:14px; font-size:0.9rem; } .info { padding:10px 14px; border:1px solid rgba(34,197,94,0.3); background:rgba(34,197,94,0.08); border-radius:8px; color:#86efac; margin-bottom:14px; font-size:0.9rem; } .unauth { padding:40px; text-align:center; border:1px dashed rgba(148,163,184,0.3); border-radius:10px; color:#94a3b8; margin-top:20px; } .tabs { display:flex; gap:4px; border-bottom:1px solid rgba(148,163,184,0.12); margin-bottom:20px; overflow-x:auto; } .tab { background:transparent; color:#94a3b8; border:0; padding:10px 16px; cursor:pointer; font-size:0.9rem; border-bottom:2px solid transparent; white-space:nowrap; } .tab.active { color:#06b6d4; border-bottom-color:#06b6d4; } .panel { background:#111827; border:1px solid rgba(148,163,184,0.12); border-radius:12px; padding:24px; } .panel h2 { margin:0 0 8px; font-size:1.05rem; color:#e2e8f0; } .muted { color:#94a3b8; font-size:0.9rem; } .small { font-size:0.8rem; } .link { display:inline-block; background:rgba(6,182,212,0.08); padding:6px 10px; border-radius:6px; margin:6px 8px 6px 0; font-family:ui-monospace, monospace; color:#5eead4; } .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:16px; } @media (max-width:700px) { .grid2 { grid-template-columns:1fr; } } .card { background:#0b1120; border:1px solid rgba(148,163,184,0.12); border-radius:10px; padding:20px; } .card h2 { margin:0 0 8px; font-size:0.95rem; } .list { list-style:none; padding:0; margin:0; } .list li { display:flex; justify-content:space-between; padding:6px 0; border-bottom:1px solid rgba(148,163,184,0.08); font-size:0.9rem; } .list li:last-child { border-bottom:0; } .primary { background:linear-gradient(to right,#06b6d4,#8b5cf6); color:#fff; border:0; padding:9px 16px; border-radius:7px; cursor:pointer; font-weight:600; text-decoration:none; display:inline-block; font-size:0.9rem; } .primary:disabled { opacity:0.6; cursor:default; } .secondary { background:transparent; color:#06b6d4; border:1px solid rgba(6,182,212,0.4); padding:8px 14px; border-radius:7px; cursor:pointer; font-size:0.9rem; } .secondary:hover { background:rgba(6,182,212,0.08); } .danger { background:transparent; color:#fca5a5; border:1px solid rgba(239,68,68,0.3); padding:4px 10px; border-radius:6px; cursor:pointer; font-size:0.82rem; } .danger:hover { background:rgba(239,68,68,0.1); } .row-gap { display:flex; gap:10px; flex-wrap:wrap; margin-top:12px; } .tbl { width:100%; border-collapse:collapse; margin-top:12px; font-size:0.9rem; } .tbl th, .tbl td { text-align:left; padding:8px 10px; border-bottom:1px solid rgba(148,163,184,0.1); } .tbl th { color:#64748b; font-weight:500; font-size:0.8rem; } .tbl input[type="time"], .tbl input[type="text"] { background:#0b1120; color:#e2e8f0; border:1px solid rgba(148,163,184,0.2); padding:4px 8px; border-radius:5px; } .add-form { display:flex; gap:8px; margin-top:16px; flex-wrap:wrap; align-items:center; } .add-form input, .add-form select { background:#0b1120; color:#e2e8f0; border:1px solid rgba(148,163,184,0.2); padding:6px 10px; border-radius:6px; font-size:0.9rem; } .add-form .inline { display:flex; align-items:center; gap:6px; color:#cbd5e1; font-size:0.9rem; } .settings-form { display:grid; grid-template-columns:1fr 1fr; gap:14px; max-width:800px; } .settings-form label { display:flex; flex-direction:column; gap:4px; color:#cbd5e1; font-size:0.85rem; } .settings-form label.full { grid-column:1 / -1; } .settings-form input, .settings-form textarea { background:#0b1120; color:#e2e8f0; border:1px solid rgba(148,163,184,0.2); padding:8px 10px; border-radius:6px; font-size:0.9rem; font-family:inherit; } .settings-form button[type="submit"] { grid-column:1 / -1; justify-self:start; } .badge { padding:2px 8px; border-radius:99px; font-size:0.75rem; font-weight:500; } .badge-confirmed, .badge-accepted, .badge-ok { background:rgba(34,197,94,0.15); color:#86efac; } .badge-cancelled, .badge-declined, .badge-error { background:rgba(239,68,68,0.15); color:#fca5a5; } .badge-completed { background:rgba(148,163,184,0.15); color:#cbd5e1; } .badge-invited { background:rgba(6,182,212,0.15); color:#5eead4; } .tz-banner { background:rgba(245,158,11,0.08); border:1px solid rgba(245,158,11,0.3); color:#fcd34d; padding:12px 16px; border-radius:8px; margin-bottom:14px; font-size:0.9rem; display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; } .small { font-size:0.82rem; padding:5px 10px; } code { background:rgba(148,163,184,0.1); padding:2px 6px; border-radius:4px; font-family:ui-monospace,monospace; font-size:0.85rem; color:#e2e8f0; } `; customElements.define("folk-schedule-admin", FolkScheduleAdmin); export {};