Skip to content
Merged
41 changes: 41 additions & 0 deletions docs/ZOOM.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Zoom integration

One Zoom OAuth app per Calnode instance (entered in **Settings → Zoom**), then each
host connects their **own** Zoom account through that app from the **Calendar** page.
Bookings with a Zoom location get a real meeting link minted under the assigned host's
account. The settings page shows the exact Redirect URL and required scope
(`meeting:write`); the app can stay unpublished for a single-account team.

## Multi-member teams: read this before inviting people

Zoom — not Calnode — decides who may authorize your app. An **unpublished** app can
only be authorized by users on the **same Zoom account** as the app owner. A member on
a different Zoom account clicks Connect and Zoom itself refuses; no callback, token,
or error ever reaches Calnode, so there is nothing to retry or work around in settings.

Your options, ranked honestly:

1. **Use the video already in the box.** Built-in LiveKit needs no per-user OAuth, no
Zoom app, and no publication — see [VIDEO.md](VIDEO.md). For most teams hitting this
wall, this is the answer: it converts "Zoom won't let us" into "we don't need Zoom".
2. **Work within Zoom's distribution rules.** Same-account joining, beta sharing, or
publishing — with their real costs, spelled out in [DEPLOY.md](../DEPLOY.md) under
"Zoom meeting links". That section is the reference for the Zoom-side mechanics;
this file's recommendation is step 1 above.

What does **not** work: re-entering credentials, reinstalling, different hosting
(Docker vs Railway vs Render all behave identically), or having the member confirm
they own an active Zoom account. The refusal happens on Zoom's consent page under all
of them.

## Note for contributors

Per-member BYO Zoom apps (each host registers their own app) would technically work —
a member always authorizes their own app — and a PR doing it would be welcome if built
within these bounds: the instance app stays the default and fallback; per-user
credentials live entirely inside `internal/zoom` (storage + client routing) with no
changes to booking or meeting-mint flows; secrets use the existing envelope encryption.
That said, it is explicitly **not on the maintainers' roadmap**: it is a big chunk of
work that asks the least technical users to do the most technical thing (register a
Zoom Marketplace app each), while LiveKit already covers the need with zero
configuration. If you build it, build it within the bounds above.
2 changes: 2 additions & 0 deletions frontend/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
const isPublicRoute = $derived(
$page.route.id === '/login' ||
$page.route.id === '/claim' ||
$page.route.id === '/forgot-password' ||
$page.route.id === '/reset-password' ||
$page.route.id === '/invite/[token]'
);

Expand Down
28 changes: 20 additions & 8 deletions frontend/src/routes/calendar/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,23 @@
}
}

// Exclusive within this account's list. The backend clears the flag across every other
// Exclusive within this account's list, but clearable: selecting the current
// destination again removes it (plain radios can't express "none", and the
// backend accepts a save with no destination — writes then fall back to the
// account's bound calendar). The backend clears the flag across every other
// account when it saves, so the UI only has to keep this list consistent.
function pickDestination(chosen: CalendarPick) {
pickerCals = pickerCals.map((c) => ({ ...c, is_destination: c.id === chosen.id }));
}

function toggleDestination(chosen: CalendarPick) {
if (chosen.is_destination) {
pickerCals = pickerCals.map((c) => ({ ...c, is_destination: false }));
} else {
pickDestination(chosen);
}
}

async function savePicker(c: CalendarConnection) {
pickerSaving = true;
pickerErr = '';
Expand Down Expand Up @@ -319,12 +330,12 @@
{:else}
<p class="text-xs text-muted-foreground">
Tick the calendars to check for conflicts, and choose the one bookings are
written into.
written into (select it again to clear the choice).
</p>
<div class="space-y-1.5">
<div class="flex items-center gap-2 pb-1 text-xs font-medium text-muted-foreground">
<span class="w-10 shrink-0 text-center">Check</span>
<span class="w-10 shrink-0 text-center">Book</span>
<span class="w-20 shrink-0 text-center">Destination</span>
<span>Calendar</span>
</div>
{#each pickerCals as cal (cal.id)}
Expand All @@ -333,13 +344,14 @@
<input type="checkbox" bind:checked={cal.check_conflicts} disabled={pickerSaving}
aria-label="Check {cal.name} for conflicts" />
</span>
<span class="w-10 shrink-0 text-center">
<span class="w-20 shrink-0 text-center">
<!-- One destination per account in the UI; the API enforces one per user
across all accounts and moves the account-level destination to match. -->
<input type="radio" name="subcal-destination" disabled={pickerSaving || !cal.writable}
across all accounts and moves the account-level destination to match.
A checkbox (not a radio) so the choice can be cleared again. -->
<input type="checkbox" disabled={pickerSaving || !cal.writable}
checked={cal.is_destination}
onchange={() => pickDestination(cal)}
aria-label="Write bookings into {cal.name}" />
onchange={() => toggleDestination(cal)}
aria-label="Write bookings into {cal.name} (select again to clear)" />
</span>
<span class="truncate">
{cal.name}
Expand Down
85 changes: 85 additions & 0 deletions frontend/src/routes/forgot-password/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';

let email = $state('');
let submitting = $state(false);
let message = $state('');
let error = $state('');

const isValidEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);

async function submit(e: SubmitEvent) {
e.preventDefault();
const addr = email.trim().toLowerCase();
// Require a valid address up front — otherwise the request silently no-ops
// (the endpoint returns the same generic message), which looks broken.
if (!isValidEmail(addr)) {
error = 'Enter a valid email address first.';
return;
}
error = '';
submitting = true;
try {
const res = await fetch('/v1/auth/password/forgot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: addr })
});
const data = await res.json().catch(() => ({}));
message =
data.message ||
'If an account with that email exists, a password-reset link is on its way.';
} catch {
message = 'If an account with that email exists, a password-reset link is on its way.';
} finally {
submitting = false;
}
}
</script>

