wannpassts/frontend/src/components/SectionConnections.vue
Tronax cb30223fd4
feat: add WannPassts booking app with Go backend and Vue frontend
Initial project setup for a privacy-focused, self-hosted scheduling app:

- Go backend with JWT auth, SQLite storage, and chi router
- Calendar integrations via Google OAuth (FreeBusy), CalDAV, and ICS links
- Public booking pages with configurable slots and booking requests
- AES-256-GCM encryption for stored provider tokens
- Vue 3 + TypeScript frontend with Vite, Pinia, and Vue Router
- Docs, .gitignore, and .env.example for local development
2026-08-25 18:50:28 +02:00

239 lines
8.4 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { ApiError, api } from '../lib/api'
import type { Connection } from '../lib/types'
import { useToast } from '../stores/toast'
import GlassCard from './GlassCard.vue'
const toast = useToast()
const connections = ref<Connection[] | null>(null)
const syncing = ref(false)
const busy = ref(false)
const showCalDAV = ref(false)
const showICS = ref(false)
const caldav = ref({ server_url: 'https://caldav.icloud.com', username: '', password: '', calendar_path: '', display_name: '' })
const ics = ref({ url: '', display_name: '' })
let pollTimer: ReturnType<typeof setTimeout> | null = null
const providerLabel: Record<Connection['provider'], string> = {
google: 'Google',
caldav: 'CalDAV / iCloud',
ics: 'ICS-Abo',
}
const providerIcon: Record<Connection['provider'], string> = {
google: '🇬',
caldav: '☁️',
ics: '📡',
}
async function load() {
try {
connections.value = await api.connections()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Laden fehlgeschlagen')
}
}
function schedulePoll() {
pollTimer = setTimeout(load, 3500)
}
onMounted(() => {
load()
schedulePoll()
})
onUnmounted(() => {
if (pollTimer) clearTimeout(pollTimer)
})
async function syncNow() {
syncing.value = true
try {
connections.value = await api.syncNow()
toast.ok('Kalender synchronisiert')
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Sync fehlgeschlagen')
} finally {
syncing.value = false
}
}
async function connectGoogle() {
try {
const { url } = await api.googleStart()
location.href = url
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Google-Start fehlgeschlagen')
}
}
async function submitCalDAV() {
busy.value = true
try {
await api.connectCalDAV({
server_url: caldav.value.server_url,
username: caldav.value.username,
password: caldav.value.password,
calendar_path: caldav.value.calendar_path || undefined,
display_name: caldav.value.display_name || undefined,
})
toast.ok('Kalender verbunden erste Synchronisierung läuft.')
showCalDAV.value = false
caldav.value.username = ''
caldav.value.password = ''
await load()
schedulePoll()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Verbinden fehlgeschlagen')
} finally {
busy.value = false
}
}
async function submitICS() {
busy.value = true
try {
await api.connectICS({ url: ics.value.url, display_name: ics.value.display_name || undefined })
toast.ok('ICS-Kalender verbunden.')
showICS.value = false
ics.value.url = ''
ics.value.display_name = ''
await load()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Verbinden fehlgeschlagen')
} finally {
busy.value = false
}
}
async function remove(conn: Connection) {
if (!confirm(`${conn.display_name}“ wirklich entfernen?`)) return
try {
await api.deleteConnection(conn.id)
connections.value = (connections.value ?? []).filter((c) => c.id !== conn.id)
toast.ok('Verbindung entfernt')
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Löschen fehlgeschlagen')
}
}
function syncedAgo(ts: number | null): string {
if (!ts) return 'wird synchronisiert…'
const s = Math.max(0, Math.round(Date.now() / 1000 - ts))
if (s < 60) return `vor ${s}s`
if (s < 3600) return `vor ${Math.round(s / 60)} Min.`
return `vor ${Math.round(s / 3600)} Std.`
}
</script>
<template>
<div class="col">
<GlassCard title="Kalender hinzufügen" subtitle="WannPassts liest nur frei/belegt nie Termindetails.">
<div class="row add-row">
<button class="btn" @click="connectGoogle">🇬 Google Kalender</button>
<button class="btn" @click="showCalDAV = !showCalDAV; showICS = false"> iCloud / CalDAV</button>
<button class="btn" @click="showICS = !showICS; showCalDAV = false">📡 ICS-Link</button>
</div>
<transition name="fade">
<form v-if="showCalDAV" class="glass-soft form" @submit.prevent="submitCalDAV">
<p class="muted small" style="margin: 0 0 4px">
Für iCloud: <strong>Apple-ID</strong> als Benutzername und ein
<a href="https://appleid.apple.com/account/manage" target="_blank" rel="noopener">App-spezifisches Passwort</a>
(Konto → Anmeldung und Sicherheit). Auch Fastmail, Nextcloud &amp; Co. funktionieren.
</p>
<div class="form-grid">
<label class="field">
<span>Server</span>
<input v-model="caldav.server_url" required class="input" />
</label>
<label class="field">
<span>Benutzername</span>
<input v-model="caldav.username" required class="input" autocomplete="username" />
</label>
<label class="field">
<span>Passwort (App-spezifisch)</span>
<input v-model="caldav.password" type="password" required class="input" autocomplete="current-password" />
</label>
<label class="field">
<span>Kalender-Pfad (optional)</span>
<input v-model="caldav.calendar_path" class="input" placeholder="wird automatisch erkannt" />
</label>
<label class="field">
<span>Anzeigename (optional)</span>
<input v-model="caldav.display_name" class="input" placeholder="z. B. Privatkalender" />
</label>
</div>
<button class="btn btn-primary" type="submit" :disabled="busy">
{{ busy ? 'Verbinde…' : 'Verbinden' }}
</button>
</form>
</transition>
<transition name="fade">
<form v-if="showICS" class="glass-soft form" @submit.prevent="submitICS">
<p class="muted small" style="margin: 0 0 4px">
Funktioniert mit jedem öffentlichen ICS/webcal-Link, z. B. der „privaten Adresse im
iCal-Format“ eines Google-Kalenders oder Ferien-/Schichtplänen.
</p>
<label class="field">
<span>ICS-URL</span>
<input v-model="ics.url" required class="input" placeholder="https://…/kalender.ics" />
</label>
<label class="field">
<span>Anzeigename (optional)</span>
<input v-model="ics.display_name" class="input" placeholder="z. B. Schichtplan" />
</label>
<button class="btn btn-primary" type="submit" :disabled="busy">
{{ busy ? 'Prüfe' : 'Hinzufügen' }}
</button>
</form>
</transition>
</GlassCard>
<GlassCard title="Verbundene Kalender">
<template #header>
<button class="btn btn-sm" :disabled="syncing" @click="syncNow">
{{ syncing ? 'Synchronisiere' : ' Jetzt synchronisieren' }}
</button>
</template>
<div v-if="connections === null" class="skeleton" style="height: 72px"></div>
<p v-else-if="connections.length === 0" class="muted small" style="margin: 0">
Noch kein Kalender verbunden. Oben einen hinzufügen ohne Kalender sind alle Zeiten frei.
</p>
<div v-else class="col conn-list">
<div v-for="c in connections" :key="c.id" class="glass-soft conn">
<span class="conn-icon">{{ providerIcon[c.provider] }}</span>
<div class="grow">
<div class="row">
<strong>{{ c.display_name }}</strong>
<span class="badge">{{ providerLabel[c.provider] }}</span>
</div>
<div class="row small">
<span :class="c.last_error ? 'err' : 'muted'">
{{ c.last_error ? '⚠ ' + c.last_error : '✓ synchronisiert ' + syncedAgo(c.last_synced_ts) }}
</span>
</div>
</div>
<button class="btn btn-sm btn-danger" @click="remove(c)">Entfernen</button>
</div>
</div>
</GlassCard>
</div>
</template>
<style scoped>
.add-row { flex-wrap: wrap; }
.form { padding: 18px; display: flex; flex-direction: column; gap: 14px; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
.form a { color: var(--accent); }
.conn { display: flex; align-items: center; gap: 14px; padding: 14px 16px; }
.conn-icon { font-size: 22px; }
.err { color: var(--danger); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
</style>