feat(admin-ui): update admin UI URLs to remove trailing slashes and adjust redirects

This commit is contained in:
RainbowBird
2026-06-09 17:46:58 +08:00
parent 2ac4e90d56
commit 7fa7f8a072
15 changed files with 45 additions and 31 deletions
@@ -21,7 +21,7 @@ jobs:
environment: environment:
name: Admin Production name: Admin Production
url: https://admin.airi.build/admin/ url: https://admin.airi.build/
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -140,7 +140,7 @@ jobs:
environment: environment:
name: AdminServerDev name: AdminServerDev
url: https://server-dev.airi-server-admin.pages.dev/admin/ url: https://server-dev.airi-server-admin.pages.dev/
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -176,4 +176,4 @@ jobs:
- name: Print preview URL - name: Print preview URL
run: | run: |
echo "Preview URL: https://server-dev.airi-server-admin.pages.dev/admin/" echo "Preview URL: https://server-dev.airi-server-admin.pages.dev/"
+1 -1
View File
@@ -42,7 +42,7 @@ Set this when previewing or deploying auth UI to a different Cloudflare URL.
Default: Default:
`ADMIN_UI_URL=https://admin.airi.build/admin` `ADMIN_UI_URL=https://admin.airi.build`
Set this when previewing or deploying admin UI to a different Cloudflare URL. Set this when previewing or deploying admin UI to a different Cloudflare URL.
+1 -1
View File
@@ -88,7 +88,7 @@ const EnvSchema = object({
// Standalone admin UI base URL. The server keeps `/admin/*` as the historical // Standalone admin UI base URL. The server keeps `/admin/*` as the historical
// entrypoint and redirects those requests here after ui-admin moved out of // entrypoint and redirects those requests here after ui-admin moved out of
// the server image. // the server image.
ADMIN_UI_URL: optional(string(), 'https://admin.airi.build/admin'), ADMIN_UI_URL: optional(string(), 'https://admin.airi.build'),
// Canonical user-facing web app origin. Used as the Stripe redirect base // 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 // (success_url / cancel_url / portal return_url) when a request has no trusted
+17 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { ensureDynamicFirstPartyRedirectUri, seedTrustedClients } from '../auth' import { createAuth, ensureDynamicFirstPartyRedirectUri, seedTrustedClients } from '../auth'
function createMockDb(existingRowsByCall: unknown[][] = []) { function createMockDb(existingRowsByCall: unknown[][] = []) {
const limit = vi.fn() const limit = vi.fn()
@@ -29,6 +29,22 @@ function createMockDb(existingRowsByCall: unknown[][] = []) {
return { db, limit, values, capturedValues } return { db, limit, values, capturedValues }
} }
describe('createAuth', () => {
it('allows signed-in users to link OAuth accounts that use a different email', () => {
const auth = createAuth({} as any, {
API_SERVER_URL: 'http://localhost:3000',
AUTH_GOOGLE_CLIENT_ID: 'google-client',
AUTH_GOOGLE_CLIENT_SECRET: 'google-secret',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as any)
expect(auth.options.account?.accountLinking?.allowDifferentEmails).toBe(true)
})
})
describe('seedTrustedClients', () => { describe('seedTrustedClients', () => {
it('seeds trusted first-party clients with explicit oauth metadata', async () => { it('seeds trusted first-party clients with explicit oauth metadata', async () => {
const { db, values, capturedValues } = createMockDb([[], [], []]) const { db, values, capturedValues } = createMockDb([[], [], []])
+1 -1
View File
@@ -42,7 +42,7 @@ describe('parseEnv', () => {
expect(env.DATABASE_URL).toBe('postgres://example') expect(env.DATABASE_URL).toBe('postgres://example')
expect(env.REDIS_URL).toBe('redis://example') expect(env.REDIS_URL).toBe('redis://example')
expect(env.AUTH_UI_URL).toBe('https://auth.airi.build/ui') expect(env.AUTH_UI_URL).toBe('https://auth.airi.build/ui')
expect(env.ADMIN_UI_URL).toBe('https://admin.airi.build/admin') expect(env.ADMIN_UI_URL).toBe('https://admin.airi.build')
expect(env.ADDITIONAL_TRUSTED_ORIGINS).toEqual([]) expect(env.ADDITIONAL_TRUSTED_ORIGINS).toEqual([])
}) })
@@ -103,7 +103,7 @@ describe('auth UI routes', () => {
expect(res.status).toBe(302) expect(res.status).toBe(302)
const location = res.headers.get('location') const location = res.headers.get('location')
expect(location).toBe('https://auth.airi.build/ui/sign-in?provider=github&client_id=stage-web&prompt=login&redirect_uri=http%3A%2F%2Flocalhost%3A5173%2Fauth%2Fcallback') expect(location).toBe('https://auth.airi.build/ui/sign-in?provider=github&client_id=stage-web&prompt=login&redirect_uri=http%3A%2F%2Flocalhost%3A5173%2Fauth%2Fcallback&api_server_url=http%3A%2F%2Flocalhost%3A3000')
}) })
it('redirects Electron OIDC callback queries to the standalone auth UI relay', async () => { it('redirects Electron OIDC callback queries to the standalone auth UI relay', async () => {
+4 -4
View File
@@ -1,8 +1,8 @@
export const SERVER_ADMIN_UI_BASE_PATH = '/admin' export const SERVER_ADMIN_UI_BASE_PATH = '/admin'
export const ADMIN_UI_API_SERVER_URL_QUERY_PARAM = 'api_server_url' 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 DEFAULT_ADMIN_UI_URL = 'https://admin.airi.build'
export const SERVER_DEV_API_SERVER_URL = 'https://airi-server-dev.up.railway.app' 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' export const SERVER_DEV_ADMIN_UI_URL = 'https://server-dev.airi-server-admin.pages.dev'
/** /**
* Builds an absolute URL inside the externally hosted admin UI. * Builds an absolute URL inside the externally hosted admin UI.
@@ -13,7 +13,7 @@ export const SERVER_DEV_ADMIN_UI_URL = 'https://server-dev.airi-server-admin.pag
* - Preserving dashboard route paths and query parameters. * - Preserving dashboard route paths and query parameters.
* *
* Expects: * Expects:
* - `adminUiUrl` is the public admin UI base, usually ending in `/admin`. * - `adminUiUrl` is the public admin UI base.
* - `path` is the route path within the admin UI router. * - `path` is the route path within the admin UI router.
* *
* Returns: * Returns:
@@ -75,7 +75,7 @@ export function resolveAdminUiUrl(adminUiUrl: string, apiServerUrl: string): str
* *
* Expects: * Expects:
* - `requestUrl` is the incoming server URL. * - `requestUrl` is the incoming server URL.
* - `adminUiUrl` points to the standalone admin UI base path. * - `adminUiUrl` points to the standalone admin UI base.
* *
* Returns: * Returns:
* - The external admin UI URL preserving route suffix and query string. * - The external admin UI URL preserving route suffix and query string.
@@ -4,42 +4,42 @@ import { buildAdminUiRedirectUrl, buildAdminUiUrl, resolveAdminUiUrl } from '../
describe('admin UI URL helpers', () => { describe('admin UI URL helpers', () => {
it('builds admin UI URLs under the configured admin base path', () => { it('builds admin UI URLs under the configured admin base path', () => {
expect(buildAdminUiUrl('https://admin.airi.build/admin', '/users', '?query=alice')).toBe( expect(buildAdminUiUrl('https://admin.airi.build', '/users', '?query=alice')).toBe(
'https://admin.airi.build/admin/users?query=alice', 'https://admin.airi.build/users?query=alice',
) )
}) })
it('maps server /admin requests to the standalone admin UI while preserving queries', () => { it('maps server /admin requests to the standalone admin UI while preserving queries', () => {
expect(buildAdminUiRedirectUrl( expect(buildAdminUiRedirectUrl(
'https://admin.airi.build/admin/', 'https://admin.airi.build/',
'https://api.airi.build/admin/voice-packs?provider=openai', 'https://api.airi.build/admin/voice-packs?provider=openai',
)).toBe('https://admin.airi.build/admin/voice-packs?provider=openai') )).toBe('https://admin.airi.build/voice-packs?provider=openai')
}) })
it('adds the API server origin for standalone admin UI cross-environment redirects', () => { it('adds the API server origin for standalone admin UI cross-environment redirects', () => {
expect(buildAdminUiRedirectUrl( expect(buildAdminUiRedirectUrl(
'https://admin-preview.example/admin/', 'https://admin-preview.example/',
'https://airi-server-dev.up.railway.app/admin/users?api_server_url=https%3A%2F%2Fevil.example', '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', 'https://airi-server-dev.up.railway.app/api/admin',
)).toBe( )).toBe(
'https://admin-preview.example/admin/users?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app', 'https://admin-preview.example/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', () => { it('routes server-dev default admin UI redirects to the matching Pages branch', () => {
expect(buildAdminUiRedirectUrl( expect(buildAdminUiRedirectUrl(
'https://admin.airi.build/admin', 'https://admin.airi.build',
'https://airi-server-dev.up.railway.app/admin/users?query=alice', 'https://airi-server-dev.up.railway.app/admin/users?query=alice',
'https://airi-server-dev.up.railway.app', 'https://airi-server-dev.up.railway.app',
)).toBe( )).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', 'https://server-dev.airi-server-admin.pages.dev/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', () => { it('keeps an explicitly configured admin UI URL for server-dev', () => {
expect(resolveAdminUiUrl( expect(resolveAdminUiUrl(
'https://admin-preview.example/admin', 'https://admin-preview.example',
'https://airi-server-dev.up.railway.app', 'https://airi-server-dev.up.railway.app',
)).toBe('https://admin-preview.example/admin') )).toBe('https://admin-preview.example')
}) })
}) })
+1 -1
View File
@@ -1,6 +1,6 @@
# AIRI Admin Dashboard # AIRI Admin Dashboard
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. Admin dashboard for operating the hosted AIRI server. It is a standalone Vue/Vite app deployed to Cloudflare Pages; the API server redirects its historical `/admin/*` entrypoints to this app.
## Use When ## Use When
+1 -3
View File
@@ -1,3 +1 @@
/ /admin/ 302 /* /index.html 200
/admin /index.html 200
/admin/* /index.html 200
+1 -1
View File
@@ -20,7 +20,7 @@ import './styles/main.css'
import 'uno.css' import 'uno.css'
const router = createRouter({ const router = createRouter({
history: createWebHistory('/admin/'), history: createWebHistory('/'),
routes: [ routes: [
{ path: '/', component: OverviewPage }, { path: '/', component: OverviewPage },
{ path: '/users', component: UsersPage }, { path: '/users', component: UsersPage },
@@ -7,16 +7,16 @@ import { getServerAdminBootstrapContext, resolveStandaloneServerAdminContext } f
describe('ui-admin bootstrap context', () => { describe('ui-admin bootstrap context', () => {
it('uses the trusted API server origin carried by standalone server redirects', () => { it('uses the trusted API server origin carried by standalone server redirects', () => {
expect(resolveStandaloneServerAdminContext( expect(resolveStandaloneServerAdminContext(
'https://admin.airi.build/admin/users?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app%2Fapi%2Fadmin', 'https://admin.airi.build/users?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app%2Fapi%2Fadmin',
)).toEqual({ )).toEqual({
apiServerUrl: 'https://airi-server-dev.up.railway.app', 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', currentUrl: 'https://admin.airi.build/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', () => { it('ignores untrusted API server origins from crafted standalone admin URLs', () => {
expect(resolveStandaloneServerAdminContext( expect(resolveStandaloneServerAdminContext(
'https://admin.airi.build/admin/users?api_server_url=https%3A%2F%2Fevil.example', 'https://admin.airi.build/users?api_server_url=https%3A%2F%2Fevil.example',
)).toBeNull() )).toBeNull()
}) })
-1
View File
@@ -7,7 +7,6 @@ import VueMacros from 'vue-macros/vite'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
export default defineConfig({ export default defineConfig({
base: '/',
resolve: { resolve: {
alias: { alias: {
'@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')), '@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')),
+1
View File
@@ -1,3 +1,4 @@
/ /ui/profile 302
/auth /ui/ 302 /auth /ui/ 302
/auth/* /ui/:splat 302 /auth/* /ui/:splat 302
/ui /index.html 200 /ui /index.html 200