<svelte:head><title>Forgot password — Calnode</title></svelte:head>

<div class="flex min-h-screen items-center justify-center bg-muted/30 p-6">
<div class="w-full max-w-sm">
<div class="mb-8 text-center">
<h1 class="text-xl font-semibold tracking-tight">Forgot your password?</h1>
<p class="mt-2 text-sm text-muted-foreground">
Enter your account email and we'll send you a link to set a new password.
</p>
</div>

{#if message}
<div class="rounded-md bg-green-50 px-3 py-2.5 text-sm text-green-700">{message}</div>
<p class="mt-4 text-center text-sm">
<a href="/admin/login" class="text-muted-foreground hover:underline">Back to sign in</a>
</p>
{:else}
<form onsubmit={submit} class="space-y-4">
<div class="space-y-1.5">
<Label for="email">Email</Label>
<Input
id="email"
type="email"
autocomplete="email"
placeholder="you@example.com"
bind:value={email}
oninput={() => (error = '')}
aria-invalid={error ? 'true' : undefined}
required
/>
{#if error}
<p class="text-xs text-destructive">{error}</p>
{/if}
</div>
<Button type="submit" class="h-11 w-full" disabled={submitting}>
{submitting ? 'Sending…' : 'Send reset link'}
</Button>
</form>
<p class="mt-4 text-center text-sm">
<a href="/admin/login" class="text-muted-foreground hover:underline">Back to sign in</a>
</p>
{/if}
</div>
</div>
101 changes: 101 additions & 0 deletions frontend/src/routes/reset-password/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';

let token = $state('');
let password = $state('');
let confirm = $state('');
let submitting = $state(false);
let error = $state('');

onMount(() => {
token = $page.url.searchParams.get('token') ?? '';
if (!token) {
error = 'This reset link is missing its token. Request a new one below.';
}
});

async function submit(e: SubmitEvent) {
e.preventDefault();
if (password.length < 8) {
error = 'Password must be at least 8 characters.';
return;
}
if (password !== confirm) {
error = 'The two passwords do not match.';
return;
}
error = '';
submitting = true;
try {
const res = await fetch('/v1/auth/password/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, new_password: password })
});
if (res.ok) {
// The reset signs the user straight in.
window.location.href = '/admin';
} else {
const data = await res.json().catch(() => ({}));
error = data.error || 'Could not reset your password. The link may have expired.';
}
} catch {
error = 'Could not reset your password. Please try again.';
} finally {
submitting = false;
}
}
</script>

<svelte:head><title>Set a new password — Calnode</title></svelte:head>

<div class="flex min-h-screen items-center justify-center bg-muted/30 p-6">
<div class="w-full max-w-sm">
<div class="mb-8 text-center">
<h1 class="text-xl font-semibold tracking-tight">Set a new password</h1>
<p class="mt-2 text-sm text-muted-foreground">Choose a password of at least 8 characters.</p>
</div>

{#if error && !token}
<div class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</div>
<p class="mt-4 text-center text-sm">
<a href="/admin/forgot-password" class="text-muted-foreground hover:underline"
>Request a new reset link</a
>
</p>
{:else}
<form onsubmit={submit} class="space-y-4">
<div class="space-y-1.5">
<Label for="password">New password</Label>
<Input
id="password"
type="password"
autocomplete="new-password"
bind:value={password}
required
/>
</div>
<div class="space-y-1.5">
<Label for="confirm">Confirm new password</Label>
<Input
id="confirm"
type="password"
autocomplete="new-password"
bind:value={confirm}
required
/>
</div>
{#if error}
<div class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</div>
{/if}
<Button type="submit" class="h-11 w-full" disabled={submitting || !token}>
{submitting ? 'Setting…' : 'Set new password'}
</Button>
</form>
{/if}
</div>
</div>
10 changes: 9 additions & 1 deletion frontend/src/routes/settings/zoom/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@
Choose a <span class="font-medium">General App</span> (User-managed OAuth). While it is
unpublished, Zoom only lets users <span class="font-medium">inside the Zoom account that owns
the app</span> connect; hosts with their own separate Zoom accounts need a published app
(see DEPLOY.md, "Zoom meeting links").
(see DEPLOY.md, "Zoom meeting links"). If that rules Zoom out, built-in video needs no
Zoom app at all — see <a href="https://github.com/Calnode/calnode/blob/main/docs/VIDEO.md" target="_blank" rel="noopener noreferrer" class="font-medium text-primary underline">docs/VIDEO.md</a>.
</div>
</li>
<li class="flex gap-3">
Expand Down Expand Up @@ -132,6 +133,13 @@
</p>
<code class="mt-2 block rounded bg-muted px-2 py-1 text-xs font-mono break-all">{redirectURI}</code>
</div>
<div class="mt-4 rounded-md bg-amber-50 px-3 py-2.5 text-xs text-amber-800 ring-1 ring-inset ring-amber-200">
<span class="font-medium">Multi-member limit:</span> only Zoom users on the same Zoom
account as this app's owner can connect an unpublished app — members elsewhere are
refused by Zoom before Calnode is involved. Options:
<a href="https://github.com/Calnode/calnode/blob/main/docs/ZOOM.md" target="_blank" rel="noopener noreferrer" class="font-medium underline">docs/ZOOM.md</a>
(built-in video needs no Zoom app at all).
</div>
{/if}

<div class="mt-5">
Expand Down
20 changes: 11 additions & 9 deletions internal/caldav/caldav.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,9 @@ func (c *Client) conflictConns(ctx context.Context, userID string) ([]conn, erro
return nil, err
}
// Resolve selection + decrypt after the cursor is closed (single-connection DB pool; the
// ConflictCalendarIDs query would deadlock against an open cursor). CalDAV binds one
// calendar per connection, so the sub-calendar picker is simply an on/off toggle: an empty
// result means the user deselected it.
// ConflictCalendarIDs query would deadlock against an open cursor). One connection row
// can bind several selected collections (the picker), so every selected URL gets its
// own REPORT below; an empty result means the user deselected the whole account.
var conns []conn
for _, d := range data {
calIDs, err := calendar.ConflictCalendarIDs(ctx, c.db, "caldav", userID, d.username, d.calURL)
Expand All @@ -225,7 +225,9 @@ func (c *Client) conflictConns(ctx context.Context, userID string) ([]conn, erro
c.logger.Warn("caldav: skipping connection with bad credentials", "user_id", userID, "error", err)
continue
}
conns = append(conns, conn{id: d.id, username: d.username, password: string(pw), calURL: d.calURL})
for _, calURL := range calIDs {
conns = append(conns, conn{id: d.id, username: d.username, password: string(pw), calURL: calURL})
}
}
return conns, nil
}
Expand All @@ -234,8 +236,8 @@ func (c *Client) conflictConns(ctx context.Context, userID string) ([]conn, erro
// a user can connect several. On a new connection: check_conflicts=1, and it becomes the
// destination only if the user has none yet. On a re-connect (row already exists for this
// account): the existing check_conflicts/is_destination flags are preserved and only the
// password + calendar URL are refreshed. Mirrors gcal.saveToken.
func (c *Client) saveConnection(ctx context.Context, userID, accountEmail, password, calURL string) error {
// password + calendar URL (+ home URL) are refreshed. Mirrors gcal.saveToken.
func (c *Client) saveConnection(ctx context.Context, userID, accountEmail, password, calURL, homeURL string) error {
pwEnc, err := c.encrypt([]byte(password))
if err != nil {
return err
Expand All @@ -261,9 +263,9 @@ func (c *Client) saveConnection(ctx context.Context, userID, accountEmail, passw
if _, err := tx.ExecContext(ctx, `
INSERT INTO calendar_connections
(id, user_id, provider, account_email, access_token_enc, calendar_id,
check_conflicts, is_destination, created_at)
VALUES (?, ?, 'caldav', ?, ?, ?, ?, ?, ?)`,
uid.New(), userID, accountEmail, pwEnc, calURL, checkConflicts, isDest, now); err != nil {
check_conflicts, is_destination, caldav_home_url, created_at)
VALUES (?, ?, 'caldav', ?, ?, ?, ?, ?, ?, ?)`,
uid.New(), userID, accountEmail, pwEnc, calURL, checkConflicts, isDest, homeURL, now); err != nil {
return fmt.Errorf("caldav: save insert: %w", err)
}
return tx.Commit()
Expand Down
Loading
Loading