resolve merge conflict

This commit is contained in:
itsrubberduck
2025-09-16 17:44:29 +02:00
29 changed files with 1860 additions and 73 deletions

68
app/composables/useApi.ts Normal file
View 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
View 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
View 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 &lt;token&gt;</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
View 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. 1518 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
View 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>

View File

@@ -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="EMail" 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 NuxtAOS 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
View 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>

View File

@@ -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
View 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)
}
},
},
})

View File

@@ -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: {

View File

@@ -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

View File

@@ -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,

View 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,
}))
})

View 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,
}
})

View 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
View 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,
}
})

View 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,
},
}
})

View 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,
}
})

View 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,
},
}
})

View 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,
}
})

View 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,
}
})

View 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,
}
})

View 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)
})

View 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)

View 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
View 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)

View 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
View 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' })
}
}

View File

@@ -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),