feat(admin-ui): implement standalone admin UI with Cloudflare Pages deployment

This commit is contained in:
RainbowBird
2026-06-09 17:09:06 +08:00
parent 57ba232432
commit 11b820a77f
24 changed files with 401 additions and 108 deletions
@@ -0,0 +1,55 @@
name: Cloudflare Pages (Admin UI)
on:
push:
branches:
- 'main'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
name: Deploy - ui-admin
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
environment:
name: Admin Production
url: https://admin.airi.build/admin/
steps:
- uses: actions/checkout@v6
# Turborepo
- name: Cache turbo build setup
uses: actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-admin-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-admin-
${{ runner.os }}-turbo-
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: lts/*
cache: pnpm
- run: pnpm i -g wrangler@4
- run: pnpm install --frozen-lockfile
- run: pnpm run build:packages
- name: Build ui-admin
run: pnpm -F @proj-airi/ui-admin run build
env:
VITE_SERVER_URL: 'https://api.airi.build'
- uses: cloudflare/wrangler-action@v3.14.1
with:
apiToken: ${{ secrets.MOERU_CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.MOERU_CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy ./apps/ui-admin/dist --project-name=airi-server-admin --branch=main
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
@@ -62,7 +62,7 @@ jobs:
- uses: cloudflare/wrangler-action@v3.14.1
with:
apiToken: ${{ secrets.AUTH_CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.AUTH_CLOUDFLARE_ACCOUNT_ID }}
apiToken: ${{ secrets.MOERU_CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.MOERU_CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy ./apps/ui-server-auth/dist --project-name=moeru-ai-airi-auth --branch=main
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
@@ -122,11 +122,58 @@ jobs:
- name: Wrangler Pages Deploy
uses: cloudflare/wrangler-action@v3.14.1
with:
apiToken: ${{ secrets.AUTH_CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.AUTH_CLOUDFLARE_ACCOUNT_ID }}
apiToken: ${{ secrets.MOERU_CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.MOERU_CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy ./apps/ui-server-auth/dist --project-name=moeru-ai-airi-auth --branch=server-dev
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
- name: Print preview URL
run: |
echo "Preview URL: https://server-dev.airi-server-auth.pages.dev/ui/"
deploy-admin-ui:
name: Deploy - ui-admin (server-dev)
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
environment:
name: AdminServerDev
url: https://server-dev.airi-server-admin.pages.dev/admin/
steps:
- uses: actions/checkout@v6
# Turborepo
- name: Cache turbo build setup
uses: actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-admin-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-admin-
${{ runner.os }}-turbo-
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: lts/*
cache: pnpm
- run: pnpm i -g wrangler@4
- run: pnpm install --frozen-lockfile
- run: pnpm run build:packages
- name: Build ui-admin
run: pnpm -F @proj-airi/ui-admin run build
env:
VITE_SERVER_URL: 'https://airi-server-dev.up.railway.app'
- name: Wrangler Pages Deploy
uses: cloudflare/wrangler-action@v3.14.1
with:
apiToken: ${{ secrets.MOERU_CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.MOERU_CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy ./apps/ui-admin/dist --project-name=airi-server-admin --branch=server-dev
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
- name: Print preview URL
run: |
echo "Preview URL: https://server-dev.airi-server-admin.pages.dev/admin/"
-17
View File
@@ -1,19 +1,3 @@
FROM node:24-alpine AS ui-assets
WORKDIR /app
RUN corepack enable
RUN apk add --no-cache git
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \
pnpm install --frozen-lockfile --ignore-scripts
RUN pnpm -r -F @proj-airi/ui-admin^... --if-present run build
RUN pnpm -F @proj-airi/ui-admin run build
FROM node:24-alpine
WORKDIR /app
@@ -23,7 +7,6 @@ RUN corepack enable
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./
COPY patches/ ./patches/
COPY apps/server apps/server
COPY --from=ui-assets /app/apps/server/public/ui-admin apps/server/public/ui-admin
COPY packages/server-schema packages/server-schema
COPY packages/server-sdk-shared packages/server-sdk-shared
+10
View File
@@ -36,6 +36,16 @@ Default:
Set this when previewing or deploying auth UI to a different Cloudflare URL.
## `ADMIN_UI_URL`
`apps/ui-admin` is deployed separately from the server image. The API server still owns the historical `/admin/*` entrypoints and redirects them to **`ADMIN_UI_URL`**.
Default:
`ADMIN_UI_URL=https://admin.airi.build/admin`
Set this when previewing or deploying admin UI to a different Cloudflare URL.
## `ADDITIONAL_TRUSTED_ORIGINS` (LAN / Capacitor dev)
When the mobile dev server uses a non-localhost origin (for example `https://10.x.x.x:5273` from `cap copy ios` / `capacitor.config.json`), set **`ADDITIONAL_TRUSTED_ORIGINS`** in `apps/server/.env.local` to a comma-separated list of exact origins (parsed and normalized at startup). Example:
-16
View File
@@ -1,18 +1,3 @@
FROM node:24-alpine AS ui-assets
WORKDIR /app
RUN corepack enable
RUN apk add --no-cache git
COPY . .
RUN pnpm install --frozen-lockfile --ignore-scripts
RUN pnpm -r -F @proj-airi/ui-admin^... --if-present run build
RUN pnpm -F @proj-airi/ui-admin run build
FROM node:24-alpine
WORKDIR /app
@@ -21,7 +6,6 @@ RUN corepack enable
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./
COPY patches/ ./patches/
COPY --from=ui-assets /app/apps/server/public/ui-admin apps/server/public/ui-admin
COPY apps/server apps/server
COPY packages/server-schema packages/server-schema
COPY packages/server-sdk-shared packages/server-sdk-shared
-1
View File
@@ -3,7 +3,6 @@ builder = "DOCKERFILE"
dockerfilePath = "/apps/server/production/railway/Dockerfile"
watchPatterns = [
"apps/server/**",
"apps/ui-admin/**",
"packages/**",
"pnpm-lock.yaml"
]
+3 -2
View File
@@ -334,8 +334,9 @@ export async function buildApp(deps: AppDeps) {
}))
/**
* Admin dashboard SPA. Auth is enforced by `/api/admin/*`; the bundle
* itself is public so unauthenticated users can be redirected cleanly.
* Admin dashboard entrypoint. Auth is enforced by `/api/admin/*`; the
* standalone UI itself is public so unauthenticated users can be redirected
* cleanly.
*/
.route('/', createAdminUiRoutes(deps.env))
+5
View File
@@ -85,6 +85,11 @@ const EnvSchema = object({
// of the server image.
AUTH_UI_URL: optional(string(), 'https://auth.airi.build/ui'),
// Standalone admin UI base URL. The server keeps `/admin/*` as the historical
// entrypoint and redirects those requests here after ui-admin moved out of
// the server image.
ADMIN_UI_URL: optional(string(), 'https://admin.airi.build/admin'),
// Canonical user-facing web app origin. Used as the Stripe redirect base
// (success_url / cancel_url / portal return_url) when a request has no trusted
// browser origin — notably the Electron desktop renderer, which loads from
+1
View File
@@ -42,6 +42,7 @@ describe('parseEnv', () => {
expect(env.DATABASE_URL).toBe('postgres://example')
expect(env.REDIS_URL).toBe('redis://example')
expect(env.AUTH_UI_URL).toBe('https://auth.airi.build/ui')
expect(env.ADMIN_UI_URL).toBe('https://admin.airi.build/admin')
expect(env.ADDITIONAL_TRUSTED_ORIGINS).toEqual([])
})
+3 -23
View File
@@ -1,32 +1,12 @@
import type { Env } from '../libs/env'
import type { HonoEnv } from '../types/hono'
import { serveStatic } from '@hono/node-server/serve-static'
import { Hono } from 'hono'
import { getServerAdminUiDistDir, renderServerAdminUiHtml, SERVER_ADMIN_UI_BASE_PATH } from '../utils/server-admin-ui'
const RE_SERVER_ADMIN_UI_BASE_PATH = /^\/admin/
import { buildAdminUiRedirectUrl, SERVER_ADMIN_UI_BASE_PATH } from '../utils/server-admin-ui'
export function createAdminUiRoutes(env: Env) {
return new Hono<HonoEnv>()
.get(SERVER_ADMIN_UI_BASE_PATH, c => c.redirect(`${SERVER_ADMIN_UI_BASE_PATH}/`))
.get(`${SERVER_ADMIN_UI_BASE_PATH}/*`, async (c, next) => {
if (!shouldRenderAdminUiHtml(new URL(c.req.url).pathname))
return next()
return c.html(renderServerAdminUiHtml({
apiServerUrl: env.API_SERVER_URL,
currentUrl: c.req.url,
}))
})
.use(`${SERVER_ADMIN_UI_BASE_PATH}/*`, serveStatic({
root: getServerAdminUiDistDir(),
rewriteRequestPath: (path: string) => path.replace(RE_SERVER_ADMIN_UI_BASE_PATH, ''),
}))
}
function shouldRenderAdminUiHtml(pathname: string): boolean {
const segment = pathname.split('/').pop() ?? ''
return segment === '' || segment === 'index.html' || !segment.includes('.')
.get(SERVER_ADMIN_UI_BASE_PATH, c => c.redirect(buildAdminUiRedirectUrl(env.ADMIN_UI_URL, c.req.url, env.API_SERVER_URL)))
.get(`${SERVER_ADMIN_UI_BASE_PATH}/*`, c => c.redirect(buildAdminUiRedirectUrl(env.ADMIN_UI_URL, c.req.url, env.API_SERVER_URL)))
}
+2
View File
@@ -15,6 +15,8 @@ const TRUSTED_EXACT_ORIGINS = [
'https://airi.moeru.ai', // Production
'https://auth.airi.build', // Standalone auth UI
'https://server-dev.airi-server-auth.pages.dev', // Server-dev standalone auth UI
'https://admin.airi.build', // Standalone admin UI
'https://server-dev.airi-server-admin.pages.dev', // Server-dev standalone admin UI
]
// NOTICE:
+86 -38
View File
@@ -1,49 +1,97 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
export const SERVER_ADMIN_UI_BASE_PATH = '/admin'
export const ADMIN_UI_API_SERVER_URL_QUERY_PARAM = 'api_server_url'
export const DEFAULT_ADMIN_UI_URL = 'https://admin.airi.build/admin'
export const SERVER_DEV_API_SERVER_URL = 'https://airi-server-dev.up.railway.app'
export const SERVER_DEV_ADMIN_UI_URL = 'https://server-dev.airi-server-admin.pages.dev/admin'
const SERVER_ADMIN_UI_DIST_DIR = fileURLToPath(new URL('../../public/ui-admin', import.meta.url))
const SERVER_ADMIN_UI_INDEX_HTML_PATH = fileURLToPath(new URL('../../public/ui-admin/index.html', import.meta.url))
const RE_HTML_LT = /</g
const RE_HTML_GT = />/g
const RE_HTML_AMP = /&/g
const RE_UNICODE_LINE_SEPARATOR = /\u2028/g
const RE_UNICODE_PARAGRAPH_SEPARATOR = /\u2029/g
/**
* Builds an absolute URL inside the externally hosted admin UI.
*
* Use when:
* - Redirecting server-owned admin UI entrypoints to the standalone
* `apps/ui-admin` deployment.
* - Preserving dashboard route paths and query parameters.
*
* Expects:
* - `adminUiUrl` is the public admin UI base, usually ending in `/admin`.
* - `path` is the route path within the admin UI router.
*
* Returns:
* - An absolute URL with the admin UI base path, normalized path, and search.
*/
export function buildAdminUiUrl(adminUiUrl: string, path: string, search = ''): string {
const target = new URL(adminUiUrl)
const basePath = target.pathname.replace(/\/+$/, '')
const routePath = path.startsWith('/') ? path : `/${path}`
let cachedIndexHtml: string | null = null
target.pathname = `${basePath}${routePath}`
target.search = search
target.hash = ''
export interface ServerAdminUiContext {
apiServerUrl: string
currentUrl: string
return target.toString()
}
export function getServerAdminUiDistDir(): string {
return SERVER_ADMIN_UI_DIST_DIR
/**
* Resolves the standalone admin UI base for the active server environment.
*
* Use when:
* - The server redirects historical `/admin/*` entrypoints to the standalone UI.
* - The server-dev Railway deployment needs the matching Cloudflare Pages
* branch without changing the production admin domain.
*
* Expects:
* - `adminUiUrl` is the configured admin UI base URL.
* - `apiServerUrl` is the configured API server URL.
*
* Returns:
* - The configured admin UI URL, except for the server-dev default pairing where
* the matching Pages branch URL is returned.
*/
export function resolveAdminUiUrl(adminUiUrl: string, apiServerUrl: string): string {
try {
const adminUi = new URL(adminUiUrl)
const defaultAdminUi = new URL(DEFAULT_ADMIN_UI_URL)
const apiServer = new URL(apiServerUrl)
const adminUiBase = `${adminUi.origin}${adminUi.pathname.replace(/\/+$/, '')}`
const defaultAdminUiBase = `${defaultAdminUi.origin}${defaultAdminUi.pathname.replace(/\/+$/, '')}`
if (adminUiBase === defaultAdminUiBase && apiServer.origin === SERVER_DEV_API_SERVER_URL) {
return SERVER_DEV_ADMIN_UI_URL
}
}
catch {
return adminUiUrl
}
return adminUiUrl
}
export function renderServerAdminUiHtml(context: ServerAdminUiContext): string {
const indexHtml = getServerAdminUiIndexHtml()
/**
* Maps a server `/admin/*` request to the standalone admin UI.
*
* Use when:
* - The server keeps owning the historical `/admin/*` entrypoint but no longer
* packages the admin UI bundle.
*
* Expects:
* - `requestUrl` is the incoming server URL.
* - `adminUiUrl` points to the standalone admin UI base path.
*
* Returns:
* - The external admin UI URL preserving route suffix and query string.
*/
export function buildAdminUiRedirectUrl(adminUiUrl: string, requestUrl: string, apiServerUrl?: string): string {
const request = new URL(requestUrl)
const suffix = request.pathname === SERVER_ADMIN_UI_BASE_PATH
? '/'
: request.pathname.slice(SERVER_ADMIN_UI_BASE_PATH.length) || '/'
const resolvedAdminUiUrl = apiServerUrl ? resolveAdminUiUrl(adminUiUrl, apiServerUrl) : adminUiUrl
if (!indexHtml.includes('__AIRI_SERVER_ADMIN_CONTEXT__'))
throw new Error('ui-admin index.html is missing __AIRI_SERVER_ADMIN_CONTEXT__ placeholder')
const target = new URL(buildAdminUiUrl(resolvedAdminUiUrl, suffix, request.search))
if (apiServerUrl) {
const apiServer = new URL(apiServerUrl)
target.searchParams.set(ADMIN_UI_API_SERVER_URL_QUERY_PARAM, apiServer.origin)
}
return indexHtml.replace('__AIRI_SERVER_ADMIN_CONTEXT__', serializeInlineJson(context))
}
function getServerAdminUiIndexHtml(): string {
if (cachedIndexHtml !== null)
return cachedIndexHtml
cachedIndexHtml = readFileSync(SERVER_ADMIN_UI_INDEX_HTML_PATH, 'utf8')
return cachedIndexHtml
}
function serializeInlineJson(value: unknown): string {
return JSON.stringify(value)
.replace(RE_HTML_LT, '\\u003c')
.replace(RE_HTML_GT, '\\u003e')
.replace(RE_HTML_AMP, '\\u0026')
.replace(RE_UNICODE_LINE_SEPARATOR, '\\u2028')
.replace(RE_UNICODE_PARAGRAPH_SEPARATOR, '\\u2029')
return target.toString()
}
+3 -1
View File
@@ -12,9 +12,11 @@ describe('origin utils', () => {
expect(getTrustedOrigin('https://127.0.0.1:5273')).toBe('https://127.0.0.1:5273')
})
it('allows the standalone auth UI origin', () => {
it('allows the standalone auth and admin UI origins', () => {
expect(getTrustedOrigin('https://auth.airi.build')).toBe('https://auth.airi.build')
expect(getTrustedOrigin('https://server-dev.airi-server-auth.pages.dev')).toBe('https://server-dev.airi-server-auth.pages.dev')
expect(getTrustedOrigin('https://admin.airi.build')).toBe('https://admin.airi.build')
expect(getTrustedOrigin('https://server-dev.airi-server-admin.pages.dev')).toBe('https://server-dev.airi-server-admin.pages.dev')
})
it('rejects private LAN Vite dev origins unless listed in ADDITIONAL_TRUSTED_ORIGINS', () => {
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { buildAdminUiRedirectUrl, buildAdminUiUrl, resolveAdminUiUrl } from '../server-admin-ui'
describe('admin UI URL helpers', () => {
it('builds admin UI URLs under the configured admin base path', () => {
expect(buildAdminUiUrl('https://admin.airi.build/admin', '/users', '?query=alice')).toBe(
'https://admin.airi.build/admin/users?query=alice',
)
})
it('maps server /admin requests to the standalone admin UI while preserving queries', () => {
expect(buildAdminUiRedirectUrl(
'https://admin.airi.build/admin/',
'https://api.airi.build/admin/voice-packs?provider=openai',
)).toBe('https://admin.airi.build/admin/voice-packs?provider=openai')
})
it('adds the API server origin for standalone admin UI cross-environment redirects', () => {
expect(buildAdminUiRedirectUrl(
'https://admin-preview.example/admin/',
'https://airi-server-dev.up.railway.app/admin/users?api_server_url=https%3A%2F%2Fevil.example',
'https://airi-server-dev.up.railway.app/api/admin',
)).toBe(
'https://admin-preview.example/admin/users?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app',
)
})
it('routes server-dev default admin UI redirects to the matching Pages branch', () => {
expect(buildAdminUiRedirectUrl(
'https://admin.airi.build/admin',
'https://airi-server-dev.up.railway.app/admin/users?query=alice',
'https://airi-server-dev.up.railway.app',
)).toBe(
'https://server-dev.airi-server-admin.pages.dev/admin/users?query=alice&api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app',
)
})
it('keeps an explicitly configured admin UI URL for server-dev', () => {
expect(resolveAdminUiUrl(
'https://admin-preview.example/admin',
'https://airi-server-dev.up.railway.app',
)).toBe('https://admin-preview.example/admin')
})
})
+2 -2
View File
@@ -1,6 +1,6 @@
# AIRI Admin Dashboard
Admin dashboard for operating the hosted AIRI server. It is a Vue/Vite app built into `apps/server/public/ui-admin` and served by the server at `/admin`.
Admin dashboard for operating the hosted AIRI server. It is a standalone Vue/Vite app deployed to Cloudflare Pages under `/admin`; the API server redirects its historical `/admin/*` entrypoints to this app.
## Use When
@@ -22,4 +22,4 @@ pnpm -F @proj-airi/ui-admin build
## Build Output
`pnpm -F @proj-airi/ui-admin build` writes to `apps/server/public/ui-admin`. Build this app before running a server image or local server flow that needs `/admin` to serve real HTML instead of reporting a missing admin UI artifact.
`pnpm -F @proj-airi/ui-admin build` writes to `apps/ui-admin/dist`. Server builds do not package this output; deploy the directory through the admin Cloudflare Pages workflow.
+4
View File
@@ -0,0 +1,4 @@
/assets/*
cache-control: max-age=31536000
cache-control: immutable
+3
View File
@@ -0,0 +1,3 @@
/ /admin/ 302
/admin /index.html 200
/admin/* /index.html 200
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { getServerAdminBootstrapContext, resolveStandaloneServerAdminContext } from './server-admin-context'
describe('ui-admin bootstrap context', () => {
it('uses the trusted API server origin carried by standalone server redirects', () => {
expect(resolveStandaloneServerAdminContext(
'https://admin.airi.build/admin/users?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app%2Fapi%2Fadmin',
)).toEqual({
apiServerUrl: 'https://airi-server-dev.up.railway.app',
currentUrl: 'https://admin.airi.build/admin/users?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app%2Fapi%2Fadmin',
})
})
it('ignores untrusted API server origins from crafted standalone admin URLs', () => {
expect(resolveStandaloneServerAdminContext(
'https://admin.airi.build/admin/users?api_server_url=https%3A%2F%2Fevil.example',
)).toBeNull()
})
it('allows localhost API origins for local development', () => {
expect(resolveStandaloneServerAdminContext(
'http://localhost:5173/admin/users?api_server_url=http%3A%2F%2F127.0.0.1%3A3000',
)?.apiServerUrl).toBe('http://127.0.0.1:3000')
})
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(
null,
'',
'/admin/users?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app',
)
expect(getServerAdminBootstrapContext()?.apiServerUrl).toBe('https://airi-server-dev.up.railway.app')
})
})
@@ -4,6 +4,19 @@ export interface ServerAdminBootstrapContext {
}
const SCRIPT_ID = 'airi-server-admin-context'
const API_SERVER_URL_QUERY_PARAM = 'api_server_url'
const TRUSTED_STANDALONE_API_SERVER_ORIGINS = [
'https://api.airi.build',
'https://airi-server-dev.up.railway.app',
]
const TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS = [
/^http:\/\/localhost(:\d+)?$/,
/^http:\/\/127\.0\.0\.1(:\d+)?$/,
/^https:\/\/localhost(:\d+)?$/,
/^https:\/\/127\.0\.0\.1(:\d+)?$/,
]
let cachedContext: ServerAdminBootstrapContext | null | undefined
@@ -13,7 +26,7 @@ export function getServerAdminBootstrapContext(): ServerAdminBootstrapContext |
const element = document.getElementById(SCRIPT_ID)
if (!element) {
cachedContext = null
cachedContext = resolveStandaloneServerAdminContext(window.location.href)
return cachedContext
}
@@ -26,7 +39,7 @@ export function getServerAdminBootstrapContext(): ServerAdminBootstrapContext |
return cachedContext
}
catch {
cachedContext = null
cachedContext = resolveStandaloneServerAdminContext(window.location.href)
return cachedContext
}
}
@@ -34,3 +47,53 @@ export function getServerAdminBootstrapContext(): ServerAdminBootstrapContext |
export function defaultApiServerUrl(): string {
return import.meta.env.VITE_SERVER_URL || window.location.origin
}
/**
* Resolves API-server context carried by server redirects into static admin UI.
*
* Use when:
* - The standalone admin UI serves more than one AIRI environment from the same
* Pages deployment.
*
* Expects:
* - The server-owned `/admin/*` redirect sets `api_server_url`.
* - Only known AIRI API origins and localhost development origins are accepted.
*
* Returns:
* - A bootstrap context using the trusted API origin, or null when no trusted
* override is present.
*/
export function resolveStandaloneServerAdminContext(currentUrl: string): ServerAdminBootstrapContext | null {
const url = new URL(currentUrl)
const apiServerUrl = normalizeTrustedApiServerUrl(
url.searchParams.get(API_SERVER_URL_QUERY_PARAM),
)
if (!apiServerUrl)
return null
return {
apiServerUrl,
currentUrl,
}
}
function normalizeTrustedApiServerUrl(value: string | null): string | null {
if (!value)
return null
try {
const origin = new URL(value).origin
if (TRUSTED_STANDALONE_API_SERVER_ORIGINS.includes(origin))
return origin
if (TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS.some(pattern => pattern.test(origin)))
return origin
return null
}
catch {
return null
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ import VueMacros from 'vue-macros/vite'
import { defineConfig } from 'vite'
export default defineConfig({
base: '/admin/',
base: '/',
resolve: {
alias: {
'@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')),
@@ -20,7 +20,7 @@ export default defineConfig({
},
build: {
emptyOutDir: true,
outDir: resolve(join(import.meta.dirname, '..', 'server', 'public', 'ui-admin')),
outDir: resolve(join(import.meta.dirname, 'dist')),
sourcemap: true,
},
plugins: [
+14
View File
@@ -0,0 +1,14 @@
import { join } from 'node:path'
import { cwd } from 'node:process'
import { loadEnv } from 'vite'
import { defineConfig } from 'vitest/config'
export default defineConfig(({ mode }) => {
return {
test: {
include: ['src/**/*.test.ts'],
env: loadEnv(mode, join(cwd(), 'apps', 'ui-admin'), ''),
},
}
})
+7
View File
@@ -0,0 +1,7 @@
name = "airi-server-admin"
compatibility_date = "2025-04-01"
preview_urls = true
[assets]
directory = "./dist"
not_found_handling = "single-page-application"
+1
View File
@@ -5,6 +5,7 @@ export default defineConfig({
projects: [
'apps/server',
'apps/ui-server-auth',
'apps/ui-admin',
'apps/stage-tamagotchi',
'packages/audio-pipelines-transcribe',
'packages/cap-vite',