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`)
|
||||
- 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.
|
||||
|
||||
### Path Mapping When Auth Host Enabled
|
||||
|
||||
| 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`) |
|
||||
| Reset / device addition token | `/{token}` | `/auth/{token}` | Redirect -> auth host `/{token}` (strip `/auth`) |
|
||||
| Static assets | `/auth/assets/*` | `/auth/assets/*` | Served directly (no redirect) |
|
||||
@@ -38,7 +38,7 @@ Notes:
|
||||
|
||||
| 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/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) |
|
||||
| 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.
|
||||
// Admin API endpoints (e.g., /auth/admin/orgs) must still hit backend.
|
||||
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.startsWith('/auth/assets/')) return url.replace(/^\/auth/, '')
|
||||
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'),
|
||||
admin: resolve(__dirname, 'admin/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: {}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,15 @@ from passkey.util import hostutil, passphrase
|
||||
|
||||
def is_ui_path(path: str) -> bool:
|
||||
"""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:
|
||||
return True
|
||||
# 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:
|
||||
"""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)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
if not path.startswith("/auth/"):
|
||||
return False
|
||||
ui_paths = {"/auth/", "/auth/admin", "/auth/admin/"}
|
||||
ui_paths = {"/auth", "/auth/", "/auth/admin", "/auth/admin/"}
|
||||
if path in ui_paths:
|
||||
return True
|
||||
# Check for reset token
|
||||
|
||||
@@ -76,6 +76,12 @@ async def frontapp(
|
||||
|
||||
try:
|
||||
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"))
|
||||
except Exception:
|
||||
if auth:
|
||||
|
||||
Reference in New Issue
Block a user