feat(api-environment): add API environment selection component and related functionality
This commit is contained in:
@@ -6,13 +6,16 @@ import { computed, onMounted, shallowRef } from 'vue'
|
||||
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||
import { Toaster } from 'vue-sonner'
|
||||
|
||||
import { adminApi, AdminApiError, signInUrl } from './modules/api'
|
||||
import ApiEnvironmentSelect from './components/admin-shell/ApiEnvironmentSelect.vue'
|
||||
|
||||
import { adminApi, AdminApiError, apiServerUrl, signInUrl } from './modules/api'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = shallowRef(true)
|
||||
const me = shallowRef<AdminMe | null>(null)
|
||||
const accessError = shallowRef<string | null>(null)
|
||||
const currentApiServerUrl = apiServerUrl()
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: 'i-lucide-layout-dashboard', label: 'Overview' },
|
||||
@@ -138,6 +141,7 @@ onMounted(async () => {
|
||||
{{ currentTitle }}
|
||||
</h1>
|
||||
</div>
|
||||
<ApiEnvironmentSelect :api-server-url="currentApiServerUrl" />
|
||||
</header>
|
||||
<div class="admin-content">
|
||||
<RouterView />
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { ADMIN_API_ENVIRONMENTS, apiEnvironmentValueFor, buildApiServerSwitchUrl } from '../../modules/server-admin-context'
|
||||
|
||||
const props = defineProps<{
|
||||
apiServerUrl: string
|
||||
}>()
|
||||
|
||||
const selectedEnvironment = computed(() => apiEnvironmentValueFor(props.apiServerUrl))
|
||||
|
||||
function switchEnvironment(event: Event) {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLSelectElement))
|
||||
return
|
||||
|
||||
if (target.value === selectedEnvironment.value)
|
||||
return
|
||||
|
||||
window.location.assign(buildApiServerSwitchUrl(window.location.href, target.value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label :class="['flex', 'items-center', 'gap-2', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
|
||||
<span :class="['i-lucide-server', 'h-4', 'w-4', 'shrink-0']" />
|
||||
<span :class="['sr-only']">API environment</span>
|
||||
<select
|
||||
:class="[
|
||||
'h-8',
|
||||
'max-w-[180px]',
|
||||
'rounded-md',
|
||||
'border',
|
||||
'border-neutral-200',
|
||||
'bg-white',
|
||||
'px-2',
|
||||
'text-xs',
|
||||
'font-medium',
|
||||
'text-neutral-700',
|
||||
'outline-none',
|
||||
'dark:border-neutral-800',
|
||||
'dark:bg-neutral-950',
|
||||
'dark:text-neutral-200',
|
||||
'focus:border-emerald-500',
|
||||
'focus:ring-3',
|
||||
'focus:ring-emerald-500/14',
|
||||
]"
|
||||
:value="selectedEnvironment"
|
||||
@change="switchEnvironment"
|
||||
>
|
||||
<option
|
||||
v-for="environment in ADMIN_API_ENVIRONMENTS"
|
||||
:key="environment.value"
|
||||
:value="environment.value"
|
||||
>
|
||||
{{ environment.label }} - {{ environment.description }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { defaultStandaloneApiServerUrl, getServerAdminBootstrapContext, resolveStandaloneServerAdminContext } from './server-admin-context'
|
||||
import { ADMIN_API_ENVIRONMENTS, apiEnvironmentValueFor, buildApiServerSwitchUrl, defaultStandaloneApiServerUrl, getServerAdminBootstrapContext, resolveStandaloneServerAdminContext } from './server-admin-context'
|
||||
|
||||
describe('ui-admin bootstrap context', () => {
|
||||
it('uses the trusted API server origin carried by standalone server redirects', () => {
|
||||
@@ -40,6 +40,25 @@ describe('ui-admin bootstrap context', () => {
|
||||
expect(defaultStandaloneApiServerUrl('https://server-dev.airi-server-admin.pages.dev')).toBe('https://airi-server-dev.up.railway.app')
|
||||
})
|
||||
|
||||
it('exposes production, testing, and local API environment choices', () => {
|
||||
expect(ADMIN_API_ENVIRONMENTS.map(environment => environment.value)).toEqual([
|
||||
'https://api.airi.build',
|
||||
'https://airi-server-dev.up.railway.app',
|
||||
'http://localhost:3000',
|
||||
])
|
||||
})
|
||||
|
||||
it('maps custom localhost API origins to the local environment option', () => {
|
||||
expect(apiEnvironmentValueFor('http://127.0.0.1:8787')).toBe('http://localhost:3000')
|
||||
})
|
||||
|
||||
it('builds a switched admin URL without dropping the current page state', () => {
|
||||
expect(buildApiServerSwitchUrl(
|
||||
'https://admin.airi.build/voice-packs/new?draft=1#advanced',
|
||||
'https://airi-server-dev.up.railway.app/api/admin',
|
||||
)).toBe('https://admin.airi.build/voice-packs/new?draft=1&api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app#advanced')
|
||||
})
|
||||
|
||||
it('falls back to the standalone query context when the static placeholder script is still present', () => {
|
||||
document.body.innerHTML = '<script id="airi-server-admin-context" type="application/json">__AIRI_SERVER_ADMIN_CONTEXT__</script>'
|
||||
window.history.replaceState(
|
||||
|
||||
@@ -3,9 +3,33 @@ export interface ServerAdminBootstrapContext {
|
||||
currentUrl: string
|
||||
}
|
||||
|
||||
export interface AdminApiEnvironment {
|
||||
label: string
|
||||
value: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const SCRIPT_ID = 'airi-server-admin-context'
|
||||
const API_SERVER_URL_QUERY_PARAM = 'api_server_url'
|
||||
|
||||
export const ADMIN_API_ENVIRONMENTS = [
|
||||
{
|
||||
label: 'Production',
|
||||
value: 'https://api.airi.build',
|
||||
description: 'api.airi.build',
|
||||
},
|
||||
{
|
||||
label: 'Testing',
|
||||
value: 'https://airi-server-dev.up.railway.app',
|
||||
description: 'airi-server-dev.up.railway.app',
|
||||
},
|
||||
{
|
||||
label: 'Local',
|
||||
value: 'http://localhost:3000',
|
||||
description: 'localhost:3000',
|
||||
},
|
||||
] as const satisfies readonly AdminApiEnvironment[]
|
||||
|
||||
const TRUSTED_STANDALONE_API_SERVER_ORIGINS = [
|
||||
'https://api.airi.build',
|
||||
'https://airi-server-dev.up.railway.app',
|
||||
@@ -107,6 +131,25 @@ export function resolveStandaloneServerAdminContext(currentUrl: string): ServerA
|
||||
}
|
||||
}
|
||||
|
||||
export function apiEnvironmentValueFor(apiServerUrl: string): string {
|
||||
const origin = new URL(apiServerUrl).origin
|
||||
const known = ADMIN_API_ENVIRONMENTS.find(environment => environment.value === origin)
|
||||
if (known)
|
||||
return known.value
|
||||
|
||||
if (TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS.some(pattern => pattern.test(origin)))
|
||||
return ADMIN_API_ENVIRONMENTS.find(environment => environment.label === 'Local')?.value ?? origin
|
||||
|
||||
return origin
|
||||
}
|
||||
|
||||
export function buildApiServerSwitchUrl(currentUrl: string, apiServerUrl: string): string {
|
||||
const url = new URL(currentUrl)
|
||||
const origin = new URL(apiServerUrl).origin
|
||||
url.searchParams.set(API_SERVER_URL_QUERY_PARAM, origin)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function normalizeTrustedApiServerUrl(value: string | null): string | null {
|
||||
if (!value)
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user