mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-10 19:36:04 +08:00
Implement authentication, waitlist, and logging upgrades
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' }),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user