Implement authentication, waitlist, and logging upgrades

This commit is contained in:
Remi
2025-09-16 17:28:34 +02:00
parent a7eebf0baf
commit 19f253f53e
12 changed files with 620 additions and 46 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' }),
}
}

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