calendar/frontend/src/pages/CalDavSetup.tsx
jtricerolph ca0dc9b070 Add recurring events, push notifications, config sync — and mobile layout fixes
Recurring event editing (rrule) with this/all occurrence scope, web push
subscriptions (VAPID) with a cached config layer for notification settings,
and email delivery via nodemailer.

Also fixes the calendar view never actually stacking on mobile: the
Calendars filter column used flex:1 with minWidth:0 on its sibling, so
flex-wrap never triggered regardless of viewport width, squeezing the grid
and view switcher into a sliver next to a fixed 220px sidebar. Adds a
proper mobile breakpoint that stacks the layout, scrolls the week grid
horizontally instead of compressing it, and enlarges touch targets.
2026-07-25 23:04:59 +00:00

281 lines
11 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react'
import { Bell, BellOff, Copy, Plus, Smartphone, Trash2, TriangleAlert } from 'lucide-react'
import type { CaldavCredential, CaldavCredentialCreated } from '../types'
import { fetchCaldavCredentials, createCaldavCredential, deleteCaldavCredential, fetchVapidKey, subscribePush, unsubscribePush } from '../api'
function urlBase64ToUint8Array(base64: string): ArrayBuffer {
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(b64)
const buf = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i)
return buf.buffer
}
type PushState = 'unsupported' | 'denied' | 'off' | 'on' | 'checking'
export default function CalDavSetup() {
const [credentials, setCredentials] = useState<CaldavCredential[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [label, setLabel] = useState('')
const [creating, setCreating] = useState(false)
const [revealed, setRevealed] = useState<CaldavCredentialCreated | null>(null)
const [pushState, setPushState] = useState<PushState>('checking')
const [pushBusy, setPushBusy] = useState(false)
const [pushError, setPushError] = useState<string | null>(null)
const refreshPushState = useCallback(async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
setPushState('unsupported')
return
}
if (Notification.permission === 'denied') {
setPushState('denied')
return
}
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
setPushState(sub ? 'on' : 'off')
} catch {
setPushState('off')
}
}, [])
useEffect(() => { refreshPushState() }, [refreshPushState])
async function enablePush() {
setPushBusy(true)
setPushError(null)
try {
const reg = await navigator.serviceWorker.ready
if (Notification.permission === 'default') {
const permission = await Notification.requestPermission()
if (permission !== 'granted') { setPushState('denied'); return }
} else if (Notification.permission === 'denied') {
setPushState('denied')
return
}
const { publicKey } = await fetchVapidKey()
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
await subscribePush(sub.toJSON())
setPushState('on')
} catch (err) {
setPushError(err instanceof Error ? err.message : 'Failed to enable push notifications')
} finally {
setPushBusy(false)
}
}
async function disablePush() {
setPushBusy(true)
setPushError(null)
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
if (sub) {
await unsubscribePush(sub.endpoint)
await sub.unsubscribe()
}
setPushState('off')
} catch (err) {
setPushError(err instanceof Error ? err.message : 'Failed to disable push notifications')
} finally {
setPushBusy(false)
}
}
const caldavUrl = `${window.location.origin}/calendar/caldav/`
const reload = useCallback(() => {
setLoading(true)
setError(null)
fetchCaldavCredentials().then(setCredentials).catch(err => setError(err.message)).finally(() => setLoading(false))
}, [])
useEffect(() => { reload() }, [reload])
async function handleCreate() {
setCreating(true)
setError(null)
try {
const created = await createCaldavCredential(label.trim() || undefined)
setRevealed(created)
setLabel('')
reload()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create credential')
} finally {
setCreating(false)
}
}
async function handleRevoke(id: number) {
if (!confirm('Revoke this device? It will stop syncing immediately.')) return
try {
await deleteCaldavCredential(id)
if (revealed?.id === id) setRevealed(null)
reload()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to revoke credential')
}
}
function copy(text: string) {
navigator.clipboard?.writeText(text).catch(() => {})
}
return (
<div className="page" style={{ maxWidth: 760 }}>
<div className="page-header">
<h1>Phone Sync</h1>
</div>
<div className="card">
<p style={{ marginTop: 0 }}>
Subscribe to this calendar from your phone or computer's own calendar app (Apple Calendar,
Google Calendar, Outlook, …) using <strong>CalDAV</strong>. Once set up, events created here
show up on your device automatically, and new device-created events sync back — no separate app
needed.
</p>
<p style={{ marginBottom: 0 }}>
Each device needs its own generated username and password below — never share your normal
hotel login for this.
</p>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="section-title">Push notifications</div>
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{pushState === 'on' ? (
<Bell size={18} strokeWidth={1.75} color="var(--gold)" />
) : (
<BellOff size={18} strokeWidth={1.75} style={{ color: 'var(--text-mid)' }} />
)}
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>
{pushState === 'unsupported' && 'Not supported on this device/browser'}
{pushState === 'denied' && 'Blocked enable notifications for this site in your browser settings'}
{pushState === 'checking' && 'Checking status'}
{pushState === 'off' && 'Notifications are off on this device'}
{pushState === 'on' && 'Notifications are on for this device'}
</div>
{pushError && <div className="field-hint" style={{ color: 'var(--danger)' }}>{pushError}</div>}
</div>
{(pushState === 'off' || pushState === 'on') && (
<button
className={`btn btn-sm ${pushState === 'off' ? 'btn-primary' : ''}`}
onClick={pushState === 'off' ? enablePush : disablePush}
disabled={pushBusy}
>
{pushBusy ? 'Working' : pushState === 'off' ? 'Enable' : 'Disable'}
</button>
)}
</div>
<div className="section-title">Your CalDAV devices</div>
{loading ? (
<div className="empty-state">Loading…</div>
) : credentials.length === 0 ? (
<div className="empty-state">No devices set up yet.</div>
) : (
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>Label</th>
<th>Username</th>
<th>Created</th>
<th>Last used</th>
<th></th>
</tr>
</thead>
<tbody>
{credentials.map(c => (
<tr key={c.id}>
<td>{c.label || ''}</td>
<td>{c.username}</td>
<td>{new Date(c.created_at).toLocaleDateString()}</td>
<td>{c.last_used_at ? new Date(c.last_used_at).toLocaleString() : 'Never'}</td>
<td style={{ textAlign: 'right' }}>
<button className="btn-ghost-sm btn-ghost-danger" onClick={() => handleRevoke(c.id)}>
<Trash2 size={13} strokeWidth={1.75} /> Revoke
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="field-row" style={{ alignItems: 'flex-end', marginTop: 12 }}>
<div className="field" style={{ flex: 1 }}>
<label>Device label (optional)</label>
<input type="text" value={label} onChange={e => setLabel(e.target.value)} placeholder="e.g. Sarah's iPhone" />
</div>
<button className="btn btn-primary" onClick={handleCreate} disabled={creating} style={{ marginBottom: 12 }}>
<Plus size={14} strokeWidth={1.75} />
{creating ? 'Generating…' : 'Generate new'}
</button>
</div>
{revealed && (
<div className="card" style={{ borderColor: 'var(--gold)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--danger)', fontWeight: 600, marginBottom: 8 }}>
<TriangleAlert size={16} strokeWidth={1.75} />
Save this now — the password won't be shown again.
</div>
<div className="cal-credential-box">
<div>Username: {revealed.username} <Copy size={13} strokeWidth={1.75} style={{ cursor: 'pointer', verticalAlign: 'middle' }} onClick={() => copy(revealed.username)} /></div>
<div>Password: {revealed.password} <Copy size={13} strokeWidth={1.75} style={{ cursor: 'pointer', verticalAlign: 'middle' }} onClick={() => copy(revealed.password)} /></div>
<div>Server URL: {caldavUrl} <Copy size={13} strokeWidth={1.75} style={{ cursor: 'pointer', verticalAlign: 'middle' }} onClick={() => copy(caldavUrl)} /></div>
</div>
<button className="btn btn-sm" onClick={() => setRevealed(null)}>I've saved it, hide this</button>
</div>
)}
<div className="section-title">Set-up instructions</div>
<div className="card">
<div style={{ fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
<Smartphone size={15} strokeWidth={1.75} /> Apple Calendar (iPhone / Mac)
</div>
<ol style={{ marginTop: 0, paddingLeft: 20 }}>
<li>Settings → Calendar → Accounts → Add Account → Other → Add CalDAV Account.</li>
<li>Server: <code>{caldavUrl}</code></li>
<li>User Name / Password: the credentials generated above.</li>
<li>Tap Next, then Save.</li>
</ol>
</div>
<div className="card">
<div style={{ fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
<Smartphone size={15} strokeWidth={1.75} /> Google Calendar
</div>
<ol style={{ marginTop: 0, paddingLeft: 20 }}>
<li>Google Calendar doesn't support direct CalDAV subscriptions on the free tier — easiest is to
use a CalDAV-sync app such as "CalDAV-Sync" (Android) with the server URL and credentials above.</li>
<li>Alternatively, on desktop, add it as a "secondary" calendar in a CalDAV-aware client and it
will appear alongside Google Calendar.</li>
</ol>
</div>
<div className="card">
<div style={{ fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
<Smartphone size={15} strokeWidth={1.75} /> Outlook
</div>
<ol style={{ marginTop: 0, paddingLeft: 20 }}>
<li>Outlook (desktop): File Account Settings Internet Calendars New, then paste <code>{caldavUrl}</code>.</li>
<li>When prompted, enter the username and password generated above.</li>
</ol>
</div>
</div>
)
}