Don't redirect non-auth-host /auth/ to auth site but show basic info on current host, and allow logging out. Adds a new host app for this purpose.
This commit is contained in:
6
API.md
6
API.md
@@ -14,14 +14,14 @@ Two deployment modes:
|
|||||||
|
|
||||||
2. Dedicated auth host (`--auth-host auth.example.com`)
|
2. Dedicated auth host (`--auth-host auth.example.com`)
|
||||||
- The specified auth host serves the UI at the root (`/`, `/admin/`, reset tokens, etc.).
|
- The specified auth host serves the UI at the root (`/`, `/admin/`, reset tokens, etc.).
|
||||||
- Other (non‑auth) hosts expose only non‑restricted API endpoints; UI is redirected to the auth host.
|
- Other (non‑auth) hosts show a lightweight account summary at `/` or `/auth/`, while other UI routes still redirect to the auth host.
|
||||||
- Restricted endpoints on non‑auth hosts return `404` instead of redirecting.
|
- Restricted endpoints on non‑auth hosts return `404` instead of redirecting.
|
||||||
|
|
||||||
### Path Mapping When Auth Host Enabled
|
### Path Mapping When Auth Host Enabled
|
||||||
|
|
||||||
| Purpose | On Auth Host | On Other Hosts (incoming) | Action |
|
| Purpose | On Auth Host | On Other Hosts (incoming) | Action |
|
||||||
|---------|--------------|---------------------------|--------|
|
|---------|--------------|---------------------------|--------|
|
||||||
| Main UI | `/` | `/auth/` or `/` | Redirect -> auth host `/` (strip leading `/auth` if present) |
|
| Main UI | `/` | `/auth/` or `/` | Serve account summary SPA (no redirect) |
|
||||||
| Admin UI root | `/admin/` | `/auth/admin/` or `/admin/` | Redirect -> auth host `/admin/` (strip `/auth`) |
|
| Admin UI root | `/admin/` | `/auth/admin/` or `/admin/` | Redirect -> auth host `/admin/` (strip `/auth`) |
|
||||||
| Reset / device addition token | `/{token}` | `/auth/{token}` | Redirect -> auth host `/{token}` (strip `/auth`) |
|
| Reset / device addition token | `/{token}` | `/auth/{token}` | Redirect -> auth host `/{token}` (strip `/auth`) |
|
||||||
| Static assets | `/auth/assets/*` | `/auth/assets/*` | Served directly (no redirect) |
|
| Static assets | `/auth/assets/*` | `/auth/assets/*` | Served directly (no redirect) |
|
||||||
@@ -38,7 +38,7 @@ Notes:
|
|||||||
|
|
||||||
| Method | Path (multi‑host) | Path (auth host) | Description |
|
| Method | Path (multi‑host) | Path (auth host) | Description |
|
||||||
|--------|-------------------|------------------|-------------|
|
|--------|-------------------|------------------|-------------|
|
||||||
| GET | `/auth/` | `/` | Main authentication SPA |
|
| GET | `/auth/` | `/` | Main authentication SPA (non-auth hosts show an account summary view) |
|
||||||
| GET | `/auth/admin/` | `/admin/` | Admin SPA root |
|
| GET | `/auth/admin/` | `/admin/` | Admin SPA root |
|
||||||
| GET | `/auth/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) |
|
| GET | `/auth/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) |
|
||||||
| GET | `/auth/restricted` | `/restricted` | Restricted / permission denied SPA |
|
| GET | `/auth/restricted` | `/restricted` | Restricted / permission denied SPA |
|
||||||
|
|||||||
12
frontend/host/index.html
Normal file
12
frontend/host/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Account Summary</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/host/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
135
frontend/src/host/HostApp.vue
Normal file
135
frontend/src/host/HostApp.vue
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<StatusMessage />
|
||||||
|
<main class="view-root host-view">
|
||||||
|
<div class="view-content">
|
||||||
|
<header class="view-header">
|
||||||
|
<h1>{{ headingTitle }}</h1>
|
||||||
|
<p class="view-lede">{{ subheading }}</p>
|
||||||
|
<p v-if="authSiteUrl" class="view-hint">
|
||||||
|
Manage your full profile on
|
||||||
|
<a :href="authSiteUrl">{{ authSiteHost }}</a> (you may need to login again).
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="section-block">
|
||||||
|
<div class="section-body">
|
||||||
|
<UserBasicInfo
|
||||||
|
v-if="user"
|
||||||
|
:name="user.user_name"
|
||||||
|
:visits="user.visits || 0"
|
||||||
|
:created-at="user.created_at"
|
||||||
|
:last-seen="user.last_seen"
|
||||||
|
:org-display-name="orgDisplayName"
|
||||||
|
:role-name="roleDisplayName"
|
||||||
|
:can-edit="false"
|
||||||
|
/>
|
||||||
|
<p v-else class="empty-state">
|
||||||
|
{{ initializing ? 'Loading your account…' : 'No active session found.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-block">
|
||||||
|
<div class="section-body host-actions">
|
||||||
|
<div class="button-row">
|
||||||
|
<button
|
||||||
|
v-if="authSiteUrl"
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary"
|
||||||
|
:disabled="authStore.isLoading"
|
||||||
|
@click="goToAuthSite"
|
||||||
|
>
|
||||||
|
Open full profile
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
:disabled="authStore.isLoading"
|
||||||
|
@click="logout"
|
||||||
|
>
|
||||||
|
{{ authStore.isLoading ? 'Signing out…' : 'Logout' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="note">Signed in on <strong>{{ currentHost }}</strong>.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import StatusMessage from '@/components/StatusMessage.vue'
|
||||||
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const initializing = ref(true)
|
||||||
|
const currentHost = window.location.host
|
||||||
|
|
||||||
|
const user = computed(() => authStore.userInfo?.user || null)
|
||||||
|
const orgDisplayName = computed(() => authStore.userInfo?.org?.display_name || '')
|
||||||
|
const roleDisplayName = computed(() => authStore.userInfo?.role?.display_name || '')
|
||||||
|
|
||||||
|
const headingTitle = computed(() => {
|
||||||
|
const service = authStore.settings?.rp_name
|
||||||
|
return service ? `${service} account` : 'Account overview'
|
||||||
|
})
|
||||||
|
|
||||||
|
const subheading = computed(() => {
|
||||||
|
const service = authStore.settings?.rp_name || 'this service'
|
||||||
|
return `You\u2019re signed in via ${service} on ${currentHost}.`
|
||||||
|
})
|
||||||
|
|
||||||
|
const authSiteHost = computed(() => authStore.settings?.auth_host || '')
|
||||||
|
const authSiteUrl = computed(() => {
|
||||||
|
const host = authSiteHost.value
|
||||||
|
if (!host) return ''
|
||||||
|
let path = authStore.settings?.ui_base_path ?? '/auth/'
|
||||||
|
if (!path.startsWith('/')) path = `/${path}`
|
||||||
|
if (!path.endsWith('/')) path = `${path}/`
|
||||||
|
const protocol = window.location.protocol || 'https:'
|
||||||
|
return `${protocol}//${host}${path}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const goToAuthSite = () => {
|
||||||
|
if (!authSiteUrl.value) return
|
||||||
|
window.location.href = authSiteUrl.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await authStore.logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await authStore.loadSettings()
|
||||||
|
const service = authStore.settings?.rp_name
|
||||||
|
if (service) document.title = `${service} · Account summary`
|
||||||
|
await authStore.loadUserInfo()
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unable to load session details'
|
||||||
|
authStore.showMessage(message, 'error', 4000)
|
||||||
|
} finally {
|
||||||
|
initializing.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.host-view { padding: 3rem 1.5rem 4rem; }
|
||||||
|
.host-actions { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.host-actions .button-row { gap: 0.75rem; flex-wrap: wrap; }
|
||||||
|
.host-actions .button-row button { flex: 0 0 auto; }
|
||||||
|
.note { margin: 0; color: var(--color-text-muted); }
|
||||||
|
.link { color: var(--color-accent); text-decoration: none; }
|
||||||
|
.link:hover { text-decoration: underline; }
|
||||||
|
.view-hint { margin-top: 0.5rem; color: var(--color-text-muted); }
|
||||||
|
.empty-state { margin: 0; color: var(--color-text-muted); }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.host-actions .button-row { flex-direction: column; }
|
||||||
|
.host-actions .button-row button { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
frontend/src/host/main.js
Normal file
11
frontend/src/host/main.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import '@/assets/style.css'
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import HostApp from './HostApp.vue'
|
||||||
|
|
||||||
|
const app = createApp(HostApp)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
@@ -33,6 +33,8 @@ export default defineConfig(({ command, mode }) => ({
|
|||||||
// Bypass only root SPA entrypoints + static assets so Vite serves them for HMR.
|
// Bypass only root SPA entrypoints + static assets so Vite serves them for HMR.
|
||||||
// Admin API endpoints (e.g., /auth/admin/orgs) must still hit backend.
|
// Admin API endpoints (e.g., /auth/admin/orgs) must still hit backend.
|
||||||
if (url === '/auth/' || url === '/auth') return '/'
|
if (url === '/auth/' || url === '/auth') return '/'
|
||||||
|
if (url === '/auth/host' || url === '/auth/host/') return '/host/index.html'
|
||||||
|
if (url === '/host' || url === '/host/') return '/host/index.html'
|
||||||
if (url === '/auth/admin' || url === '/auth/admin/') return '/admin/'
|
if (url === '/auth/admin' || url === '/auth/admin/') return '/admin/'
|
||||||
if (url.startsWith('/auth/assets/')) return url.replace(/^\/auth/, '')
|
if (url.startsWith('/auth/assets/')) return url.replace(/^\/auth/, '')
|
||||||
if (/^\/auth\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html'
|
if (/^\/auth\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html'
|
||||||
@@ -53,7 +55,8 @@ export default defineConfig(({ command, mode }) => ({
|
|||||||
index: resolve(__dirname, 'index.html'),
|
index: resolve(__dirname, 'index.html'),
|
||||||
admin: resolve(__dirname, 'admin/index.html'),
|
admin: resolve(__dirname, 'admin/index.html'),
|
||||||
reset: resolve(__dirname, 'reset/index.html'),
|
reset: resolve(__dirname, 'reset/index.html'),
|
||||||
restricted: resolve(__dirname, 'restricted/index.html')
|
restricted: resolve(__dirname, 'restricted/index.html'),
|
||||||
|
host: resolve(__dirname, 'host/index.html')
|
||||||
},
|
},
|
||||||
output: {}
|
output: {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ from passkey.util import hostutil, passphrase
|
|||||||
|
|
||||||
def is_ui_path(path: str) -> bool:
|
def is_ui_path(path: str) -> bool:
|
||||||
"""Check if the path is a UI endpoint."""
|
"""Check if the path is a UI endpoint."""
|
||||||
ui_paths = {"/", "/admin", "/admin/", "/auth/", "/auth/admin", "/auth/admin/"}
|
ui_paths = {
|
||||||
|
"/",
|
||||||
|
"/admin",
|
||||||
|
"/admin/",
|
||||||
|
"/auth",
|
||||||
|
"/auth/",
|
||||||
|
"/auth/admin",
|
||||||
|
"/auth/admin/",
|
||||||
|
}
|
||||||
if path in ui_paths:
|
if path in ui_paths:
|
||||||
return True
|
return True
|
||||||
# Treat reset token pages as UI (dynamic). Accept single-segment tokens.
|
# Treat reset token pages as UI (dynamic). Accept single-segment tokens.
|
||||||
@@ -30,6 +38,8 @@ def is_restricted_path(path: str) -> bool:
|
|||||||
|
|
||||||
def should_redirect_to_auth_host(path: str) -> bool:
|
def should_redirect_to_auth_host(path: str) -> bool:
|
||||||
"""Determine if the request should be redirected to the auth host."""
|
"""Determine if the request should be redirected to the auth host."""
|
||||||
|
if path in {"/", "/auth", "/auth/"}:
|
||||||
|
return False
|
||||||
return is_ui_path(path) or is_restricted_path(path)
|
return is_ui_path(path) or is_restricted_path(path)
|
||||||
|
|
||||||
|
|
||||||
@@ -47,7 +57,7 @@ def should_redirect_auth_path_to_root(path: str) -> bool:
|
|||||||
"""Check if /auth/ UI path should be redirected to root on auth host."""
|
"""Check if /auth/ UI path should be redirected to root on auth host."""
|
||||||
if not path.startswith("/auth/"):
|
if not path.startswith("/auth/"):
|
||||||
return False
|
return False
|
||||||
ui_paths = {"/auth/", "/auth/admin", "/auth/admin/"}
|
ui_paths = {"/auth", "/auth/", "/auth/admin", "/auth/admin/"}
|
||||||
if path in ui_paths:
|
if path in ui_paths:
|
||||||
return True
|
return True
|
||||||
# Check for reset token
|
# Check for reset token
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ async def frontapp(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await get_session(auth, host=request.headers.get("host"))
|
await get_session(auth, host=request.headers.get("host"))
|
||||||
|
cfg_host = hostutil.configured_auth_host()
|
||||||
|
if cfg_host:
|
||||||
|
cur_host = hostutil.normalize_host(request.headers.get("host"))
|
||||||
|
cfg_normalized = hostutil.normalize_host(cfg_host)
|
||||||
|
if cur_host and cfg_normalized and cur_host != cfg_normalized:
|
||||||
|
return FileResponse(frontend.file("host", "index.html"))
|
||||||
return FileResponse(frontend.file("index.html"))
|
return FileResponse(frontend.file("index.html"))
|
||||||
except Exception:
|
except Exception:
|
||||||
if auth:
|
if auth:
|
||||||
|
|||||||
Reference in New Issue
Block a user