mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-15 10:36:17 +08:00
488 lines
18 KiB
Vue
488 lines
18 KiB
Vue
<template>
|
||
<div class="relative min-h-screen bg-[#0B1020] text-white">
|
||
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(22,187,215,0.14),transparent_55%)]"/>
|
||
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_bottom,_rgba(79,70,229,0.12),transparent_60%)]"/>
|
||
|
||
<main class="relative mx-auto w-full max-w-3xl px-5 py-12 sm:px-6 lg:px-8">
|
||
<nav class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<NuxtLink
|
||
to="/"
|
||
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-white transition hover:border-white/20 hover:bg-white/10 sm:w-auto"
|
||
>
|
||
<span aria-hidden="true">←</span>
|
||
Back to opensquawk.de
|
||
</NuxtLink>
|
||
<NuxtLink
|
||
to="/bridge"
|
||
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-[#292D3B] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#353a4c] sm:w-auto"
|
||
>
|
||
Bridge landing
|
||
</NuxtLink>
|
||
</nav>
|
||
|
||
<header class="mt-10 space-y-4 text-center sm:text-left">
|
||
<p class="text-xs font-semibold uppercase tracking-[0.45em] text-[#16BBD7]">OpenSquawk Bridge</p>
|
||
<h1 class="text-3xl font-semibold sm:text-4xl">Link your Bridge in seconds</h1>
|
||
<p class="mx-auto max-w-2xl text-sm text-white/70 sm:mx-0">Follow the prompt from the desktop Bridge app and you’ll be ready to fly.</p>
|
||
</header>
|
||
|
||
<div
|
||
v-if="!hasToken"
|
||
class="mt-10 rounded-3xl border border-red-500/30 bg-red-500/10 px-6 py-10 text-center shadow-[0_25px_80px_rgba(64,18,18,0.35)] sm:px-8"
|
||
>
|
||
<h2 class="text-2xl font-semibold">Open from the Bridge app</h2>
|
||
<p class="mt-4 text-sm text-red-100/80">Launch the desktop Bridge and tap the “Link account” button so we can pass the code over automatically.</p>
|
||
<p class="mt-6 text-xs uppercase tracking-[0.38em] text-red-200/60">Waiting for desktop link</p>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<section class="mt-10 rounded-3xl border border-white/10 bg-[#111832]/85 p-6 shadow-[0_24px_70px_rgba(5,10,30,0.55)] sm:p-8">
|
||
<div class="flex items-center gap-3">
|
||
<span class="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#16BBD7]/15 text-sm font-semibold text-[#16BBD7]">1</span>
|
||
<div>
|
||
<p class="text-xs uppercase tracking-[0.32em] text-white/55">Step 1</p>
|
||
<h2 class="text-lg font-semibold">Sign in</h2>
|
||
</div>
|
||
</div>
|
||
<p class="mt-4 text-sm text-white/70">Use your OpenSquawk login so we can link the Bridge to the right account.</p>
|
||
|
||
<div v-if="isAuthenticated" class="mt-6 rounded-2xl border border-[#16BBD7]/40 bg-[#16BBD7]/10 px-5 py-4 text-sm">
|
||
<p class="font-medium text-[#16BBD7]">You’re signed in</p>
|
||
<p class="mt-1 text-white/70">Logged in as <span class="font-semibold text-white">{{ authDisplayName }}</span>.</p>
|
||
</div>
|
||
|
||
<form v-else class="mt-6 space-y-4" @submit.prevent="submitLogin">
|
||
<div class="space-y-2">
|
||
<label class="block text-sm font-medium text-white/70">Email</label>
|
||
<input
|
||
v-model.trim="loginForm.email"
|
||
type="email"
|
||
required
|
||
autocomplete="email"
|
||
class="w-full rounded-2xl border border-white/10 bg-[#0B132A]/90 px-4 py-3 text-sm text-white placeholder-white/40 outline-none transition focus:border-[#16BBD7]/60 focus:ring-2 focus:ring-[#16BBD7]/60"
|
||
/>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<div class="flex items-center justify-between">
|
||
<label class="block text-sm font-medium text-white/70">Password</label>
|
||
<NuxtLink
|
||
to="/forgot-password"
|
||
class="text-xs font-semibold uppercase tracking-[0.28em] text-[#16BBD7] transition hover:text-[#72d9ea]"
|
||
>
|
||
Forgot?
|
||
</NuxtLink>
|
||
</div>
|
||
<input
|
||
v-model="loginForm.password"
|
||
type="password"
|
||
required
|
||
autocomplete="current-password"
|
||
class="w-full rounded-2xl border border-white/10 bg-[#0B132A]/90 px-4 py-3 text-sm text-white placeholder-white/40 outline-none transition focus:border-[#16BBD7]/60 focus:ring-2 focus:ring-[#16BBD7]/60"
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-[#16BBD7] px-5 py-3 text-sm font-semibold text-[#0B1020] transition hover:bg-[#13a7c4] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#72d9ea] disabled:cursor-not-allowed disabled:bg-[#16BBD7]/60"
|
||
:disabled="loginLoading"
|
||
>
|
||
<span v-if="loginLoading" class="flex items-center gap-2">
|
||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-[#0B1020]/30 border-t-[#0B1020]"/>
|
||
Signing in …
|
||
</span>
|
||
<span v-else>Sign in</span>
|
||
</button>
|
||
|
||
<p v-if="loginError" class="text-sm text-red-300">{{ loginError }}</p>
|
||
</form>
|
||
</section>
|
||
|
||
<section class="mt-6 rounded-3xl border border-white/10 bg-[#111832]/85 p-6 shadow-[0_24px_70px_rgba(5,10,30,0.55)] sm:p-8">
|
||
<div class="flex items-center gap-3">
|
||
<span class="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#16BBD7]/15 text-sm font-semibold text-[#16BBD7]">2</span>
|
||
<div>
|
||
<p class="text-xs uppercase tracking-[0.32em] text-white/55">Step 2</p>
|
||
<h2 class="text-lg font-semibold">Confirm the code</h2>
|
||
</div>
|
||
</div>
|
||
<p class="mt-4 text-sm text-white/70">This secure code comes straight from the desktop Bridge.</p>
|
||
|
||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||
<code class="rounded-2xl bg-[#0B132A]/80 px-4 py-2 font-mono text-sm text-[#72d9ea] shadow-inner">{{ token }}</code>
|
||
<button
|
||
type="button"
|
||
class="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/5 px-4 py-2 text-xs font-medium text-white/70 transition hover:border-white/30 hover:text-white"
|
||
@click="copyToken"
|
||
>
|
||
<span aria-hidden="true">⧉</span>
|
||
<span>{{ copiedToken ? 'Copied' : 'Copy code' }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="mt-6 space-y-4">
|
||
<button
|
||
type="button"
|
||
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-[#16BBD7] px-5 py-3 text-sm font-semibold text-[#0B1020] transition hover:bg-[#13a7c4] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#72d9ea] disabled:cursor-not-allowed disabled:bg-[#16BBD7]/60"
|
||
:disabled="!isAuthenticated || connectLoading"
|
||
@click="connectBridge"
|
||
>
|
||
<span v-if="connectLoading" class="flex items-center gap-2">
|
||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-[#0B1020]/30 border-t-[#0B1020]"/>
|
||
Linking …
|
||
</span>
|
||
<span v-else>Link Bridge</span>
|
||
</button>
|
||
|
||
<p v-if="!isAuthenticated" class="text-sm text-white/60">Sign in above to enable the link button.</p>
|
||
<p v-if="connectError" class="text-sm text-red-300">{{ connectError }}</p>
|
||
|
||
<div
|
||
v-if="successBannerVisible"
|
||
class="rounded-2xl border border-emerald-400/40 bg-emerald-400/10 px-5 py-4 text-sm text-emerald-100 shadow-[0_0_35px_rgba(16,185,129,0.25)]"
|
||
>
|
||
<p class="text-base font-semibold text-emerald-200">Bridge linked</p>
|
||
<p class="mt-1 text-emerald-100/80">You can close this page and head back to the Bridge app.</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="mt-6 rounded-3xl border border-white/10 bg-[#111832]/85 p-6 shadow-[0_24px_70px_rgba(5,10,30,0.55)] sm:p-8">
|
||
<div class="flex items-center gap-3">
|
||
<span class="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#16BBD7]/15 text-sm font-semibold text-[#16BBD7]">3</span>
|
||
<div>
|
||
<p class="text-xs uppercase tracking-[0.32em] text-white/55">Step 3</p>
|
||
<h2 class="text-lg font-semibold">Watch the Bridge</h2>
|
||
</div>
|
||
</div>
|
||
<p class="mt-4 text-sm text-white/70">The desktop app pings us every few seconds while it’s running.</p>
|
||
|
||
<div class="mt-5 space-y-4">
|
||
<div
|
||
class="rounded-2xl border px-4 py-4"
|
||
:class="connectionStatus?.connected
|
||
? 'border-emerald-400/45 bg-emerald-400/10 shadow-[0_0_35px_rgba(16,185,129,0.25)]'
|
||
: 'border-white/10 bg-[#0B132A]/75'"
|
||
>
|
||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||
<p class="text-sm font-semibold">Bridge status</p>
|
||
<span
|
||
class="rounded-full px-3 py-1 text-[11px] uppercase tracking-[0.32em]"
|
||
:class="connectionStatus?.connected ? 'bg-emerald-400/20 text-emerald-200' : 'bg-white/10 text-white/60'"
|
||
>
|
||
{{ connectionStatus?.connected ? 'Linked' : 'Waiting' }}
|
||
</span>
|
||
</div>
|
||
<p
|
||
class="mt-3 text-base font-medium"
|
||
:class="connectionStatus?.connected ? 'text-emerald-200' : 'text-white/60'"
|
||
>
|
||
{{ connectionLabel }}
|
||
</p>
|
||
<p v-if="connectionSubLabel" class="text-xs text-white/45">{{ connectionSubLabel }}</p>
|
||
</div>
|
||
|
||
<div class="grid gap-3 sm:grid-cols-2">
|
||
<div
|
||
class="flex items-center gap-3 rounded-2xl border px-4 py-3"
|
||
:class="connectionStatus?.simConnected
|
||
? 'border-emerald-400/45 bg-emerald-400/10 shadow-[0_0_30px_rgba(16,185,129,0.25)]'
|
||
: 'border-white/10 bg-[#0B132A]/75'"
|
||
>
|
||
<span
|
||
class="flex h-3 w-3 items-center justify-center rounded-full"
|
||
:class="connectionStatus?.simConnected
|
||
? 'bg-emerald-400 shadow-[0_0_18px_rgba(16,185,129,0.85)]'
|
||
: 'bg-white/30'"
|
||
/>
|
||
<div>
|
||
<p class="text-sm font-semibold">Simulator</p>
|
||
<p class="text-xs text-white/60">{{ connectionStatus?.simConnected ? 'Simulator detected' : 'Waiting for simulator' }}</p>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="flex items-center gap-3 rounded-2xl border px-4 py-3"
|
||
:class="connectionStatus?.flightActive
|
||
? 'border-emerald-400/45 bg-emerald-400/10 shadow-[0_0_30px_rgba(16,185,129,0.25)]'
|
||
: 'border-white/10 bg-[#0B132A]/75'"
|
||
>
|
||
<span
|
||
class="flex h-3 w-3 items-center justify-center rounded-full"
|
||
:class="connectionStatus?.flightActive
|
||
? 'bg-emerald-400 shadow-[0_0_18px_rgba(16,185,129,0.85)]'
|
||
: 'bg-white/30'"
|
||
/>
|
||
<div>
|
||
<p class="text-sm font-semibold">Flight</p>
|
||
<p class="text-xs text-white/60">{{ connectionStatus?.flightActive ? 'Flight in progress' : 'No flight detected' }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid gap-3 sm:grid-cols-2">
|
||
<div class="rounded-2xl border border-white/10 bg-[#0B132A]/75 px-4 py-3 text-sm text-white/65">
|
||
<p class="text-xs uppercase tracking-[0.28em] text-white/45">Connected since</p>
|
||
<p class="mt-1 font-medium text-white">{{ formattedConnectedAt }}</p>
|
||
</div>
|
||
<div class="rounded-2xl border border-white/10 bg-[#0B132A]/75 px-4 py-3 text-sm text-white/65">
|
||
<p class="text-xs uppercase tracking-[0.28em] text-white/45">Last update</p>
|
||
<p class="mt-1 font-medium text-white">{{ formattedLastStatusAt }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<p v-if="statusLoading" class="mt-6 text-xs uppercase tracking-[0.38em] text-white/45">Refreshing …</p>
|
||
<p v-if="statusError" class="mt-2 text-sm text-red-300">{{ statusError }}</p>
|
||
</section>
|
||
</template>
|
||
</main>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||
import { storeToRefs } from 'pinia'
|
||
import { useHead, useRoute } from '#imports'
|
||
import { useAuthStore } from '~/stores/auth'
|
||
|
||
interface BridgeStatusPayload {
|
||
token: string
|
||
connected: boolean
|
||
user: {
|
||
id: string
|
||
email: string
|
||
name?: string | null
|
||
} | null
|
||
simConnected: boolean
|
||
flightActive: boolean
|
||
connectedAt: string | null
|
||
lastStatusAt: string | null
|
||
}
|
||
|
||
useHead({ title: 'Link Bridge · OpenSquawk' })
|
||
|
||
const route = useRoute()
|
||
const auth = useAuthStore()
|
||
const { user, accessToken, isAuthenticated, initialized } = storeToRefs(auth)
|
||
|
||
const loginForm = reactive({ email: '', password: '' })
|
||
const loginLoading = ref(false)
|
||
const loginError = ref('')
|
||
|
||
const connectLoading = ref(false)
|
||
const connectError = ref('')
|
||
const connectSuccess = ref(false)
|
||
|
||
const connectionStatus = ref<BridgeStatusPayload | null>(null)
|
||
const statusInitialized = ref(false)
|
||
const statusError = ref('')
|
||
const statusLoading = ref(false)
|
||
const statusRequestActive = ref(false)
|
||
|
||
const copiedToken = ref(false)
|
||
|
||
const token = computed(() => {
|
||
const value = route.query.token
|
||
if (Array.isArray(value)) {
|
||
return value[0] || ''
|
||
}
|
||
return typeof value === 'string' ? value : ''
|
||
})
|
||
|
||
const hasToken = computed(() => token.value.length > 0)
|
||
|
||
const authDisplayName = computed(() => {
|
||
if (!user.value) return ''
|
||
return user.value.name || user.value.email
|
||
})
|
||
|
||
const connectionLabel = computed(() => {
|
||
if (!connectionStatus.value?.connected) {
|
||
return 'Waiting for the desktop app'
|
||
}
|
||
const name = connectionStatus.value.user?.name || connectionStatus.value.user?.email
|
||
return name ? `Linked as ${name}` : 'Linked'
|
||
})
|
||
|
||
const connectionSubLabel = computed(() => {
|
||
if (!connectionStatus.value?.connected) return null
|
||
return connectionStatus.value.user?.email && connectionStatus.value.user.email !== connectionStatus.value.user?.name
|
||
? connectionStatus.value.user.email
|
||
: null
|
||
})
|
||
|
||
const formattedConnectedAt = computed(() => formatTimestamp(connectionStatus.value?.connectedAt ?? null))
|
||
const formattedLastStatusAt = computed(() => formatTimestamp(connectionStatus.value?.lastStatusAt ?? null))
|
||
|
||
const successBannerVisible = computed(() => connectSuccess.value || Boolean(connectionStatus.value?.connected))
|
||
|
||
function formatTimestamp(value: string | null) {
|
||
if (!value) {
|
||
return '—'
|
||
}
|
||
const parsed = new Date(value)
|
||
if (Number.isNaN(parsed.getTime())) {
|
||
return '—'
|
||
}
|
||
return new Intl.DateTimeFormat('en-GB', {
|
||
dateStyle: 'short',
|
||
timeStyle: 'medium',
|
||
}).format(parsed)
|
||
}
|
||
|
||
async function submitLogin() {
|
||
loginError.value = ''
|
||
connectError.value = ''
|
||
if (!loginForm.email || !loginForm.password) {
|
||
loginError.value = 'Please enter your email and password.'
|
||
return
|
||
}
|
||
|
||
loginLoading.value = true
|
||
try {
|
||
await auth.login({ email: loginForm.email, password: loginForm.password })
|
||
connectSuccess.value = false
|
||
await fetchStatus(true)
|
||
} catch (err: any) {
|
||
loginError.value =
|
||
err?.data?.statusMessage ||
|
||
err?.response?._data?.statusMessage ||
|
||
err?.message ||
|
||
'Login failed.'
|
||
} finally {
|
||
loginLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function connectBridge() {
|
||
if (!hasToken.value || !isAuthenticated.value || !accessToken.value) {
|
||
return
|
||
}
|
||
|
||
connectLoading.value = true
|
||
connectError.value = ''
|
||
try {
|
||
await $fetch('/api/bridge/connect', {
|
||
method: 'POST',
|
||
body: { token: token.value },
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken.value}`,
|
||
},
|
||
})
|
||
connectSuccess.value = true
|
||
await fetchStatus(true)
|
||
} catch (err: any) {
|
||
connectError.value =
|
||
err?.data?.statusMessage ||
|
||
err?.response?._data?.statusMessage ||
|
||
err?.message ||
|
||
'Could not link the token.'
|
||
} finally {
|
||
connectLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function fetchStatus(force = false) {
|
||
if (!hasToken.value) {
|
||
return
|
||
}
|
||
if (statusRequestActive.value) {
|
||
return
|
||
}
|
||
|
||
statusRequestActive.value = true
|
||
if (force || !statusInitialized.value) {
|
||
statusLoading.value = true
|
||
}
|
||
|
||
try {
|
||
const response = await $fetch<BridgeStatusPayload>('/api/bridge/me', {
|
||
params: { token: token.value },
|
||
})
|
||
connectionStatus.value = response
|
||
statusError.value = ''
|
||
statusInitialized.value = true
|
||
if (response.connected) {
|
||
connectSuccess.value = true
|
||
}
|
||
} catch (err: any) {
|
||
statusError.value =
|
||
err?.data?.statusMessage ||
|
||
err?.response?._data?.statusMessage ||
|
||
err?.message ||
|
||
'We could not refresh the status.'
|
||
} finally {
|
||
statusRequestActive.value = false
|
||
statusLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function copyToken() {
|
||
if (!hasToken.value) return
|
||
if (typeof navigator === 'undefined' || !navigator.clipboard) return
|
||
try {
|
||
await navigator.clipboard.writeText(token.value)
|
||
copiedToken.value = true
|
||
setTimeout(() => {
|
||
copiedToken.value = false
|
||
}, 2000)
|
||
} catch {
|
||
// Ignore copy errors – clipboard access is optional
|
||
}
|
||
}
|
||
|
||
let poller: ReturnType<typeof setInterval> | null = null
|
||
|
||
function stopPolling() {
|
||
if (poller) {
|
||
clearInterval(poller)
|
||
poller = null
|
||
}
|
||
}
|
||
|
||
function startPolling() {
|
||
stopPolling()
|
||
if (!hasToken.value) {
|
||
return
|
||
}
|
||
fetchStatus(!statusInitialized.value)
|
||
poller = setInterval(() => {
|
||
fetchStatus()
|
||
}, 5000)
|
||
}
|
||
|
||
watch(token, () => {
|
||
connectError.value = ''
|
||
connectSuccess.value = false
|
||
connectionStatus.value = null
|
||
statusInitialized.value = false
|
||
statusError.value = ''
|
||
startPolling()
|
||
})
|
||
|
||
watch(
|
||
() => isAuthenticated.value,
|
||
(value) => {
|
||
if (value && hasToken.value) {
|
||
fetchStatus(true)
|
||
}
|
||
},
|
||
)
|
||
|
||
onMounted(() => {
|
||
if (!initialized.value) {
|
||
auth.fetchUser().catch(() => {})
|
||
}
|
||
startPolling()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
stopPolling()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
code {
|
||
word-break: break-all;
|
||
}
|
||
</style>
|