mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-06 01:06:28 +08:00
resolve merge conflict
This commit is contained in:
68
app/composables/useApi.ts
Normal file
68
app/composables/useApi.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
|
||||
interface ApiRequestOptions<T = any> {
|
||||
method?: HttpMethod
|
||||
body?: T
|
||||
query?: Record<string, any>
|
||||
headers?: HeadersInit
|
||||
auth?: boolean
|
||||
}
|
||||
|
||||
export function useApi() {
|
||||
const auth = useAuthStore()
|
||||
|
||||
const execute = async <T>(path: string, options: ApiRequestOptions = {}) => {
|
||||
const { method = 'GET', body, query, headers = {}, auth: requiresAuth = true } = options
|
||||
|
||||
const computedHeaders: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...(headers as Record<string, string>),
|
||||
}
|
||||
|
||||
if (requiresAuth && auth.accessToken) {
|
||||
computedHeaders.Authorization = `Bearer ${auth.accessToken}`
|
||||
}
|
||||
|
||||
const requestOptions: any = {
|
||||
method,
|
||||
headers: computedHeaders,
|
||||
query,
|
||||
body,
|
||||
}
|
||||
|
||||
if (body && !(body instanceof FormData)) {
|
||||
computedHeaders['Content-Type'] = 'application/json'
|
||||
requestOptions.body = body
|
||||
}
|
||||
|
||||
try {
|
||||
return await $fetch<T>(path, requestOptions)
|
||||
} catch (error: any) {
|
||||
const status = error?.status || error?.response?.status
|
||||
if (status === 401 && requiresAuth) {
|
||||
const refreshed = await auth.tryRefresh()
|
||||
if (refreshed) {
|
||||
if (auth.accessToken) {
|
||||
computedHeaders.Authorization = `Bearer ${auth.accessToken}`
|
||||
}
|
||||
return await $fetch<T>(path, requestOptions)
|
||||
}
|
||||
await auth.logout()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
request: execute,
|
||||
get: <T>(path: string, options: ApiRequestOptions = {}) => execute<T>(path, { ...options, method: 'GET' }),
|
||||
post: <T>(path: string, body?: any, options: ApiRequestOptions = {}) =>
|
||||
execute<T>(path, { ...options, method: 'POST', body }),
|
||||
put: <T>(path: string, body?: any, options: ApiRequestOptions = {}) =>
|
||||
execute<T>(path, { ...options, method: 'PUT', body }),
|
||||
del: <T>(path: string, options: ApiRequestOptions = {}) => execute<T>(path, { ...options, method: 'DELETE' }),
|
||||
}
|
||||
}
|
||||
|
||||
107
app/pages/agb.vue
Normal file
107
app/pages/agb.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#0b1020] text-white py-12 px-6">
|
||||
<div class="mx-auto max-w-4xl space-y-8">
|
||||
<header class="space-y-3">
|
||||
<NuxtLink to="/" class="inline-flex items-center gap-2 text-sm text-white/60 hover:text-cyan-300">
|
||||
<v-icon icon="mdi-arrow-left" size="18" /> Zurück zur Startseite
|
||||
</NuxtLink>
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">Rechtliches</p>
|
||||
<h1 class="text-3xl font-semibold">Allgemeine Geschäftsbedingungen (AGB)</h1>
|
||||
<p class="text-white/70">Stand: {{ lastUpdated }}</p>
|
||||
</header>
|
||||
|
||||
<section v-for="section in sections" :key="section.title" class="space-y-3 rounded-2xl border border-white/10 bg-white/5 p-6">
|
||||
<h2 class="text-xl font-semibold">{{ section.title }}</h2>
|
||||
<div class="space-y-2 text-white/70">
|
||||
<p v-for="paragraph in section.paragraphs" :key="paragraph">{{ paragraph }}</p>
|
||||
<ul v-if="section.list" class="list-disc space-y-1 pl-6">
|
||||
<li v-for="item in section.list" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="rounded-2xl border border-white/10 bg-white/5 p-6 text-sm text-white/70">
|
||||
<p>Fragen zu diesen AGB beantworten wir unter legal@opensquawk.dev. Für Enterprise- oder Trainingsprogramme erstellen wir individuelle Verträge.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const lastUpdated = new Date('2025-01-15').toLocaleDateString('de-DE')
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: '1. Geltungsbereich',
|
||||
paragraphs: [
|
||||
'Diese AGB regeln die Nutzung der Plattform OpenSquawk (“Dienst”), betrieben von der OpenSquawk UG (haftungsbeschränkt). Abweichende Bedingungen des Nutzers werden nicht anerkannt, es sei denn, wir stimmen ihrer Geltung ausdrücklich schriftlich zu.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '2. Registrierung & Einladungscodes',
|
||||
paragraphs: [
|
||||
'Ein Nutzerkonto darf nur mit gültigem Einladungscode angelegt werden. Einladungscodes sind personalisiert, zeitlich limitiert und dürfen nicht verkauft oder unbefugt weitergegeben werden.',
|
||||
],
|
||||
list: [
|
||||
'Wartelistenplätze berechtigen nicht automatisch zur Registrierung.',
|
||||
'Aktive Nutzer können frühestens 14 Tage nach Account-Erstellung bis zu zwei Einladungscodes generieren.',
|
||||
'OpenSquawk behält sich vor, Einladungscodes nach Missbrauchsverdacht zu sperren.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '3. Vertragsgegenstand',
|
||||
paragraphs: [
|
||||
'OpenSquawk bietet AI-gestützte Kommunikationssimulation für Flugsimulatoren. Funktionen umfassen Push-to-Talk-Verarbeitung, Text-to-Speech, Lernmodule und Integrationen. Der Dienst ist ausschließlich für Ausbildungs- und Trainingszwecke gedacht – nicht für den realen Flugfunk.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '4. Pflichten der Nutzer',
|
||||
list: [
|
||||
'Zugangsdaten vertraulich behandeln und Dritten nicht überlassen.',
|
||||
'Keine rechtswidrigen oder beleidigenden Inhalte übertragen.',
|
||||
'Systeme nicht übermäßig belasten oder Sicherheitsmechanismen umgehen.',
|
||||
'Eigenständige Sicherung lokaler Daten/Audioaufnahmen.',
|
||||
],
|
||||
paragraphs: [
|
||||
'Wir behalten uns vor, Accounts bei Verstößen temporär oder dauerhaft zu sperren.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '5. Logging & Datenanalyse',
|
||||
paragraphs: [
|
||||
'Zur Qualitätsverbesserung werden sämtliche Funksprüche – sowohl Texteingaben als auch PTT-Transkripte – zusammen mit Metaangaben (z. B. Modul, Entscheidungspfad, Signalstärke) gespeichert und ausgewertet. Mit Nutzung des Dienstes stimmen Sie dieser Verarbeitung zu.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '6. Preise & Zahlungsbedingungen',
|
||||
paragraphs: [
|
||||
'OpenSquawk kann kostenpflichtige Hosted-Pläne anbieten. Preise werden transparent vor Vertragsabschluss angezeigt. Gebühren sind monatlich im Voraus fällig. Erfolgt die Zahlung nicht fristgerecht, kann das Konto gesperrt werden.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '7. Laufzeit & Kündigung',
|
||||
paragraphs: [
|
||||
'Kostenlose Konten können jederzeit ohne Frist gelöscht werden. Bei kostenpflichtigen Tarifen gilt – sofern nichts anderes vereinbart – eine monatliche Laufzeit mit Kündigungsfrist von sieben Tagen zum Monatsende. Kündigungen müssen schriftlich oder per E-Mail erfolgen.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '8. Gewährleistung & Haftung',
|
||||
paragraphs: [
|
||||
'OpenSquawk stellt einen Beta-Dienst bereit. Wir übernehmen keine Gewähr für unterbrechungsfreie Verfügbarkeit oder absolute Richtigkeit der generierten Inhalte. Haftung besteht nur bei Vorsatz, grober Fahrlässigkeit sowie bei Verletzung von Leben, Körper oder Gesundheit.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '9. Änderungen der AGB',
|
||||
paragraphs: [
|
||||
'Wir behalten uns vor, diese AGB bei Bedarf anzupassen. Nutzer werden mindestens 14 Tage vor Inkrafttreten informiert. Widerspricht der Nutzer nicht innerhalb der Frist, gelten die neuen Bedingungen als akzeptiert.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '10. Schlussbestimmungen',
|
||||
paragraphs: [
|
||||
'Es gilt deutsches Recht. Gerichtsstand für Kaufleute ist Berlin. Sollten einzelne Bestimmungen unwirksam sein, bleibt die Wirksamkeit der übrigen Regelungen unberührt.',
|
||||
],
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
106
app/pages/api-docs.vue
Normal file
106
app/pages/api-docs.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#0b1020] text-white py-12 px-6">
|
||||
<div class="mx-auto max-w-5xl space-y-10">
|
||||
<header class="space-y-3">
|
||||
<NuxtLink to="/" class="inline-flex items-center gap-2 text-sm text-white/60 hover:text-cyan-300">
|
||||
<v-icon icon="mdi-arrow-left" size="18" /> Zurück zur Startseite
|
||||
</NuxtLink>
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">Developer</p>
|
||||
<h1 class="text-3xl font-semibold">OpenSquawk API – Dokumentation</h1>
|
||||
<p class="text-white/70">Alle Endpunkte sind JSON-basiert und laufen über HTTPS. Standard-Response enthält <code class="bg-white/10 px-1">success</code> oder ein Fehlerobjekt mit <code class="bg-white/10 px-1">statusMessage</code>.</p>
|
||||
</header>
|
||||
|
||||
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Authentifizierung</h2>
|
||||
<p class="text-white/70">
|
||||
Nach erfolgreichem Login wird ein JWT (Bearer-Token) zurückgegeben und ein HTTP-only-Refresh-Cookie gesetzt. Für alle geschützten Endpunkte muss der Header <code class="bg-white/10 px-1">Authorization: Bearer <token></code> gesetzt werden. Tokens laufen nach 15 Minuten ab und können über <code>/api/service/auth/refresh</code> erneuert werden.
|
||||
</p>
|
||||
<p class="text-white/70">Nicht authentifizierte Endpunkte befinden sich im Namensraum <code class="bg-white/10 px-1">/api/service/*</code>.</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-6">
|
||||
<h2 class="text-xl font-semibold">Öffentliche Endpunkte</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="uppercase tracking-[0.2em] text-white/50">
|
||||
<tr>
|
||||
<th class="px-3 py-2">Methode</th>
|
||||
<th class="px-3 py-2">Pfad</th>
|
||||
<th class="px-3 py-2">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/10">
|
||||
<tr v-for="row in publicEndpoints" :key="row.path">
|
||||
<td class="px-3 py-2 font-mono text-cyan-300">{{ row.method }}</td>
|
||||
<td class="px-3 py-2 font-mono">{{ row.path }}</td>
|
||||
<td class="px-3 py-2 text-white/70">{{ row.description }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-6">
|
||||
<h2 class="text-xl font-semibold">Geschützte Endpunkte</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="uppercase tracking-[0.2em] text-white/50">
|
||||
<tr>
|
||||
<th class="px-3 py-2">Methode</th>
|
||||
<th class="px-3 py-2">Pfad</th>
|
||||
<th class="px-3 py-2">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/10">
|
||||
<tr v-for="row in protectedEndpoints" :key="row.path">
|
||||
<td class="px-3 py-2 font-mono text-cyan-300">{{ row.method }}</td>
|
||||
<td class="px-3 py-2 font-mono">{{ row.path }}</td>
|
||||
<td class="px-3 py-2 text-white/70">{{ row.description }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Antwort- und Fehlerformat</h2>
|
||||
<pre class="overflow-x-auto rounded-xl bg-black/60 p-4 text-sm text-white/80">
|
||||
{
|
||||
"success": true,
|
||||
"data": { ... }
|
||||
}
|
||||
|
||||
// Fehler
|
||||
{
|
||||
"statusCode": 401,
|
||||
"statusMessage": "Authentication required"
|
||||
}
|
||||
</pre>
|
||||
<p class="text-white/70">Weitere Felder wie <code class="bg-white/10 px-1">joinedAt</code> oder <code class="bg-white/10 px-1">invitationStatus</code> werden jeweils im Response dokumentiert.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const publicEndpoints = [
|
||||
{ method: 'POST', path: '/api/service/waitlist', description: 'Person auf die Warteliste setzen. Erfordert Zustimmung zu AGB & Datenschutz.' },
|
||||
{ method: 'GET', path: '/api/service/waitlist', description: 'Aggregierte Wartelistenstatistiken abrufen (Anzahl, letzte Einträge).' },
|
||||
{ method: 'POST', path: '/api/service/auth/login', description: 'Login mit E-Mail & Passwort. Gibt JWT zurück und setzt Refresh-Cookie.' },
|
||||
{ method: 'POST', path: '/api/service/auth/register', description: 'Registrierung mit Einladungscode und Einwilligungen.' },
|
||||
{ method: 'POST', path: '/api/service/auth/refresh', description: 'Access-Token anhand des Refresh-Cookies erneuern.' },
|
||||
{ method: 'GET', path: '/api/service/invitations/{code}', description: 'Einladungscode prüfen (gültig, abgelaufen, verwendet).' },
|
||||
]
|
||||
|
||||
const protectedEndpoints = [
|
||||
{ method: 'GET', path: '/api/auth/me', description: 'Profil des aktuellen Nutzers abrufen.' },
|
||||
{ method: 'POST', path: '/api/auth/logout', description: 'Aktive Session invalidieren und Refresh-Cookie löschen.' },
|
||||
{ method: 'GET', path: '/api/auth/invitations', description: 'Eigene erstellte Einladungscodes auflisten.' },
|
||||
{ method: 'POST', path: '/api/auth/invitations', description: 'Neuen Einladungscode generieren (nach 14 Tagen, max. 2).' },
|
||||
{ method: 'POST', path: '/api/atc/say', description: 'ATC-Sprachausgabe erzeugen (TTS) – mit Logging des Textes.' },
|
||||
{ method: 'POST', path: '/api/atc/ptt', description: 'Push-to-Talk Audio verarbeiten, transkribieren und loggen.' },
|
||||
{ method: 'POST', path: '/api/llm/decide', description: 'LLM-gestützte Entscheidungslogik für den aktuellen Flugzustand.' },
|
||||
{ method: 'GET', path: '/api/vatsim/flightplans', description: 'Gefilterte VATSIM-Flugpläne für eine CID abrufen.' },
|
||||
]
|
||||
</script>
|
||||
|
||||
86
app/pages/datenschutz.vue
Normal file
86
app/pages/datenschutz.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#0b1020] text-white py-12 px-6">
|
||||
<div class="mx-auto max-w-4xl space-y-8">
|
||||
<header class="space-y-3">
|
||||
<NuxtLink to="/" class="inline-flex items-center gap-2 text-sm text-white/60 hover:text-cyan-300">
|
||||
<v-icon icon="mdi-arrow-left" size="18" /> Zurück zur Startseite
|
||||
</NuxtLink>
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">Rechtliches</p>
|
||||
<h1 class="text-3xl font-semibold">Datenschutzerklärung</h1>
|
||||
<p class="text-white/70">Stand: {{ lastUpdated }}</p>
|
||||
</header>
|
||||
|
||||
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
|
||||
<h2 class="text-xl font-semibold">1. Verantwortlicher</h2>
|
||||
<p class="text-white/70">
|
||||
Verantwortlich für die Datenverarbeitung im Sinne der Datenschutz-Grundverordnung (DSGVO) ist die OpenSquawk UG (haftungsbeschränkt), Musterstraße 12, 10115 Berlin, Deutschland. Kontakt: datenschutz@opensquawk.dev.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
|
||||
<h2 class="text-xl font-semibold">2. Verarbeitete Daten</h2>
|
||||
<ul class="list-disc space-y-2 pl-6 text-white/70">
|
||||
<li><strong>Warteliste:</strong> Name (optional), E-Mail-Adresse, freiwillige Hinweise, Zeitpunkt des Eintrags, Einwilligungen.</li>
|
||||
<li><strong>Nutzerkonto:</strong> Name, E-Mail-Adresse, Passwort-Hash, Einladungsstatus, Erstellungs- und Login-Zeitpunkte, Einladungscode-Historie.</li>
|
||||
<li><strong>Kommunikationsdaten:</strong> Sämtliche eingegebenen oder per Push-to-Talk übermittelten Funksprüche (Transkripte, normalisierte Texte, Metaangaben wie Modul, Lesson-ID, Stärke, Entscheidungskontext).</li>
|
||||
<li><strong>Technische Protokolle:</strong> Geräteinformationen (Browser, OS), Zeitstempel, Request-IDs, Fehlerlogs.</li>
|
||||
</ul>
|
||||
<p class="text-xs text-white/50">Hinweis: Audio-Dateien aus Push-to-Talk werden nur temporär verarbeitet, Transkripte und Kontextdaten speichern wir zur Qualitätsverbesserung.</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
|
||||
<h2 class="text-xl font-semibold">3. Zwecke und Rechtsgrundlagen</h2>
|
||||
<ul class="space-y-3 text-white/70">
|
||||
<li><strong>Bereitstellung des Dienstes (Art. 6 Abs. 1 lit. b DSGVO):</strong> Verwaltung von Warteliste, Accounts und Sessions, Ausgabe von Einladungscodes, Abrechnung zukünftiger Tarife.</li>
|
||||
<li><strong>Produktverbesserung & Sicherheit (Art. 6 Abs. 1 lit. f DSGVO):</strong> Analyse und Logging sämtlicher Funksprüche, Fehlerüberwachung, Missbrauchsprävention.</li>
|
||||
<li><strong>Kommunikation (Art. 6 Abs. 1 lit. a DSGVO):</strong> Versand von Wartelisten-Updates, Einladungen und produktspezifischen Informationen nach ausdrücklicher Einwilligung.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
|
||||
<h2 class="text-xl font-semibold">4. Speicherdauer</h2>
|
||||
<ul class="space-y-2 text-white/70">
|
||||
<li>Wartelisteneinträge: bis zum Widerruf oder maximal 24 Monate nach letzter Aktivität.</li>
|
||||
<li>Accountdaten: solange das Nutzerkonto besteht, anschließend entsprechend gesetzlicher Aufbewahrungspflichten.</li>
|
||||
<li>Kommunikationslogs: mindestens 12 Monate zur Qualitätsverbesserung; bei Supportfällen oder Missbrauchsvorwürfen bis zur abschließenden Klärung.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
|
||||
<h2 class="text-xl font-semibold">5. Weitergabe & Auftragsverarbeitung</h2>
|
||||
<p class="text-white/70">
|
||||
Wir hosten OpenSquawk auf europäischen Cloud-Plattformen (derzeit Hetzner Cloud, Deutschland). Kommunikationsdaten werden in unserer MongoDB-Datenbank gespeichert. Externe KI-Dienstleister (z. B. OpenAI) erhalten ausschließlich pseudonymisierte Texte zur Verarbeitung von TTS/LLM-Funktionen. Es gelten entsprechende Auftragsverarbeitungsverträge. Eine Übermittlung in Drittstaaten erfolgt nur unter Nutzung von EU-Standardvertragsklauseln.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
|
||||
<h2 class="text-xl font-semibold">6. Rechte der Betroffenen</h2>
|
||||
<ul class="list-disc space-y-2 pl-6 text-white/70">
|
||||
<li>Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung (Art. 15–18 DSGVO).</li>
|
||||
<li>Datenübertragbarkeit (Art. 20 DSGVO).</li>
|
||||
<li>Widerspruch gegen Verarbeitung aus berechtigtem Interesse (Art. 21 DSGVO).</li>
|
||||
<li>Widerruf erteilter Einwilligungen mit Wirkung für die Zukunft.</li>
|
||||
<li>Beschwerderecht bei einer Datenschutzaufsichtsbehörde, z. B. Berliner Beauftragte für Datenschutz und Informationsfreiheit.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
|
||||
<h2 class="text-xl font-semibold">7. Kontakt für Datenschutzanfragen</h2>
|
||||
<p class="text-white/70">
|
||||
Bitte richten Sie Auskunfts- oder Löschersuchen an datenschutz@opensquawk.dev. Zur eindeutigen Zuordnung benötigen wir die bei OpenSquawk registrierte E-Mail-Adresse sowie ggf. weitere Identifikationsmerkmale (z. B. VATSIM-ID).
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const lastUpdated = new Date('2025-01-15').toLocaleDateString('de-DE')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chip {
|
||||
@apply inline-flex h-6 w-6 items-center justify-center rounded-full bg-cyan-500/20 text-xs font-medium text-cyan-200;
|
||||
}
|
||||
</style>
|
||||
|
||||
57
app/pages/impressum.vue
Normal file
57
app/pages/impressum.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#0b1020] text-white py-12 px-6">
|
||||
<div class="mx-auto max-w-3xl space-y-8">
|
||||
<header class="space-y-3">
|
||||
<NuxtLink to="/" class="inline-flex items-center gap-2 text-sm text-white/60 hover:text-cyan-300">
|
||||
<v-icon icon="mdi-arrow-left" size="18" /> Zurück zur Startseite
|
||||
</NuxtLink>
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">Rechtliches</p>
|
||||
<h1 class="text-3xl font-semibold">Impressum</h1>
|
||||
</header>
|
||||
|
||||
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Angaben gemäß § 5 TMG</h2>
|
||||
<div class="space-y-2 text-white/70">
|
||||
<p>OpenSquawk UG (haftungsbeschränkt)</p>
|
||||
<p>Musterstraße 12</p>
|
||||
<p>10115 Berlin</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Vertreten durch</h2>
|
||||
<p class="text-white/70">Geschäftsführer: Leonie Wagner</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Kontakt</h2>
|
||||
<div class="space-y-2 text-white/70">
|
||||
<p>E-Mail: hallo@opensquawk.dev</p>
|
||||
<p>Telefon: +49 (0)30 1234 5678</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Registereintrag</h2>
|
||||
<div class="space-y-2 text-white/70">
|
||||
<p>Amtsgericht Berlin Charlottenburg</p>
|
||||
<p>HRB 245678 B</p>
|
||||
<p>USt-IdNr.: DE345678912</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
|
||||
<p class="text-white/70">Leonie Wagner, Anschrift wie oben.</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Haftungsausschluss</h2>
|
||||
<p class="text-white/70">
|
||||
OpenSquawk stellt Trainingsinhalte für Flugsimulatoren bereit. Die Nutzung erfolgt eigenverantwortlich. Für Inhalte externer Links übernehmen wir keine Haftung; für den Inhalt der verlinkten Seiten sind ausschließlich deren Betreiber verantwortlich.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -262,17 +262,95 @@ POST /api/route/taxi
|
||||
<!-- CTA -->
|
||||
<section id="cta" class="py-16 md:py-24 bg-gradient-to-tr from-cyan-500/20 via-blue-500/10 to-transparent border-y border-white/10">
|
||||
<div class="container-outer">
|
||||
<div class="card md:flex md:items-center md:justify-between" data-aos="zoom-in">
|
||||
<div>
|
||||
<h3 class="text-2xl md:text-3xl font-semibold">Jetzt OpenSquawk ausprobieren</h3>
|
||||
<p class="mt-2 text-white/80">Starte kostenlos. Upgrade jederzeit. Keine Bindung.</p>
|
||||
<div class="card grid gap-8 md:grid-cols-2" data-aos="zoom-in">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-2xl md:text-3xl font-semibold">Frühzugang & Warteliste</h3>
|
||||
<p class="mt-2 text-white/80">
|
||||
Trag dich ein und sichere dir deinen Platz. Aktuell warten
|
||||
<span class="font-semibold text-cyan-300">{{ waitlistCountDisplay }}</span>
|
||||
Pilot:innen auf den nächsten Invite-Drop.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-black/30 p-4 space-y-3 max-h-60 overflow-y-auto">
|
||||
<div v-if="waitlistLoading" class="text-sm text-white/60">Lade Warteliste…</div>
|
||||
<template v-else>
|
||||
<div v-if="waitlistMembers.length === 0" class="text-sm text-white/60">
|
||||
Sei die erste Person auf der Liste und erhalte einen Invite, sobald wir die nächste Welle freischalten.
|
||||
</div>
|
||||
<div
|
||||
v-for="member in waitlistMembers"
|
||||
:key="`${member.email}-${member.joinedAt}`"
|
||||
class="flex items-start justify-between gap-3 border-b border-white/5 pb-3 last:border-none last:pb-0"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">{{ member.name }}</p>
|
||||
<p class="text-xs text-white/40">{{ member.email }}</p>
|
||||
</div>
|
||||
<div class="text-right text-xs text-white/60">
|
||||
<div>{{ formatWaitlistDate(member.joinedAt) }}</div>
|
||||
<div class="text-[11px] text-cyan-300/80">wartet seit {{ formatWaitlistDuration(member.joinedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-xs text-white/60">Wir senden Einladungen in Batches, priorisieren aktive Wartelistenplätze und verschicken Einladungscodes per E-Mail.</p>
|
||||
</div>
|
||||
<form class="mt-4 md:mt-0 flex w-full md:w-auto gap-2" @submit.prevent="submitEmail">
|
||||
<input v-model="email" aria-label="E‑Mail" type="email" required placeholder="dein@email" class="w-full md:w-72 px-4 py-3 rounded-xl bg-white/5 border border-white/10 placeholder-white/40 outline-none focus:border-cyan-400" />
|
||||
<button class="btn btn-primary">Einladung anfordern</button>
|
||||
<form class="space-y-4" @submit.prevent="submitWaitlist">
|
||||
<div class="grid gap-3">
|
||||
<input
|
||||
v-model.trim="waitlistForm.name"
|
||||
aria-label="Name"
|
||||
type="text"
|
||||
placeholder="Vor- und Nachname (optional)"
|
||||
class="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 placeholder-white/40 outline-none focus:border-cyan-400"
|
||||
/>
|
||||
<input
|
||||
v-model.trim="waitlistForm.email"
|
||||
aria-label="E-Mail"
|
||||
type="email"
|
||||
required
|
||||
placeholder="dein@email"
|
||||
class="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 placeholder-white/40 outline-none focus:border-cyan-400"
|
||||
/>
|
||||
<textarea
|
||||
v-model.trim="waitlistForm.notes"
|
||||
rows="3"
|
||||
placeholder="Was möchtest du mit OpenSquawk lernen? (optional)"
|
||||
class="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 placeholder-white/40 outline-none focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2 text-xs text-white/60">
|
||||
<label class="flex items-start gap-3">
|
||||
<input type="checkbox" v-model="waitlistForm.consentTerms" class="mt-1" required />
|
||||
<span>
|
||||
Ich akzeptiere die <NuxtLink to="/agb" class="text-cyan-300 underline">AGB</NuxtLink> von OpenSquawk.
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3">
|
||||
<input type="checkbox" v-model="waitlistForm.consentPrivacy" class="mt-1" required />
|
||||
<span>
|
||||
Ich habe die <NuxtLink to="/datenschutz" class="text-cyan-300 underline">Datenschutzerklärung</NuxtLink> gelesen und willige in die Speicherung meiner Angaben zur Kontaktaufnahme ein.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="!waitlistFormValid || waitlistSubmitting"
|
||||
>
|
||||
<span v-if="waitlistSubmitting" class="flex items-center gap-2">
|
||||
<v-progress-circular indeterminate size="16" width="2" color="white" />
|
||||
Sende Daten…
|
||||
</span>
|
||||
<span v-else>Auf Warteliste setzen</span>
|
||||
</button>
|
||||
<p v-if="waitlistSuccess" class="text-sm text-green-300">
|
||||
Danke! Wir haben dich auf der Warteliste eingetragen und melden uns, sobald Plätze frei werden.
|
||||
</p>
|
||||
<p v-else-if="waitlistError" class="text-sm text-red-300">{{ waitlistError }}</p>
|
||||
</form>
|
||||
</div>
|
||||
<p v-if="thanks" class="mt-3 text-sm text-green-300" data-aos="fade-in">Danke! Wir melden uns in Kürze mit deinem Zugang.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -331,9 +409,10 @@ POST /api/route/taxi
|
||||
<div>
|
||||
<h4 class="font-semibold mb-3">Rechtliches</h4>
|
||||
<ul class="space-y-2 text-white/70 text-sm">
|
||||
<li><NuxtLink to="#" class="hover:text-cyan-300">Impressum</NuxtLink></li>
|
||||
<li><NuxtLink to="#" class="hover:text-cyan-300">Datenschutz</NuxtLink></li>
|
||||
<li><NuxtLink to="#" class="hover:text-cyan-300">Nutzungshinweise</NuxtLink></li>
|
||||
<li><NuxtLink to="/impressum" class="hover:text-cyan-300">Impressum</NuxtLink></li>
|
||||
<li><NuxtLink to="/datenschutz" class="hover:text-cyan-300">Datenschutz</NuxtLink></li>
|
||||
<li><NuxtLink to="/agb" class="hover:text-cyan-300">AGB</NuxtLink></li>
|
||||
<li><NuxtLink to="/api-docs" class="hover:text-cyan-300">API-Dokumentation</NuxtLink></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,18 +423,92 @@ POST /api/route/taxi
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useHead } from '#imports'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
const yearly = ref(true)
|
||||
const email = ref('')
|
||||
const thanks = ref(false)
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
function submitEmail(){
|
||||
// TODO: replace with real endpoint
|
||||
console.log('request access:', email.value)
|
||||
thanks.value = true
|
||||
const waitlistForm = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
notes: '',
|
||||
consentPrivacy: false,
|
||||
consentTerms: false,
|
||||
})
|
||||
|
||||
const waitlistSubmitting = ref(false)
|
||||
const waitlistSuccess = ref(false)
|
||||
const waitlistError = ref('')
|
||||
const waitlistStats = ref<{ count: number; members: Array<{ name: string; email: string; joinedAt: string }> }>({
|
||||
count: 0,
|
||||
members: [],
|
||||
})
|
||||
const waitlistLoading = ref(false)
|
||||
|
||||
const waitlistCountDisplay = computed(() => new Intl.NumberFormat('de-DE').format(waitlistStats.value?.count || 0))
|
||||
const waitlistMembers = computed(() => waitlistStats.value?.members || [])
|
||||
const waitlistFormValid = computed(() =>
|
||||
Boolean(waitlistForm.email && waitlistForm.consentPrivacy && waitlistForm.consentTerms)
|
||||
)
|
||||
|
||||
async function loadWaitlistStats() {
|
||||
try {
|
||||
waitlistLoading.value = true
|
||||
const data = await api.get('/api/service/waitlist', { auth: false })
|
||||
waitlistStats.value = data as any
|
||||
} catch (err) {
|
||||
console.warn('Waitlist stats unavailable', err)
|
||||
} finally {
|
||||
waitlistLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitWaitlist() {
|
||||
if (!waitlistFormValid.value || waitlistSubmitting.value) return
|
||||
|
||||
waitlistSubmitting.value = true
|
||||
waitlistError.value = ''
|
||||
waitlistSuccess.value = false
|
||||
|
||||
try {
|
||||
await api.post('/api/service/waitlist', { ...waitlistForm }, { auth: false })
|
||||
waitlistSuccess.value = true
|
||||
await loadWaitlistStats()
|
||||
waitlistForm.name = ''
|
||||
waitlistForm.email = ''
|
||||
waitlistForm.notes = ''
|
||||
waitlistForm.consentPrivacy = false
|
||||
waitlistForm.consentTerms = false
|
||||
} catch (err: any) {
|
||||
const message = err?.data?.statusMessage || err?.message || 'Anmeldung fehlgeschlagen'
|
||||
waitlistError.value = message
|
||||
} finally {
|
||||
waitlistSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatWaitlistDate = (iso: string) => {
|
||||
if (!iso) return 'unbekannt'
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const formatWaitlistDuration = (iso: string) => {
|
||||
if (!iso) return 'heute'
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const days = Math.max(1, Math.round(diff / (1000 * 60 * 60 * 24)))
|
||||
if (days < 7) return `${days} Tag${days === 1 ? '' : 'e'}`
|
||||
const weeks = Math.round(days / 7)
|
||||
if (weeks < 8) return `${weeks} Woche${weeks === 1 ? '' : 'n'}`
|
||||
const months = Math.round(days / 30)
|
||||
return `${months} Monat${months === 1 ? '' : 'e'}`
|
||||
}
|
||||
|
||||
useHead({
|
||||
@@ -376,6 +529,7 @@ useHead({
|
||||
|
||||
// AOS: falls du kein Nuxt‑AOS Modul nutzt, hier Fallback
|
||||
onMounted(async () => {
|
||||
loadWaitlistStats()
|
||||
// @ts-ignore – optionaler Fallback
|
||||
if (!('AOS' in window)) {
|
||||
const [{ default: AOS }] = await Promise.all([
|
||||
|
||||
295
app/pages/login.vue
Normal file
295
app/pages/login.vue
Normal file
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#0b1020] text-white">
|
||||
<div class="mx-auto max-w-5xl px-6 py-12 lg:py-16">
|
||||
<div class="grid gap-10 lg:grid-cols-[1.1fr_1fr] items-start">
|
||||
<div class="space-y-6">
|
||||
<NuxtLink to="/" class="inline-flex items-center gap-2 text-sm text-white/70 hover:text-cyan-300">
|
||||
<v-icon icon="mdi-arrow-left" size="18" /> Zurück zur Landingpage
|
||||
</NuxtLink>
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">OpenSquawk Access</p>
|
||||
<h1 class="text-3xl md:text-4xl font-semibold">Melde dich an oder sichere dir deinen Invite</h1>
|
||||
<p class="text-white/70">
|
||||
Registrierungen sind nur mit Einladungscode möglich. Codes werden nach Eintrag auf die Warteliste vergeben oder von bestehenden Mitgliedern nach zwei Wochen Nutzungsdauer ausgestellt.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5 space-y-4">
|
||||
<h2 class="text-lg font-semibold">So bekommst du Zugang</h2>
|
||||
<ol class="space-y-3 text-white/70 text-sm">
|
||||
<li class="flex gap-3"><span class="chip">1</span><span>Auf der <NuxtLink to="/#cta" class="text-cyan-300 underline">Warteliste</NuxtLink> eintragen.</span></li>
|
||||
<li class="flex gap-3"><span class="chip">2</span><span>Invite per E-Mail erhalten oder von einem aktiven Nutzer einen Code bekommen.</span></li>
|
||||
<li class="flex gap-3"><span class="chip">3</span><span>Hier registrieren, AGB & Datenschutz bestätigen und direkt loslegen.</span></li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-cyan-400/20 bg-cyan-400/10 p-5">
|
||||
<p class="text-sm text-cyan-100">
|
||||
Tipp: Nach 14 Tagen aktiver Nutzung kannst du selbst bis zu zwei Einladungscodes generieren und mit Freunden teilen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="flex-1 rounded-xl border px-3 py-2 text-sm font-medium transition"
|
||||
:class="mode === 'login' ? 'border-cyan-400 bg-cyan-400/20 text-white' : 'border-white/10 text-white/70 hover:text-white'"
|
||||
@click="mode = 'login'"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 rounded-xl border px-3 py-2 text-sm font-medium transition"
|
||||
:class="mode === 'register' ? 'border-cyan-400 bg-cyan-400/20 text-white' : 'border-white/10 text-white/70 hover:text-white'"
|
||||
@click="mode = 'register'"
|
||||
>
|
||||
Registrieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form v-if="mode === 'login'" class="space-y-4" @submit.prevent="submitLogin">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.3em] text-white/40">E-Mail</label>
|
||||
<input
|
||||
v-model.trim="loginForm.email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
class="mt-1 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.3em] text-white/40">Passwort</label>
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="mt-1 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full" :disabled="loginLoading">
|
||||
<span v-if="loginLoading" class="flex items-center justify-center gap-2">
|
||||
<v-progress-circular indeterminate size="16" width="2" color="white" />
|
||||
Anmeldung…
|
||||
</span>
|
||||
<span v-else>Login</span>
|
||||
</button>
|
||||
|
||||
<p v-if="loginError" class="text-sm text-red-300">{{ loginError }}</p>
|
||||
</form>
|
||||
|
||||
<form v-else class="space-y-4" @submit.prevent="submitRegister">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.3em] text-white/40">Name</label>
|
||||
<input
|
||||
v-model.trim="registerForm.name"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
placeholder="Vorname Nachname"
|
||||
class="mt-1 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.3em] text-white/40">E-Mail</label>
|
||||
<input
|
||||
v-model.trim="registerForm.email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
class="mt-1 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.3em] text-white/40">Passwort</label>
|
||||
<input
|
||||
v-model="registerForm.password"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
class="mt-1 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none focus:border-cyan-400"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-white/50">Mindestens 8 Zeichen, gerne inkl. Zahl & Sonderzeichen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs uppercase tracking-[0.3em] text-white/40">Einladungscode</label>
|
||||
<button type="button" class="text-[11px] text-cyan-300 underline" @click="checkInvitationCode" :disabled="!registerForm.invitationCode">
|
||||
Code prüfen
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model.trim="registerForm.invitationCode"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 w-full uppercase tracking-widest rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none focus:border-cyan-400"
|
||||
placeholder="z.B. ABCD1234"
|
||||
/>
|
||||
<p v-if="invitationStatus === 'valid'" class="mt-1 text-xs text-green-300">Code ist gültig.</p>
|
||||
<p v-else-if="invitationStatus === 'invalid'" class="mt-1 text-xs text-red-300">Dieser Einladungscode ist ungültig oder bereits verwendet.</p>
|
||||
<p v-else-if="invitationStatus === 'checking'" class="mt-1 text-xs text-white/60">Prüfe Einladungscode…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-xs text-white/60">
|
||||
<label class="flex items-start gap-3">
|
||||
<input type="checkbox" v-model="registerForm.acceptTerms" class="mt-1" required />
|
||||
<span>Ich akzeptiere die <NuxtLink to="/agb" class="text-cyan-300 underline">AGB</NuxtLink>.</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3">
|
||||
<input type="checkbox" v-model="registerForm.acceptPrivacy" class="mt-1" required />
|
||||
<span>Ich habe die <NuxtLink to="/datenschutz" class="text-cyan-300 underline">Datenschutzerklärung</NuxtLink> gelesen und stimme der Datenverarbeitung zu.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full" :disabled="registerLoading || !canRegister">
|
||||
<span v-if="registerLoading" class="flex items-center justify-center gap-2">
|
||||
<v-progress-circular indeterminate size="16" width="2" color="white" />
|
||||
Registriere…
|
||||
</span>
|
||||
<span v-else>Account erstellen</span>
|
||||
</button>
|
||||
|
||||
<p v-if="registerError" class="text-sm text-red-300">{{ registerError }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useHead } from '#imports'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
type Mode = 'login' | 'register'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const api = useApi()
|
||||
|
||||
const mode = ref<Mode>('login')
|
||||
|
||||
const loginForm = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
const loginLoading = ref(false)
|
||||
const loginError = ref('')
|
||||
|
||||
const registerForm = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
invitationCode: '',
|
||||
acceptTerms: false,
|
||||
acceptPrivacy: false,
|
||||
})
|
||||
const registerLoading = ref(false)
|
||||
const registerError = ref('')
|
||||
const invitationStatus = ref<'unknown' | 'checking' | 'valid' | 'invalid'>('unknown')
|
||||
|
||||
const canRegister = computed(() =>
|
||||
Boolean(
|
||||
registerForm.email &&
|
||||
registerForm.password &&
|
||||
registerForm.invitationCode &&
|
||||
registerForm.acceptTerms &&
|
||||
registerForm.acceptPrivacy &&
|
||||
invitationStatus.value !== 'invalid'
|
||||
)
|
||||
)
|
||||
|
||||
async function submitLogin() {
|
||||
if (loginLoading.value) return
|
||||
loginLoading.value = true
|
||||
loginError.value = ''
|
||||
try {
|
||||
await auth.login({ ...loginForm })
|
||||
await auth.fetchUser()
|
||||
router.push('/pm')
|
||||
} catch (err: any) {
|
||||
const message = err?.data?.statusMessage || err?.message || 'Anmeldung fehlgeschlagen'
|
||||
loginError.value = message
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkInvitationCode() {
|
||||
if (!registerForm.invitationCode) {
|
||||
invitationStatus.value = 'unknown'
|
||||
return
|
||||
}
|
||||
invitationStatus.value = 'checking'
|
||||
try {
|
||||
const res: any = await api.get(`/api/service/invitations/${registerForm.invitationCode.trim()}`, { auth: false })
|
||||
invitationStatus.value = res.valid ? 'valid' : 'invalid'
|
||||
} catch {
|
||||
invitationStatus.value = 'invalid'
|
||||
}
|
||||
}
|
||||
|
||||
let codeCheckTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
watch(
|
||||
() => registerForm.invitationCode,
|
||||
(code) => {
|
||||
invitationStatus.value = code ? 'checking' : 'unknown'
|
||||
if (codeCheckTimeout) clearTimeout(codeCheckTimeout)
|
||||
if (!code) return
|
||||
codeCheckTimeout = setTimeout(() => {
|
||||
checkInvitationCode()
|
||||
}, 400)
|
||||
}
|
||||
)
|
||||
|
||||
async function submitRegister() {
|
||||
if (registerLoading.value || !canRegister.value) return
|
||||
registerLoading.value = true
|
||||
registerError.value = ''
|
||||
try {
|
||||
await auth.register({
|
||||
name: registerForm.name,
|
||||
email: registerForm.email,
|
||||
password: registerForm.password,
|
||||
invitationCode: registerForm.invitationCode,
|
||||
acceptTerms: registerForm.acceptTerms,
|
||||
acceptPrivacy: registerForm.acceptPrivacy,
|
||||
})
|
||||
await auth.fetchUser()
|
||||
router.push('/pm')
|
||||
} catch (err: any) {
|
||||
const message = err?.data?.statusMessage || err?.message || 'Registrierung fehlgeschlagen'
|
||||
registerError.value = message
|
||||
} finally {
|
||||
registerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: 'OpenSquawk – Login & Einladung',
|
||||
meta: [
|
||||
{ name: 'robots', content: 'noindex,nofollow' },
|
||||
],
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
router.push('/pm')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chip {
|
||||
@apply inline-flex h-6 w-6 items-center justify-center rounded-full bg-cyan-500/20 text-xs font-medium text-cyan-200;
|
||||
}
|
||||
</style>
|
||||
|
||||
128
app/pages/pm.vue
128
app/pages/pm.vue
@@ -460,8 +460,20 @@
|
||||
<v-card class="bg-white/5 border border-white/10">
|
||||
<v-card-text class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">Communication Log</h3>
|
||||
<v-chip size="small" color="cyan" variant="outlined">{{ log.length }}</v-chip>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-lg font-semibold">Communication Log</h3>
|
||||
<v-chip size="small" color="cyan" variant="outlined">{{ log.length }}</v-chip>
|
||||
</div>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
color="cyan"
|
||||
class="text-xs uppercase tracking-[0.2em]"
|
||||
:disabled="log.length === 0"
|
||||
@click="clearLog"
|
||||
>
|
||||
Leeren
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||
@@ -563,15 +575,22 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import useCommunicationsEngine from "../../shared/utils/communicationsEngine";
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
// Core State
|
||||
const engine = useCommunicationsEngine()
|
||||
const auth = useAuthStore()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const {
|
||||
currentState,
|
||||
nextCandidates,
|
||||
activeFrequency,
|
||||
communicationLog: log,
|
||||
clearCommunicationLog,
|
||||
variables: vars,
|
||||
flags,
|
||||
flightContext,
|
||||
@@ -586,6 +605,11 @@ const {
|
||||
getStateDetails
|
||||
} = engine
|
||||
|
||||
const clearLog = () => {
|
||||
clearCommunicationLog()
|
||||
lastTransmission.value = ''
|
||||
}
|
||||
|
||||
// UI State
|
||||
const currentScreen = ref<'login' | 'flightselect' | 'monitor'>('login')
|
||||
const loading = ref(false)
|
||||
@@ -608,6 +632,32 @@ const frequencies = ref({
|
||||
standby: '118.100'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.accessToken) {
|
||||
const refreshed = await auth.tryRefresh()
|
||||
if (!refreshed) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!auth.user) {
|
||||
await auth.fetchUser().catch((err) => {
|
||||
console.error('Session initialisation failed', err)
|
||||
router.push('/login')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => auth.accessToken,
|
||||
(token) => {
|
||||
if (!token) {
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// VATSIM Integration
|
||||
const vatsimId = ref('1857215')
|
||||
const flightPlans = ref<any[]>([])
|
||||
@@ -821,17 +871,14 @@ const playAudioWithEffects = async (base64: string) => {
|
||||
|
||||
const speakPrepared = async (prepared: PreparedSpeech, options: SpeechOptions = {}) => {
|
||||
try {
|
||||
const response = await $fetch('/api/atc/say', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
text: options.useNormalizedForTTS === false ? prepared.plain : prepared.normalized,
|
||||
level: signalStrength.value,
|
||||
voice: options.voice || 'alloy',
|
||||
speed: 0.95,
|
||||
moduleId: 'pilot-monitoring',
|
||||
lessonId: currentState.value?.id || 'general',
|
||||
tag: options.tag || 'controller-reply'
|
||||
}
|
||||
const response = await api.post('/api/atc/say', {
|
||||
text: options.useNormalizedForTTS === false ? prepared.plain : prepared.normalized,
|
||||
level: signalStrength.value,
|
||||
voice: options.voice || 'alloy',
|
||||
speed: 0.95,
|
||||
moduleId: 'pilot-monitoring',
|
||||
lessonId: currentState.value?.id || 'general',
|
||||
tag: options.tag || 'controller-reply'
|
||||
})
|
||||
|
||||
if (response.success && response.audio) {
|
||||
@@ -898,10 +945,7 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' =
|
||||
const ctx = buildLLMContext(transcript)
|
||||
|
||||
try {
|
||||
const decision = await $fetch('/api/llm/decide', {
|
||||
method: 'POST',
|
||||
body: ctx
|
||||
})
|
||||
const decision = await api.post('/api/llm/decide', ctx)
|
||||
|
||||
applyLLMDecision(decision)
|
||||
|
||||
@@ -922,7 +966,9 @@ const loadFlightPlans = async () => {
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const response = await $fetch(`/api/vatsim/flightplans?cid=${vatsimId.value}`)
|
||||
const response = await api.get('/api/vatsim/flightplans', {
|
||||
query: { cid: vatsimId.value }
|
||||
})
|
||||
|
||||
if (Array.isArray(response) && response.length > 0) {
|
||||
flightPlans.value = response.slice(0, 10)
|
||||
@@ -1039,22 +1085,19 @@ const processTransmission = async (audioBlob: Blob, isIntercom: boolean) => {
|
||||
const base64Audio = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)))
|
||||
|
||||
if (isIntercom) {
|
||||
const result = await $fetch('/api/atc/ptt', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
audio: base64Audio,
|
||||
context: {
|
||||
state_id: currentState.value?.id || 'INTERCOM',
|
||||
state: {},
|
||||
candidates: [],
|
||||
variables: { callsign: vars.value.callsign },
|
||||
flags: {}
|
||||
},
|
||||
moduleId: 'pilot-monitoring-intercom',
|
||||
lessonId: 'intercom',
|
||||
format: 'webm',
|
||||
autoDecide: false
|
||||
}
|
||||
const result = await api.post('/api/atc/ptt', {
|
||||
audio: base64Audio,
|
||||
context: {
|
||||
state_id: currentState.value?.id || 'INTERCOM',
|
||||
state: {},
|
||||
candidates: [],
|
||||
variables: { callsign: vars.value.callsign },
|
||||
flags: {}
|
||||
},
|
||||
moduleId: 'pilot-monitoring-intercom',
|
||||
lessonId: 'intercom',
|
||||
format: 'webm',
|
||||
autoDecide: false
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
@@ -1072,16 +1115,13 @@ const processTransmission = async (audioBlob: Blob, isIntercom: boolean) => {
|
||||
} else {
|
||||
const ctx = buildLLMContext('')
|
||||
|
||||
const result = await $fetch('/api/atc/ptt', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
audio: base64Audio,
|
||||
context: ctx,
|
||||
moduleId: 'pilot-monitoring',
|
||||
lessonId: currentState.value?.id || 'general',
|
||||
format: 'webm',
|
||||
autoDecide: false
|
||||
}
|
||||
const result = await api.post('/api/atc/ptt', {
|
||||
audio: base64Audio,
|
||||
context: ctx,
|
||||
moduleId: 'pilot-monitoring',
|
||||
lessonId: currentState.value?.id || 'general',
|
||||
format: 'webm',
|
||||
autoDecide: false
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
|
||||
128
app/stores/auth.ts
Normal file
128
app/stores/auth.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
interface Credentials {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
interface RegisterPayload extends Credentials {
|
||||
name?: string
|
||||
invitationCode: string
|
||||
acceptTerms: boolean
|
||||
acceptPrivacy: boolean
|
||||
}
|
||||
|
||||
interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
name?: string
|
||||
role: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string
|
||||
user: AuthUser | null
|
||||
initialized: boolean
|
||||
}
|
||||
|
||||
function loadToken(): string {
|
||||
if (typeof window === 'undefined') return ''
|
||||
return localStorage.getItem('os_access_token') || ''
|
||||
}
|
||||
|
||||
function persistToken(token: string) {
|
||||
if (typeof window === 'undefined') return
|
||||
if (token) {
|
||||
localStorage.setItem('os_access_token', token)
|
||||
} else {
|
||||
localStorage.removeItem('os_access_token')
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): AuthState => ({
|
||||
accessToken: loadToken(),
|
||||
user: null,
|
||||
initialized: false,
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: (state) => Boolean(state.accessToken),
|
||||
},
|
||||
actions: {
|
||||
setAccessToken(token: string) {
|
||||
this.accessToken = token
|
||||
persistToken(token)
|
||||
},
|
||||
setUser(user: AuthUser | null) {
|
||||
this.user = user
|
||||
},
|
||||
async login(payload: Credentials) {
|
||||
const response = await $fetch<{ accessToken: string; user: AuthUser }>('/api/service/auth/login', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
this.setAccessToken(response.accessToken)
|
||||
this.setUser(response.user)
|
||||
return response.user
|
||||
},
|
||||
async register(payload: RegisterPayload) {
|
||||
const response = await $fetch<{ accessToken: string; user: AuthUser }>('/api/service/auth/register', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
this.setAccessToken(response.accessToken)
|
||||
this.setUser(response.user)
|
||||
return response.user
|
||||
},
|
||||
async tryRefresh() {
|
||||
try {
|
||||
const response = await $fetch<{ accessToken: string }>('/api/service/auth/refresh', {
|
||||
method: 'POST',
|
||||
})
|
||||
this.setAccessToken(response.accessToken)
|
||||
return true
|
||||
} catch {
|
||||
this.setAccessToken('')
|
||||
return false
|
||||
}
|
||||
},
|
||||
async fetchUser() {
|
||||
if (!this.accessToken) {
|
||||
this.setUser(null)
|
||||
this.initialized = true
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const user = await $fetch<AuthUser>('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${this.accessToken}` },
|
||||
})
|
||||
this.setUser(user)
|
||||
this.initialized = true
|
||||
return user
|
||||
} catch (err) {
|
||||
console.warn('Failed to load user session', err)
|
||||
this.setAccessToken('')
|
||||
this.setUser(null)
|
||||
this.initialized = true
|
||||
return null
|
||||
}
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
if (this.accessToken) {
|
||||
await $fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${this.accessToken}` },
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Logout failed', err)
|
||||
} finally {
|
||||
this.setAccessToken('')
|
||||
this.setUser(null)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -16,7 +16,15 @@ export default defineNuxtConfig({
|
||||
openaiKey: process.env.OPENAI_API_KEY,
|
||||
llmModel: process.env.LLM_MODEL || 'gpt-5-nano',
|
||||
ttsModel: process.env.TTS_MODEL || 'tts-1',
|
||||
public: {}
|
||||
jwtSecret: process.env.JWT_SECRET,
|
||||
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET,
|
||||
mongoose: {
|
||||
uri: process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/opensquawk',
|
||||
options: {},
|
||||
},
|
||||
public: {
|
||||
apiDocumentationUrl: '/api-docs',
|
||||
},
|
||||
},
|
||||
vuetify: {
|
||||
vuetifyOptions: {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { randomUUID } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { openai, routeDecision } from "../../utils/openai";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { TransmissionLog } from "../../models/TransmissionLog";
|
||||
import { getUserFromEvent } from "../../utils/auth";
|
||||
|
||||
interface PTTRequest {
|
||||
audio: string; // Base64 encoded audio
|
||||
@@ -120,6 +122,25 @@ export default defineEventHandler(async (event) => {
|
||||
await rm(tmpAudioWav).catch(() => {});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getUserFromEvent(event)
|
||||
await TransmissionLog.create({
|
||||
user: user?._id,
|
||||
role: "pilot",
|
||||
channel: "ptt",
|
||||
direction: "incoming",
|
||||
text: transcribedText,
|
||||
metadata: {
|
||||
moduleId: body.moduleId,
|
||||
lessonId: body.lessonId,
|
||||
decision,
|
||||
autoDecide: shouldAutoDecide,
|
||||
},
|
||||
})
|
||||
} catch (logError) {
|
||||
console.warn("Transmission logging failed", logError)
|
||||
}
|
||||
|
||||
const result: PTTResponse = {
|
||||
success: true,
|
||||
transcription: transcribedText
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
// server/api/atc/say.post.ts
|
||||
import { createError, readBody } from "h3";
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { normalize, TTS_MODEL, normalizeATC } from "../../utils/normalize";
|
||||
import { request } from "node:http";
|
||||
// dotenv
|
||||
import { config } from "dotenv";
|
||||
import {createError, readBody} from "h3";
|
||||
import {writeFile, mkdir} from "node:fs/promises";
|
||||
import {existsSync} from "node:fs";
|
||||
import {join} from "node:path";
|
||||
import {randomUUID} from "node:crypto";
|
||||
import {normalize, TTS_MODEL, normalizeATC} from "../../utils/normalize";
|
||||
import {request} from "node:http";
|
||||
import { TransmissionLog } from "../../models/TransmissionLog";
|
||||
import { getUserFromEvent } from "../../utils/auth";
|
||||
|
||||
// dotenv config
|
||||
import {config} from "dotenv";
|
||||
|
||||
config();
|
||||
|
||||
|
||||
function outDir() {
|
||||
return process.env.ATC_OUT_DIR?.trim() || join(process.cwd(), "storage", "atc");
|
||||
}
|
||||
@@ -206,8 +211,32 @@ export default defineEventHandler(async (event) => {
|
||||
model: modelUsed,
|
||||
format: actualMime
|
||||
};
|
||||
|
||||
// await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8");
|
||||
|
||||
try {
|
||||
const user = await getUserFromEvent(event)
|
||||
await TransmissionLog.create({
|
||||
user: user?._id,
|
||||
role: "atc",
|
||||
channel: "say",
|
||||
direction: "outgoing",
|
||||
text: raw,
|
||||
normalized,
|
||||
metadata: {
|
||||
level,
|
||||
voice,
|
||||
speed,
|
||||
moduleId: body?.moduleId || null,
|
||||
lessonId: body?.lessonId || null,
|
||||
tag: body?.tag || null,
|
||||
radioQuality: radioQuality.description,
|
||||
}
|
||||
})
|
||||
} catch (logError) {
|
||||
console.warn("Transmission logging failed", logError)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id,
|
||||
|
||||
15
server/api/auth/invitations.get.ts
Normal file
15
server/api/auth/invitations.get.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { requireUserSession } from '../../utils/auth'
|
||||
import { InvitationCode } from '../../models/InvitationCode'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserSession(event)
|
||||
const codes = await InvitationCode.find({ createdBy: user._id }).sort({ createdAt: -1 }).lean()
|
||||
return codes.map((code) => ({
|
||||
code: code.code,
|
||||
createdAt: code.createdAt,
|
||||
expiresAt: code.expiresAt,
|
||||
usedAt: code.usedAt,
|
||||
usedBy: code.usedBy ? String(code.usedBy) : null,
|
||||
}))
|
||||
})
|
||||
|
||||
49
server/api/auth/invitations.post.ts
Normal file
49
server/api/auth/invitations.post.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createError } from 'h3'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { requireUserSession } from '../../utils/auth'
|
||||
import { InvitationCode } from '../../models/InvitationCode'
|
||||
|
||||
function generateCode() {
|
||||
return randomBytes(4).toString('hex').toUpperCase()
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserSession(event)
|
||||
|
||||
const accountAgeMs = Date.now() - new Date(user.createdAt).getTime()
|
||||
const twoWeeksMs = 1000 * 60 * 60 * 24 * 14
|
||||
|
||||
if (accountAgeMs < twoWeeksMs) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Einladungscodes stehen nach 14 Tagen zur Verfügung' })
|
||||
}
|
||||
|
||||
if (user.invitationCodesIssued >= 2) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Limit von zwei Einladungscodes erreicht' })
|
||||
}
|
||||
|
||||
const activeCodes = await InvitationCode.countDocuments({ createdBy: user._id, usedBy: { $exists: false } })
|
||||
if (activeCodes >= 2) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Bitte vorhandene Einladungscodes zuerst nutzen' })
|
||||
}
|
||||
|
||||
const code = generateCode()
|
||||
const now = new Date()
|
||||
const expiresAt = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 30)
|
||||
|
||||
await InvitationCode.create({
|
||||
code,
|
||||
createdBy: user._id,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
})
|
||||
|
||||
user.invitationCodesIssued += 1
|
||||
await user.save()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
code,
|
||||
expiresAt,
|
||||
}
|
||||
})
|
||||
|
||||
10
server/api/auth/logout.post.ts
Normal file
10
server/api/auth/logout.post.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { clearRefreshTokenCookie, requireUserSession } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserSession(event)
|
||||
user.tokenVersion += 1
|
||||
await user.save()
|
||||
clearRefreshTokenCookie(event)
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
15
server/api/auth/me.get.ts
Normal file
15
server/api/auth/me.get.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { requireUserSession } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserSession(event)
|
||||
return {
|
||||
id: String(user._id),
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
invitationCodesIssued: user.invitationCodesIssued,
|
||||
}
|
||||
})
|
||||
|
||||
46
server/api/service/auth/login.post.ts
Normal file
46
server/api/service/auth/login.post.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { issueAuthTokens, verifyPassword } from '../../../utils/auth'
|
||||
import { User } from '../../../models/User'
|
||||
|
||||
interface LoginBody {
|
||||
email?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<LoginBody>(event)
|
||||
const email = body.email?.trim().toLowerCase()
|
||||
const password = body.password?.trim()
|
||||
|
||||
if (!email || !password) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte E-Mail und Passwort angeben' })
|
||||
}
|
||||
|
||||
const user = await User.findOne({ email })
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Ungültige Zugangsdaten' })
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, user.passwordHash)
|
||||
if (!valid) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Ungültige Zugangsdaten' })
|
||||
}
|
||||
|
||||
user.lastLoginAt = new Date()
|
||||
await user.save()
|
||||
|
||||
const tokens = await issueAuthTokens(event, user)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accessToken: tokens.accessToken,
|
||||
user: {
|
||||
id: String(user._id),
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
10
server/api/service/auth/refresh.post.ts
Normal file
10
server/api/service/auth/refresh.post.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { rotateRefreshToken } from '../../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const tokens = await rotateRefreshToken(event)
|
||||
return {
|
||||
success: true,
|
||||
accessToken: tokens.accessToken,
|
||||
}
|
||||
})
|
||||
|
||||
78
server/api/service/auth/register.post.ts
Normal file
78
server/api/service/auth/register.post.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { hashPassword, issueAuthTokens } from '../../../utils/auth'
|
||||
import { User } from '../../../models/User'
|
||||
import { InvitationCode } from '../../../models/InvitationCode'
|
||||
import { WaitlistEntry } from '../../../models/WaitlistEntry'
|
||||
|
||||
interface RegisterBody {
|
||||
email?: string
|
||||
password?: string
|
||||
name?: string
|
||||
invitationCode?: string
|
||||
acceptTerms?: boolean
|
||||
acceptPrivacy?: boolean
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<RegisterBody>(event)
|
||||
const email = body.email?.trim().toLowerCase()
|
||||
const password = body.password?.trim()
|
||||
const name = body.name?.trim()
|
||||
const code = body.invitationCode?.trim().toUpperCase()
|
||||
|
||||
if (!email || !password || !code) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte E-Mail, Passwort und Einladungscode angeben' })
|
||||
}
|
||||
|
||||
if (!body.acceptPrivacy || !body.acceptTerms) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte AGB und Datenschutz bestätigen' })
|
||||
}
|
||||
|
||||
const existingUser = await User.findOne({ email })
|
||||
if (existingUser) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'Für diese E-Mail existiert bereits ein Konto' })
|
||||
}
|
||||
|
||||
const invitation = await InvitationCode.findOne({ code })
|
||||
if (!invitation) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Einladungscode nicht gefunden' })
|
||||
}
|
||||
if (invitation.usedBy) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Einladungscode wurde bereits verwendet' })
|
||||
}
|
||||
if (invitation.expiresAt && invitation.expiresAt < new Date()) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Einladungscode ist abgelaufen' })
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password)
|
||||
const now = new Date()
|
||||
|
||||
const user = await User.create({
|
||||
email,
|
||||
passwordHash,
|
||||
name,
|
||||
acceptedPrivacyAt: now,
|
||||
acceptedTermsAt: now,
|
||||
})
|
||||
|
||||
invitation.usedBy = user._id
|
||||
invitation.usedAt = now
|
||||
await invitation.save()
|
||||
|
||||
await WaitlistEntry.findOneAndUpdate({ email }, { activatedAt: now }).catch(() => undefined)
|
||||
|
||||
const tokens = await issueAuthTokens(event, user)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accessToken: tokens.accessToken,
|
||||
user: {
|
||||
id: String(user._id),
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
30
server/api/service/invitations/[code].get.ts
Normal file
30
server/api/service/invitations/[code].get.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createError } from 'h3'
|
||||
import { InvitationCode } from '../../../models/InvitationCode'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { code } = event.context.params || {}
|
||||
if (!code) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Code fehlt' })
|
||||
}
|
||||
|
||||
const normalized = String(code).trim().toUpperCase()
|
||||
const invitation = await InvitationCode.findOne({ code: normalized })
|
||||
|
||||
if (!invitation) {
|
||||
return { valid: false }
|
||||
}
|
||||
if (invitation.usedBy) {
|
||||
return { valid: false, reason: 'used' }
|
||||
}
|
||||
if (invitation.expiresAt && invitation.expiresAt < new Date()) {
|
||||
return { valid: false, reason: 'expired' }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
code: invitation.code,
|
||||
createdAt: invitation.createdAt,
|
||||
expiresAt: invitation.expiresAt,
|
||||
}
|
||||
})
|
||||
|
||||
28
server/api/service/waitlist.get.ts
Normal file
28
server/api/service/waitlist.get.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { WaitlistEntry } from '../../models/WaitlistEntry'
|
||||
|
||||
function maskEmail(email: string) {
|
||||
const [user, domain] = email.split('@')
|
||||
if (!domain) return email
|
||||
const visible = user.slice(0, 2)
|
||||
return `${visible}${'•'.repeat(Math.max(user.length - 2, 1))}@${domain}`
|
||||
}
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const [count, latest] = await Promise.all([
|
||||
WaitlistEntry.countDocuments(),
|
||||
WaitlistEntry.find().sort({ joinedAt: -1 }).limit(8).lean(),
|
||||
])
|
||||
|
||||
const members = latest.map((entry) => ({
|
||||
name: entry.name || maskEmail(entry.email),
|
||||
email: maskEmail(entry.email),
|
||||
joinedAt: entry.joinedAt,
|
||||
activatedAt: entry.activatedAt ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
count,
|
||||
members,
|
||||
}
|
||||
})
|
||||
|
||||
62
server/api/service/waitlist.post.ts
Normal file
62
server/api/service/waitlist.post.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { readBody, createError } from 'h3'
|
||||
import { WaitlistEntry } from '../../models/WaitlistEntry'
|
||||
|
||||
interface WaitlistRequestBody {
|
||||
email?: string
|
||||
name?: string
|
||||
notes?: string
|
||||
consentPrivacy?: boolean
|
||||
consentTerms?: boolean
|
||||
source?: string
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<WaitlistRequestBody>(event)
|
||||
const email = body.email?.trim().toLowerCase()
|
||||
const name = body.name?.trim()
|
||||
const notes = body.notes?.trim()
|
||||
const source = body.source?.trim() || 'landing'
|
||||
|
||||
if (!email) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
|
||||
}
|
||||
|
||||
if (!body.consentPrivacy || !body.consentTerms) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Zustimmung zu AGB und Datenschutz ist erforderlich' })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const existing = await WaitlistEntry.findOne({ email })
|
||||
|
||||
if (existing) {
|
||||
existing.name = name || existing.name
|
||||
existing.notes = notes || existing.notes
|
||||
existing.source = source
|
||||
existing.consentPrivacy = true
|
||||
existing.consentTerms = true
|
||||
await existing.save()
|
||||
return {
|
||||
success: true,
|
||||
alreadyJoined: true,
|
||||
joinedAt: existing.joinedAt,
|
||||
}
|
||||
}
|
||||
|
||||
const entry = await WaitlistEntry.create({
|
||||
email,
|
||||
name,
|
||||
notes,
|
||||
source,
|
||||
consentPrivacy: true,
|
||||
consentTerms: true,
|
||||
joinedAt: now,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
alreadyJoined: false,
|
||||
joinedAt: entry.joinedAt,
|
||||
}
|
||||
})
|
||||
|
||||
17
server/middleware/auth.global.ts
Normal file
17
server/middleware/auth.global.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineEventHandler, getRequestURL } from 'h3'
|
||||
import { requireUserSession } from '../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const url = getRequestURL(event)
|
||||
if (!url.pathname.startsWith('/api')) {
|
||||
return
|
||||
}
|
||||
if (url.pathname.startsWith('/api/service/')) {
|
||||
return
|
||||
}
|
||||
if (event.node.req.method === 'OPTIONS') {
|
||||
return
|
||||
}
|
||||
await requireUserSession(event)
|
||||
})
|
||||
|
||||
26
server/models/InvitationCode.ts
Normal file
26
server/models/InvitationCode.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import mongoose from 'mongoose'
|
||||
|
||||
const { Schema } = mongoose
|
||||
|
||||
export interface InvitationCodeDocument extends mongoose.Document {
|
||||
code: string
|
||||
createdBy: mongoose.Types.ObjectId
|
||||
createdAt: Date
|
||||
expiresAt?: Date
|
||||
usedBy?: mongoose.Types.ObjectId
|
||||
usedAt?: Date
|
||||
}
|
||||
|
||||
const invitationSchema = new mongoose.Schema<InvitationCodeDocument>({
|
||||
code: { type: String, required: true, unique: true, uppercase: true, trim: true },
|
||||
createdBy: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
createdAt: { type: Date, default: () => new Date() },
|
||||
expiresAt: { type: Date },
|
||||
usedBy: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||
usedAt: { type: Date },
|
||||
})
|
||||
|
||||
export const InvitationCode =
|
||||
(mongoose.models.InvitationCode as mongoose.Model<InvitationCodeDocument> | undefined) ||
|
||||
mongoose.model<InvitationCodeDocument>('InvitationCode', invitationSchema)
|
||||
|
||||
30
server/models/TransmissionLog.ts
Normal file
30
server/models/TransmissionLog.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import mongoose from 'mongoose'
|
||||
|
||||
const { Schema } = mongoose
|
||||
|
||||
export interface TransmissionLogDocument extends mongoose.Document {
|
||||
user?: mongoose.Types.ObjectId
|
||||
role: string
|
||||
channel: 'ptt' | 'say' | 'text'
|
||||
direction: 'incoming' | 'outgoing'
|
||||
text: string
|
||||
normalized?: string
|
||||
metadata?: Record<string, any>
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
const transmissionSchema = new mongoose.Schema<TransmissionLogDocument>({
|
||||
user: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||
role: { type: String, required: true },
|
||||
channel: { type: String, enum: ['ptt', 'say', 'text'], required: true },
|
||||
direction: { type: String, enum: ['incoming', 'outgoing'], required: true },
|
||||
text: { type: String, required: true },
|
||||
normalized: { type: String },
|
||||
metadata: { type: Schema.Types.Mixed },
|
||||
createdAt: { type: Date, default: () => new Date() },
|
||||
})
|
||||
|
||||
export const TransmissionLog =
|
||||
(mongoose.models.TransmissionLog as mongoose.Model<TransmissionLogDocument> | undefined) ||
|
||||
mongoose.model<TransmissionLogDocument>('TransmissionLog', transmissionSchema)
|
||||
|
||||
32
server/models/User.ts
Normal file
32
server/models/User.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import mongoose from 'mongoose'
|
||||
|
||||
export interface UserDocument extends mongoose.Document {
|
||||
email: string
|
||||
passwordHash: string
|
||||
name?: string
|
||||
role: 'user' | 'admin'
|
||||
createdAt: Date
|
||||
lastLoginAt?: Date
|
||||
tokenVersion: number
|
||||
invitationCodesIssued: number
|
||||
acceptedTermsAt: Date
|
||||
acceptedPrivacyAt: Date
|
||||
}
|
||||
|
||||
const userSchema = new mongoose.Schema<UserDocument>({
|
||||
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
|
||||
passwordHash: { type: String, required: true },
|
||||
name: { type: String, trim: true },
|
||||
role: { type: String, enum: ['user', 'admin'], default: 'user' },
|
||||
createdAt: { type: Date, default: () => new Date() },
|
||||
lastLoginAt: { type: Date },
|
||||
tokenVersion: { type: Number, default: 0 },
|
||||
invitationCodesIssued: { type: Number, default: 0 },
|
||||
acceptedTermsAt: { type: Date, required: true },
|
||||
acceptedPrivacyAt: { type: Date, required: true },
|
||||
})
|
||||
|
||||
export const User =
|
||||
(mongoose.models.User as mongoose.Model<UserDocument> | undefined) ||
|
||||
mongoose.model<UserDocument>('User', userSchema)
|
||||
|
||||
28
server/models/WaitlistEntry.ts
Normal file
28
server/models/WaitlistEntry.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import mongoose from 'mongoose'
|
||||
|
||||
export interface WaitlistEntryDocument extends mongoose.Document {
|
||||
email: string
|
||||
name?: string
|
||||
notes?: string
|
||||
source?: string
|
||||
consentPrivacy: boolean
|
||||
consentTerms: boolean
|
||||
joinedAt: Date
|
||||
activatedAt?: Date
|
||||
}
|
||||
|
||||
const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
|
||||
email: { type: String, required: true, lowercase: true, trim: true, unique: true },
|
||||
name: { type: String, trim: true },
|
||||
notes: { type: String, trim: true },
|
||||
source: { type: String, default: 'landing' },
|
||||
consentPrivacy: { type: Boolean, default: false },
|
||||
consentTerms: { type: Boolean, default: false },
|
||||
joinedAt: { type: Date, default: () => new Date() },
|
||||
activatedAt: { type: Date },
|
||||
})
|
||||
|
||||
export const WaitlistEntry =
|
||||
(mongoose.models.WaitlistEntry as mongoose.Model<WaitlistEntryDocument> | undefined) ||
|
||||
mongoose.model<WaitlistEntryDocument>('WaitlistEntry', waitlistSchema)
|
||||
|
||||
206
server/utils/auth.ts
Normal file
206
server/utils/auth.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { createError, getHeader, H3Event, setCookie, deleteCookie } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { createHmac, randomBytes, timingSafeEqual, scrypt as _scrypt } from 'node:crypto'
|
||||
import { promisify } from 'node:util'
|
||||
import type { UserDocument } from '../models/User'
|
||||
import { User } from '../models/User'
|
||||
|
||||
const scrypt = promisify(_scrypt) as (password: string | Buffer, salt: string | Buffer, keylen: number) => Promise<Buffer>
|
||||
|
||||
const ACCESS_TOKEN_TTL_SECONDS = 60 * 15
|
||||
const REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 7
|
||||
const REFRESH_COOKIE_NAME = 'os_refresh_token'
|
||||
const PASSWORD_SALT_BYTES = 16
|
||||
const PASSWORD_KEYLEN = 64
|
||||
|
||||
function getSecrets() {
|
||||
const config = useRuntimeConfig()
|
||||
if (!config.jwtSecret) {
|
||||
throw new Error('JWT secret missing – bitte JWT_SECRET in .env setzen')
|
||||
}
|
||||
return {
|
||||
accessSecret: config.jwtSecret as string,
|
||||
refreshSecret: (config.jwtRefreshSecret as string) || (config.jwtSecret as string),
|
||||
}
|
||||
}
|
||||
|
||||
function base64url(buffer: Buffer) {
|
||||
return buffer.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
|
||||
}
|
||||
|
||||
function fromBase64url(input: string) {
|
||||
let sanitized = input.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const pad = sanitized.length % 4
|
||||
if (pad === 2) sanitized += '=='
|
||||
else if (pad === 3) sanitized += '='
|
||||
else if (pad !== 0) sanitized += '==='
|
||||
return Buffer.from(sanitized, 'base64')
|
||||
}
|
||||
|
||||
function createJwtToken(payload: Record<string, any>, secret: string, ttlSeconds: number) {
|
||||
const header = { alg: 'HS256', typ: 'JWT' }
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const body = { ...payload, iat: now, exp: now + ttlSeconds }
|
||||
const encodedHeader = base64url(Buffer.from(JSON.stringify(header)))
|
||||
const encodedPayload = base64url(Buffer.from(JSON.stringify(body)))
|
||||
const data = `${encodedHeader}.${encodedPayload}`
|
||||
const signature = createHmac('sha256', secret).update(data).digest()
|
||||
return `${data}.${base64url(signature)}`
|
||||
}
|
||||
|
||||
function verifyJwtToken(token: string, secret: string) {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) throw new Error('Malformed token')
|
||||
const [encodedHeader, encodedPayload, signature] = parts
|
||||
const data = `${encodedHeader}.${encodedPayload}`
|
||||
const expectedSignature = createHmac('sha256', secret).update(data).digest()
|
||||
const receivedSignature = fromBase64url(signature)
|
||||
if (receivedSignature.length !== expectedSignature.length || !timingSafeEqual(receivedSignature, expectedSignature)) {
|
||||
throw new Error('Invalid signature')
|
||||
}
|
||||
const header = JSON.parse(fromBase64url(encodedHeader).toString('utf8'))
|
||||
if (header.alg !== 'HS256') throw new Error('Unsupported algorithm')
|
||||
const payload = JSON.parse(fromBase64url(encodedPayload).toString('utf8'))
|
||||
if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) {
|
||||
throw new Error('Token expired')
|
||||
}
|
||||
return payload as Record<string, any>
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
const salt = randomBytes(PASSWORD_SALT_BYTES)
|
||||
const derived = await scrypt(password, salt, PASSWORD_KEYLEN)
|
||||
return `${salt.toString('hex')}.${derived.toString('hex')}`
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, stored: string) {
|
||||
const [saltHex, hashHex] = stored.split('.')
|
||||
if (!saltHex || !hashHex) return false
|
||||
const salt = Buffer.from(saltHex, 'hex')
|
||||
const expected = Buffer.from(hashHex, 'hex')
|
||||
const derived = await scrypt(password, salt, expected.length)
|
||||
if (derived.length !== expected.length) return false
|
||||
return timingSafeEqual(derived, expected)
|
||||
}
|
||||
|
||||
export function createAccessToken(user: UserDocument) {
|
||||
const { accessSecret } = getSecrets()
|
||||
return createJwtToken(
|
||||
{
|
||||
sub: String(user._id),
|
||||
email: user.email,
|
||||
version: user.tokenVersion,
|
||||
},
|
||||
accessSecret,
|
||||
ACCESS_TOKEN_TTL_SECONDS,
|
||||
)
|
||||
}
|
||||
|
||||
export function createRefreshToken(user: UserDocument) {
|
||||
const { refreshSecret } = getSecrets()
|
||||
return createJwtToken(
|
||||
{
|
||||
sub: String(user._id),
|
||||
type: 'refresh',
|
||||
version: user.tokenVersion,
|
||||
},
|
||||
refreshSecret,
|
||||
REFRESH_TOKEN_TTL_SECONDS,
|
||||
)
|
||||
}
|
||||
|
||||
export function setRefreshTokenCookie(event: H3Event, token: string) {
|
||||
setCookie(event, REFRESH_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: REFRESH_TOKEN_TTL_SECONDS,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
})
|
||||
}
|
||||
|
||||
export function clearRefreshTokenCookie(event: H3Event) {
|
||||
deleteCookie(event, REFRESH_COOKIE_NAME, { path: '/' })
|
||||
}
|
||||
|
||||
function parseAuthorizationHeader(event: H3Event) {
|
||||
const header = getHeader(event, 'authorization')
|
||||
if (!header) return null
|
||||
const [scheme, token] = header.split(' ')
|
||||
if (!token || scheme?.toLowerCase() !== 'bearer') return null
|
||||
return token
|
||||
}
|
||||
|
||||
export async function resolveUserFromToken(event: H3Event) {
|
||||
const token = parseAuthorizationHeader(event)
|
||||
if (!token) return null
|
||||
try {
|
||||
const { accessSecret } = getSecrets()
|
||||
const payload = verifyJwtToken(token, accessSecret)
|
||||
if (!payload?.sub) return null
|
||||
const user = await User.findById(payload.sub)
|
||||
if (!user) return null
|
||||
if (typeof payload.version === 'number' && payload.version !== user.tokenVersion) {
|
||||
return null
|
||||
}
|
||||
return user
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireUserSession(event: H3Event) {
|
||||
if (event.context?.user) {
|
||||
return event.context.user as UserDocument
|
||||
}
|
||||
const user = await resolveUserFromToken(event)
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
|
||||
}
|
||||
event.context.user = user
|
||||
return user
|
||||
}
|
||||
|
||||
export async function getUserFromEvent(event: H3Event) {
|
||||
if (event.context?.user) return event.context.user as UserDocument
|
||||
const user = await resolveUserFromToken(event)
|
||||
if (user) {
|
||||
event.context.user = user
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
export async function issueAuthTokens(event: H3Event, user: UserDocument) {
|
||||
const accessToken = createAccessToken(user)
|
||||
const refreshToken = createRefreshToken(user)
|
||||
setRefreshTokenCookie(event, refreshToken)
|
||||
return { accessToken }
|
||||
}
|
||||
|
||||
export async function rotateRefreshToken(event: H3Event) {
|
||||
const cookieHeader = event.node.req.headers?.cookie || ''
|
||||
const match = cookieHeader.split(';').map((p) => p.trim()).find((p) => p.startsWith(`${REFRESH_COOKIE_NAME}=`))
|
||||
if (!match) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'No refresh token present' })
|
||||
}
|
||||
const token = match.substring(REFRESH_COOKIE_NAME.length + 1)
|
||||
try {
|
||||
const { refreshSecret } = getSecrets()
|
||||
const payload = verifyJwtToken(token, refreshSecret)
|
||||
if (!payload?.sub || payload.type !== 'refresh') {
|
||||
throw new Error('Invalid token payload')
|
||||
}
|
||||
const user = await User.findById(payload.sub)
|
||||
if (!user) {
|
||||
throw new Error('User missing')
|
||||
}
|
||||
if (typeof payload.version === 'number' && payload.version !== user.tokenVersion) {
|
||||
throw new Error('Token version mismatch')
|
||||
}
|
||||
return issueAuthTokens(event, user)
|
||||
} catch (err) {
|
||||
clearRefreshTokenCookie(event)
|
||||
throw createError({ statusCode: 401, statusMessage: 'Refresh token invalid' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +477,12 @@ export default function useCommunicationsEngine() {
|
||||
}
|
||||
|
||||
function exposeCtx() {
|
||||
return { variables: variables.value, flags: flags.value }
|
||||
return {
|
||||
...variables.value,
|
||||
...flags.value,
|
||||
variables: variables.value,
|
||||
flags: flags.value,
|
||||
}
|
||||
}
|
||||
|
||||
function exposeCtxFlat() {
|
||||
@@ -559,6 +564,7 @@ export default function useCommunicationsEngine() {
|
||||
nextCandidates,
|
||||
activeFrequency,
|
||||
communicationLog: readonly(communicationLog),
|
||||
clearCommunicationLog: () => { communicationLog.value = [] },
|
||||
|
||||
// pm_alt.vue Integration
|
||||
flightContext: readonly(flightContext),
|
||||
|
||||
Reference in New Issue
Block a user