fix(fam-ui): 未登录自动跳转 OIDC + 登录/退出入口,打通 smart-camera 登录
This commit is contained in:
19
PROGRESS.md
19
PROGRESS.md
@@ -522,3 +522,22 @@ AI 分析瓶颈: 帧5耗时 229s (疑似 ARM CPU 热降频), 其余帧 50-65s
|
|||||||
2. `.env` 里 `AUTH_HUB_*` 是裸赋值(无 `export`),`source` 后不进环境,gunicorn 子进程读不到 → 登录一直 503 fail-closed(与之前“生产未部署”一致)。脚本加 `set -a` 包裹 source 修复。
|
2. `.env` 里 `AUTH_HUB_*` 是裸赋值(无 `export`),`source` 后不进环境,gunicorn 子进程读不到 → 登录一直 503 fail-closed(与之前“生产未部署”一致)。脚本加 `set -a` 包裹 source 修复。
|
||||||
|
|
||||||
**验证**:`smart-camera.zichuan.xyz/` 200、`/login` 302→auth.zichuan.xyz(redirect_uri=smart-camera)、`/api/*` 401;`oracle.zichuan.xyz/` 404、`/fam` 200;`api.zichuan.xyz` 已不通。fam-core 经修复脚本重启后 health ok。
|
**验证**:`smart-camera.zichuan.xyz/` 200、`/login` 302→auth.zichuan.xyz(redirect_uri=smart-camera)、`/api/*` 401;`oracle.zichuan.xyz/` 404、`/fam` 200;`api.zichuan.xyz` 已不通。fam-core 经修复脚本重启后 health ok。
|
||||||
|
|
||||||
|
## SPA 登录打通:未登录自动跳转 OIDC(2026-09-01)
|
||||||
|
|
||||||
|
**问题**:域名收口后访问 `https://smart-camera.zichuan.xyz/timeline` 显示「⚠ 未登录 / 登陆不了啊」。后端 `/login`(302→auth-hub)与 Caddy `/login` 反代此前已验证可用,根因在**前端零登录逻辑**:`fam-ui` 调 `/api/*` 拿到 401 后只在页面上渲染字面错误 `data.error`("未登录"),从不发起 OIDC 跳转,也没有任何登录入口。
|
||||||
|
|
||||||
|
**改动(本仓库 `fam-ui/`)**:
|
||||||
|
- `src/api.js` `request()`:捕获 401 时 `window.location.href = '/login'` 触发统一登录(后端经 auth-hub 走 Authorization Code + PKCE);用模块级 `_redirectingToLogin` 开关保证单次会话只跳一次,并 `return new Promise(()=>{})` 阻止调用方继续渲染错误态;`/api/auth/check` 等白名单接口不会 401,不受影响。
|
||||||
|
- `src/api.js` `api` 对象新增 `authCheck: () => request('/api/auth/check')`。
|
||||||
|
- `src/App.vue`:`onMounted` 调 `api.authCheck()` 维护 `authed` 状态;左侧栏 + 移动端顶栏新增「🔑 登录」入口(`<a href="/login">`)与「👋 退出登录」按钮(`POST /api/logout` 后回 `/` 由后端 401 自动跳登录)。
|
||||||
|
|
||||||
|
**部署**:`npm run build` → `dist/` 经 `tar | ssh ubuntu@129.146.26.249` 覆盖 `/var/www/fam-ui`(macOS `._*` 元数据已清)。
|
||||||
|
|
||||||
|
**端到端验证**:
|
||||||
|
- `smart-camera.zichuan.xyz/timeline` → 200(SPA)
|
||||||
|
- `/login` → 302 → `auth.zichuan.xyz/authorize?...&redirect_uri=https://smart-camera.zichuan.xyz/api/auth/callback`
|
||||||
|
- `/api/auth/check`(无 cookie)→ `{"authed":false}`(白名单,不 401)
|
||||||
|
- `/api/ui/videos`(无 cookie)→ 401 → 前端据此自动跳 `/login`
|
||||||
|
|
||||||
|
登录链路已通:未登录访问任意页面 → 首个 401 → 自动跳 auth-hub 登录 → 回调种 `fam_session` cookie → 回 `/timeline` 正常加载。
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ import { navItems } from './router.js'
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const sync = ref(null)
|
const sync = ref(null)
|
||||||
|
const authed = ref(false)
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const r = await api.authCheck()
|
||||||
|
authed.value = !!r.authed
|
||||||
|
} catch {
|
||||||
|
authed.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshStatus() {
|
async function refreshStatus() {
|
||||||
try {
|
try {
|
||||||
@@ -16,8 +26,19 @@ async function refreshStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function doLogout() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/logout', { method: 'POST' })
|
||||||
|
} catch {
|
||||||
|
// 忽略网络错误,下面强制跳转即可
|
||||||
|
}
|
||||||
|
// 清掉 cookie 后回根路径,未登录态会让后端 401 -> 自动跳 /login 重新登录
|
||||||
|
window.location.href = '/'
|
||||||
|
}
|
||||||
|
|
||||||
let timer = null
|
let timer = null
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
checkAuth()
|
||||||
refreshStatus()
|
refreshStatus()
|
||||||
timer = setInterval(refreshStatus, 30000)
|
timer = setInterval(refreshStatus, 30000)
|
||||||
})
|
})
|
||||||
@@ -40,14 +61,34 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
游标 {{ sync.cursor ? fmtDateTime(sync.cursor) : '(全量)' }}
|
游标 {{ sync.cursor ? fmtDateTime(sync.cursor) : '(全量)' }}
|
||||||
<div v-if="sync.last_error" class="font-semibold text-danger">⚠ {{ sync.last_error }}</div>
|
<div v-if="sync.last_error" class="font-semibold text-danger">⚠ {{ sync.last_error }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<a v-if="!authed" href="/login"
|
||||||
|
class="block rounded-xl bg-gradient-to-br from-accent to-accent-2 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-[0_4px_16px_-4px_rgba(91,140,255,.45)] transition-opacity hover:opacity-90">
|
||||||
|
🔑 登录
|
||||||
|
</a>
|
||||||
|
<button v-else @click="doLogout"
|
||||||
|
class="block w-full rounded-xl border border-border bg-panel-2 px-4 py-2.5 text-center text-sm font-medium text-text-dim transition-colors hover:text-text">
|
||||||
|
👋 退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- 移动端顶栏:品牌 + 简要同步指示灯,lg 以上隐藏(用左侧栏代替) -->
|
<!-- 移动端顶栏:品牌 + 登录/退出 + 简要同步指示灯,lg 以上隐藏(用左侧栏代替) -->
|
||||||
<header class="flex items-center justify-between border-b border-border bg-[#090c12] px-4 py-3 lg:hidden">
|
<header class="flex items-center justify-between border-b border-border bg-[#090c12] px-4 py-3 lg:hidden">
|
||||||
<div class="text-[15px] font-bold text-[#f7f9fc]">🏠 家庭智能监控</div>
|
<div class="text-[15px] font-bold text-[#f7f9fc]">🏠 家庭智能监控</div>
|
||||||
<span v-if="sync" class="flex items-center gap-1.5 text-xs font-medium" :class="sync.running ? 'text-ok' : 'text-danger'">
|
<div class="flex items-center gap-2">
|
||||||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ sync.running ? '同步中' : '未运行' }}
|
<a v-if="!authed" href="/login"
|
||||||
</span>
|
class="rounded-lg bg-gradient-to-br from-accent to-accent-2 px-3 py-1.5 text-xs font-semibold text-white shadow-[0_4px_16px_-4px_rgba(91,140,255,.45)]">
|
||||||
|
🔑 登录
|
||||||
|
</a>
|
||||||
|
<button v-else @click="doLogout"
|
||||||
|
class="rounded-lg border border-border bg-panel-2 px-3 py-1.5 text-xs font-medium text-text-dim">
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
<span v-if="sync" class="flex items-center gap-1.5 text-xs font-medium" :class="sync.running ? 'text-ok' : 'text-danger'">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ sync.running ? '同步中' : '未运行' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="min-w-0 flex-1 px-4 py-4 sm:px-8 sm:py-6">
|
<div class="min-w-0 flex-1 px-4 py-4 sm:px-8 sm:py-6">
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
// API 薄封装:生产环境同源相对路径;开发环境走 vite.config.js 的 /api 代理。
|
// API 薄封装:生产环境同源相对路径;开发环境走 vite.config.js 的 /api 代理。
|
||||||
|
|
||||||
|
// 全局:未登录时首个 401 直接跳转到 /login 走 OIDC/PKCE 登录流程。
|
||||||
|
// 用一个模块级开关保证整次会话只触发一次跳转,避免多个并发 401 重复导航。
|
||||||
|
let _redirectingToLogin = false
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -12,6 +16,14 @@ async function request(path, options = {}) {
|
|||||||
// 非 JSON 响应(如 404 空 body),保持 data=null
|
// 非 JSON 响应(如 404 空 body),保持 data=null
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
// 401 未登录:交给后端 /login(302 到 auth-hub)发起统一登录。
|
||||||
|
// /api/auth/check 等白名单接口不会返回 401,所以这里只会命中真正的鉴权失败。
|
||||||
|
if (res.status === 401 && !_redirectingToLogin) {
|
||||||
|
_redirectingToLogin = true
|
||||||
|
window.location.href = '/login'
|
||||||
|
// 返回永挂起的 promise,阻止调用方继续渲染"未登录"错误态(页面即将跳转)
|
||||||
|
return new Promise(() => {})
|
||||||
|
}
|
||||||
const msg = (data && (data.error || data.message)) || `HTTP ${res.status}`
|
const msg = (data && (data.error || data.message)) || `HTTP ${res.status}`
|
||||||
throw new Error(msg)
|
throw new Error(msg)
|
||||||
}
|
}
|
||||||
@@ -52,6 +64,7 @@ export const api = {
|
|||||||
request('/api/member/merge', { method: 'POST', body: JSON.stringify({ source, target }) }),
|
request('/api/member/merge', { method: 'POST', body: JSON.stringify({ source, target }) }),
|
||||||
|
|
||||||
status: () => request('/api/status'),
|
status: () => request('/api/status'),
|
||||||
|
authCheck: () => request('/api/auth/check'),
|
||||||
syncTrigger: () => request('/api/sync/trigger', { method: 'POST' }),
|
syncTrigger: () => request('/api/sync/trigger', { method: 'POST' }),
|
||||||
|
|
||||||
identityCorrect: (video_id, current_name, new_name) =>
|
identityCorrect: (video_id, current_name, new_name) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user