登录子系统:OIDC 统一自签 token + 吊销即时生效 + 设备并入会话列表 + 应用内扫码 + 禁则修复
接续 90a3790(扫码 A0+A1+B + step-up 雏形)。蓝本见 AUTH.md。
OIDC 统一到 cdrop 自签 token
- /auth/exchange 先 JWKS 验 id_token 签名(自签 token 的唯一信任锚)再签 cdrop
自签 HS256 access token(绑 sid),不再把 IdP RS256 下发浏览器;验签失败仅告警
并回退 RS256(会话仍建、下次 refresh 自愈),登录不中断
- /auth/refresh oidc 路径仍打 IdP 轮换 refresh_token + 探测 IdP 侧吊销,但同样回
自签 token;createWebSession 改返回行 id
- verify 链保留 RS256 分支仅供桌面端 loopback(其即时吊销留后续)
吊销即时生效 + 被吊销设备自知回退
- verifySelfToken 每请求按 sid 查 web_sessions 行,行删即拒 → 吊销在被吊销设备
下一次请求即生效(不等 TTL),oidc / self / guest 一致
- 前端仅在「服务端以 401 确证会话失效」时 forceLogout 清登录态、回登录页;短期连接
失败(5xx / 网络)一律保留登录态(refreshTokens 改三态 refreshed/invalid/transient,
cookieRefresh 改 discriminated 结果)
- __root 反应式守卫:prod 下失登录态且非认证路由即跳登录页;新增 auth.sessionLost.* 三语
设备列表并入会话列表 + 孤儿自动清除
- GET /api/auth/sessions 统一 web_sessions 与原生客户端设备(桌面 / iOS,自 devices
登记表呈现,native=true,离线也列);同名不重复列出,排除 shortcut 与无会话 browser
- 吊销网页会话连带删其设备登记(无同名在用会话时);原生「登出」走
DELETE /api/devices/{name}(同要求 step-up)
- 新增 DeleteOrphanBrowserDevices(browser 型且无存活 web_session),接入设备清扫器(1h)
应用内扫码 + 聚珍崩溃 / CJK 禁则修复
- 登录页内置 getUserMedia + jsqr 扫码,不再外跳系统应用(避免开错浏览器)
- 修聚珍(cjk-autospace)MutationObserver 与 React 重渲染同子树冲突致的 insertBefore
崩溃:扫码 / 显码 / 批准三状态机页整页跳过聚珍(data-jz-skip)
- 修流动正文未跑 CJK 禁则(jinze 为段落级、须 opt-in):设置 / 快捷指令 / 桌面页一批
hint 补 data-jz-level="paragraph" + justify,短标签 / 状态仍留文本级
step-up 完善
- auth_time 改可选(Casdoor 不下发,原强制致死循环);stepped_up_at 按会话绑定、
StepUpMaxAgeSeconds 窗口内不复要求;新增 POST /api/auth/stepup
- 批准页 persist 编进 step-up returnTo 防整页跳转丢失;scope 随档绑定(信任=full /
仅此次=guest),默认偏安全的「仅此次」
文档与测试
- AUTH.md:折中架构、accounts 薄表、令牌架构、扫码流程、step-up、§4.4 统一会话列表
- 新增 Go 测试:OIDC 自签验证、verifySelfToken 吊销即拒、会话吊销连带删设备、
统一会话列表(含原生 / 不含 shortcut / 不重复)、孤儿清扫、删设备需 step-up
This commit is contained in:
@@ -25,7 +25,7 @@
|
|||||||
- **决策 B — `user_id` 继续 = OIDC `sub`,不引内部 id**。新增薄 `accounts` 表(键在 `sub`),其中存一个**稳定匹配键**(默认取 JWT 的 `email` claim)。日常切换 OAuth provider 即账号失效——**运维须知此前提**。仅当管理员显式启用“后端迁移标记”时,cdrop 才在新源登录时按该匹配键自动关联回同一账号(受控窗口,非日常自动 link,规避账号接管)。
|
- **决策 B — `user_id` 继续 = OIDC `sub`,不引内部 id**。新增薄 `accounts` 表(键在 `sub`),其中存一个**稳定匹配键**(默认取 JWT 的 `email` claim)。日常切换 OAuth provider 即账号失效——**运维须知此前提**。仅当管理员显式启用“后端迁移标记”时,cdrop 才在新源登录时按该匹配键自动关联回同一账号(受控窗口,非日常自动 link,规避账号接管)。
|
||||||
- **决策 C — 2FA 委派给 provider**。cdrop 不自存任何第二因子密钥,仅保留敏感动作的 **step-up 钩子**(要求 provider 再认证),默认关闭。
|
- **决策 C — 2FA 委派给 provider**。cdrop 不自存任何第二因子密钥,仅保留敏感动作的 **step-up 钩子**(要求 provider 再认证),默认关闭。
|
||||||
- **决策 D — 扫码方向 = 陌生设备显码、已登录手机扫码批准**(微信 / WhatsApp 网页版模型)。陌生设备常无摄像头但有屏,最贴合“临时借设备传文件”。
|
- **决策 D — 扫码方向 = 陌生设备显码、已登录手机扫码批准**(微信 / WhatsApp 网页版模型)。陌生设备常无摄像头但有屏,最贴合“临时借设备传文件”。
|
||||||
- **决策 E — 扫码会话 = 受限访客会话,持久性可选**。批准时选“信任此设备(7 天滑动)”或“仅此次(1 小时)”;访客会话能收发文件,但不能改账号、不能再批准别的设备、不能签发长效 token。
|
- **决策 E — 扫码会话两档,scope 与持久性绑定**。批准时二选一:“信任此设备(完整会话,7 天滑动)”——授予 scope=full、kind=self,可完整使用本账号(自有设备用);“仅此次(受限访客,1 小时)”——授予 scope=guest、kind=guest,能收发文件与消息,但不能改账号、不能再批准别的设备、不能签发长效 token(陌生/借用设备用)。默认偏向更安全的“仅此次”。(原设计两档皆 guest、仅持久性不同;2026-06-21 实测后改为 scope 随档绑定,消除“信任却受限”的语义矛盾。)
|
||||||
|
|
||||||
> 本文取代 `.scratch/auth-inversion-diff.html` 中按“全反转”写的 §四 大半——那五个“凭证保管者”决策(无邮件重置 / 开放注册 / argon2id 等)在折中下随委派 provider 而消散。
|
> 本文取代 `.scratch/auth-inversion-diff.html` 中按“全反转”写的 §四 大半——那五个“凭证保管者”决策(无邮件重置 / 开放注册 / argon2id 等)在折中下随委派 provider 而消散。
|
||||||
|
|
||||||
@@ -129,9 +129,10 @@ CREATE INDEX IF NOT EXISTS idx_login_requests_expires ON login_requests (expires
|
|||||||
- **签名密钥**:从 `SESSION_SECRET` 派生独立 HMAC 密钥(`DeriveSessionTokenKey(SESSION_SECRET)`,与既有 `DeriveHS256Key(HS256Secret)` 区分密钥域)。`SESSION_SECRET` 在 prod 本就强制非空,无新增必填密钥。
|
- **签名密钥**:从 `SESSION_SECRET` 派生独立 HMAC 密钥(`DeriveSessionTokenKey(SESSION_SECRET)`,与既有 `DeriveHS256Key(HS256Secret)` 区分密钥域)。`SESSION_SECRET` 在 prod 本就强制非空,无新增必填密钥。
|
||||||
- **载荷**:`{ sub: user_id, sid: session_id, typ: "session", scope: "full"|"guest", iat, exp }`。短 TTL(默认 900s)。
|
- **载荷**:`{ sub: user_id, sid: session_id, typ: "session", scope: "full"|"guest", iat, exp }`。短 TTL(默认 900s)。
|
||||||
- **校验**:中间件新增 `verifySelfToken` 分支(§9)。靠 `typ:"session"` + 独立签名密钥与 scoped shortcut token(`typ` 缺省、`scope:"clipboard"`、走 `shortcut_tokens` 查吊销)区分,互不串验。
|
- **校验**:中间件新增 `verifySelfToken` 分支(§9)。靠 `typ:"session"` + 独立签名密钥与 scoped shortcut token(`typ` 缺省、`scope:"clipboard"`、走 `shortcut_tokens` 查吊销)区分,互不串验。
|
||||||
- **吊销 / 撤销**:access token 无状态、短命;长效凭证是 `web_sessions` 行。`/auth/refresh` 每次(≤TTL 周期)查 session 行存活与未吊销,故吊销在一个 TTL 内生效。`oidc` 会话 refresh 仍打 IdP,保留 IdP 侧登出 / 吊销传播。
|
- **吊销 / 撤销(即时)**:长效凭证是 `web_sessions` 行;access token 短命且**绑定 `sid`**。`verifySelfToken` 每请求按 `sid` 查 session 行——行被删除(吊销)即当场拒绝,故吊销在被吊销设备的**下一次请求**即生效,而非等一个 TTL。`oidc` 会话 refresh 仍打 IdP,保留 IdP 侧登出 / 吊销传播。
|
||||||
|
- **被吊销设备的感知与回退**:服务端拒绝之外,前端把“访问被拒且续期也被拒”视作会话不可恢复 → 就地清空登录态并回到登录页(`net/api.ts` 的 `forceLogout` + `__root` 反应式守卫)。**离线设备**于下一次使用(请求 401 或开机 cookie 续期失败)得知;**在线设备**经 `Hub.Kick` 关流 → 约 1s 后重连撞 401 得知。两路殊途同归到 `verifySelfToken` 的 `sid` 查行。
|
||||||
|
|
||||||
> RS256(IdP access token)退出每请求热路径,仅在 OIDC exchange 时验 `id_token` 签名一次(§5)。中间件保留 RS256 分支作兼容(桌面端等仍直接带 IdP token 的客户端),待后续统一迁移到自签后再清理——避免 flag day。
|
> RS256(IdP access token)已退出浏览器每请求热路径:浏览器登录(含 OIDC)一律持自签 token,OIDC exchange 时验 `id_token` 签名一次(§5)作唯一信任锚,refresh 仍打 IdP 轮换 refresh_token 但同样回自签 token。中间件保留 RS256 分支仅供**桌面端**(自跑 loopback PKCE、直带 IdP token);桌面端即时吊销留后续。
|
||||||
|
|
||||||
### 3.2 受限能力门(guest 会话)
|
### 3.2 受限能力门(guest 会话)
|
||||||
|
|
||||||
@@ -179,6 +180,14 @@ CREATE INDEX IF NOT EXISTS idx_login_requests_expires ON login_requests (expires
|
|||||||
|
|
||||||
前端:新设备“显码页”+ 手机“批准页”+ 设备 / 会话管理(列出 `granted_by` 借用设备、可吊销)。
|
前端:新设备“显码页”+ 手机“批准页”+ 设备 / 会话管理(列出 `granted_by` 借用设备、可吊销)。
|
||||||
|
|
||||||
|
### 4.4 登录会话列表 = 全部设备(统一视图 + 自动清除)
|
||||||
|
|
||||||
|
「登录会话」面板收敛到“一台设备 = 一条会话”:列表即当前登录此账号的全部设备。既往“手动清除设备”入口已撤,导致被吊销设备长期滞留——本节即为此消除。
|
||||||
|
|
||||||
|
- **统一列表**(`GET /api/auth/sessions`):`web_sessions`(浏览器 oidc / self / guest、扫码)**并入**原生客户端设备(桌面 / iOS,`type ∈ {macos, windows, linux, ios}`)。原生客户端用 IdP 令牌、不入 `web_sessions`,故从设备登记表 `devices`(即决策里说的“等价表”)呈现:`id` 形如 `device:<设备名>`、`kind` 取设备类型、`native=true`。同名已有会话的设备不重复列出;无会话的 `browser` 型(孤儿)与 `shortcut` 型(自有面板)不在此列;过期会话已隐藏。这满足“原生设备也在会话列表登记”,且离线原生设备照常显示(`devices` 留有 `last_seen`)。
|
||||||
|
- **吊销即清设备**:吊销网页会话(`DELETE /api/auth/sessions/{id}`)在删会话行后**连带删除其设备登记**(除非另有同名在用会话),再 Kick + 广播 presence → 被吊销设备即时从所有列表消失。原生客户端的“登出”改走 `DELETE /api/devices/{name}`(同样要求 step-up),删设备 + Kick;该设备若仍在线,会在下次请求经中间件重新登记(原生**即时**吊销需客户端配合,留后续)。
|
||||||
|
- **孤儿自动清除**:设备清扫器(每 1h)除按 `last_seen` TTL 清陈旧设备外,新增清除“`browser` 型且无存活 `web_session`”的孤儿(会话已撤 / 过期但设备行滞留);`DeleteOrphanBrowserDevices` 一条 SQL 完成,原生 / shortcut 设备无 `web_session` 属正常、仅受 TTL 清扫。即时路径(吊销)+ 周期路径(清扫)合起来让设备列表与会话列表持续对齐。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 账号密码 / Passkey 登录(委派 provider)
|
## 5. 账号密码 / Passkey 登录(委派 provider)
|
||||||
@@ -196,7 +205,7 @@ CREATE INDEX IF NOT EXISTS idx_login_requests_expires ON login_requests (expires
|
|||||||
## 6. 2FA(委派 + step-up 钩子,已实装)
|
## 6. 2FA(委派 + step-up 钩子,已实装)
|
||||||
|
|
||||||
- **默认**:2FA 在 provider 完成(Casdoor TOTP / passkey-as-2FA),cdrop 不感知、不存密钥。
|
- **默认**:2FA 在 provider 完成(Casdoor TOTP / passkey-as-2FA),cdrop 不感知、不存密钥。
|
||||||
- **step-up 钩子**(决策 C,默认关,`CDROP_STEP_UP_ENABLED`;新鲜度窗口 `CDROP_STEP_UP_MAX_AGE_SECONDS`,默认 300):开启后,批准扫码设备(`qr/approve`)这类敏感动作要求一次**现场再认证**。机制:前端先跑一次带 `prompt=login` / `max_age=0` 的新鲜 PKCE,把拿到的授权码 + verifier 交给 `qr/approve`;后端**就地**用它向 IdP 换 `id_token`,JWKS 验签 + 校验 `sub` 等于调用者 + `auth_time` 在窗口内,方才放行(provider 若开了 2FA,即在这次 prompt=login 往返中强制)。失败返回 `403 step_up_required`。授权码一次性、不可重放;cdrop 不存任何第二因子,也不留 step-up 服务端状态——证明随这一次批准请求过期。
|
- **step-up 钩子**(决策 C,默认关,`CDROP_STEP_UP_ENABLED`;新鲜度窗口 `CDROP_STEP_UP_MAX_AGE_SECONDS`,默认 300):开启后,批准扫码设备(`qr/approve`)这类敏感动作要求一次**现场再认证**。机制:前端先跑一次带 `prompt=login` / `max_age=0` 的新鲜 PKCE,把拿到的授权码 + verifier 交给 `qr/approve`;后端**就地**用它向 IdP 换 `id_token`,JWKS 验签 + 校验 `sub` 等于调用者,方才放行(provider 若开了 2FA,即在这次 prompt=login 往返中强制)。`auth_time` 为**可选**——OIDC 仅在 IdP 选择遵循 `max_age` 时才要求它,**Casdoor 不下发**;有则强制 `StepUpMaxAgeSeconds` 窗口,无则以「单次短寿授权码刚换出」作为新鲜度界(否则会因缺 `auth_time` 而恒拒、陷死循环,2026-06-21 实测修正)。失败返回 `403 step_up_required`。授权码一次性、不可重放;cdrop 不存任何第二因子,也不留 step-up 服务端状态——证明随这一次批准请求过期。
|
||||||
- `qr/request` 响应回带 `step_up` 标志,供批准页判断是否需先再认证。
|
- `qr/request` 响应回带 `step_up` 标志,供批准页判断是否需先再认证。
|
||||||
- passkey 作为“快速登录”的补充:陌生设备场景下扫码优于 passkey(passkey 设备绑定,陌生设备上没有),故 passkey 由 provider 承担、不进 cdrop 自建范围。
|
- passkey 作为“快速登录”的补充:陌生设备场景下扫码优于 passkey(passkey 设备绑定,陌生设备上没有),故 passkey 由 provider 承担、不进 cdrop 自建范围。
|
||||||
|
|
||||||
@@ -268,7 +277,7 @@ verify(token):
|
|||||||
## 11. 安全要点
|
## 11. 安全要点
|
||||||
|
|
||||||
- **`id_token` 必须 exchange 时验签**——自签 access token 后,原“每请求 JWKS 兜底”消失,OIDC 信任收敛到这唯一一刻。
|
- **`id_token` 必须 exchange 时验签**——自签 access token 后,原“每请求 JWKS 兜底”消失,OIDC 信任收敛到这唯一一刻。
|
||||||
- **自签 access token 短 TTL**(默认 900s),承载吊销时延上界;长效凭证只在 `web_sessions` 行,可吊销。
|
- **吊销即时**:`verifySelfToken` 每请求按 `sid` 查 `web_sessions` 行,删行即当场拒绝,被吊销设备下一次请求即失效(不依赖 TTL);前端据 401 + 续期失败回退登录页(§3.1)。自签 access token 短 TTL(默认 900s)只是续期节律,长效凭证只在 `web_sessions` 行。
|
||||||
- **扫码三密钥分离**:QR 仅含批准信息,会话只投私有 `poll_secret` 持有者;偷拍 QR 双重失败。
|
- **扫码三密钥分离**:QR 仅含批准信息,会话只投私有 `poll_secret` 持有者;偷拍 QR 双重失败。
|
||||||
- **访客会话能力门**:`scope=guest` 服务端强制受限(§3.2),不依赖前端自律。
|
- **访客会话能力门**:`scope=guest` 服务端强制受限(§3.2),不依赖前端自律。
|
||||||
- **迁移标记受控**:跨源 `match_key` 关联仅在管理员显式开启时生效,非日常自动 link,规避 email 接管。
|
- **迁移标记受控**:跨源 `match_key` 关联仅在管理员显式开启时生效,非日常自动 link,规避 email 接管。
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ func Bootstrap(ctx context.Context, d *sql.DB) error {
|
|||||||
"ALTER TABLE web_sessions ADD COLUMN granted_by TEXT NOT NULL DEFAULT ''"); err != nil {
|
"ALTER TABLE web_sessions ADD COLUMN granted_by TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||||
return fmt.Errorf("migrate web_sessions.granted_by: %w", err)
|
return fmt.Errorf("migrate web_sessions.granted_by: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := ensureColumn(ctx, tx, "web_sessions", "stepped_up_at",
|
||||||
|
"ALTER TABLE web_sessions ADD COLUMN stepped_up_at INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||||
|
return fmt.Errorf("migrate web_sessions.stepped_up_at: %w", err)
|
||||||
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return fmt.Errorf("commit bootstrap: %w", err)
|
return fmt.Errorf("commit bootstrap: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,29 @@ func (q *Queries) DeleteDevice(ctx context.Context, arg DeleteDeviceParams) (int
|
|||||||
return result.RowsAffected()
|
return result.RowsAffected()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteOrphanBrowserDevices = `-- name: DeleteOrphanBrowserDevices :execrows
|
||||||
|
DELETE FROM devices
|
||||||
|
WHERE type = 'browser'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM web_sessions ws
|
||||||
|
WHERE ws.user_id = devices.user_id
|
||||||
|
AND ws.device_name = devices.name
|
||||||
|
AND ws.expires_at > ?
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
// DeleteOrphanBrowserDevices removes browser device rows with no live web_session
|
||||||
|
// (the session was revoked or expired), keeping the device list aligned with the
|
||||||
|
// session list. The ? is the current epoch second. Native (macos/windows/linux/ios)
|
||||||
|
// and shortcut devices are left alone: they legitimately keep no web_session.
|
||||||
|
func (q *Queries) DeleteOrphanBrowserDevices(ctx context.Context, expiresAt int64) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, deleteOrphanBrowserDevices, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
const deleteStaleDevices = `-- name: DeleteStaleDevices :exec
|
const deleteStaleDevices = `-- name: DeleteStaleDevices :exec
|
||||||
DELETE FROM devices
|
DELETE FROM devices
|
||||||
WHERE last_seen < ?
|
WHERE last_seen < ?
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ CREATE TABLE IF NOT EXISTS web_sessions (
|
|||||||
-- 但不能改账号、不能再批准别的设备、不能签发长效 token(路由层 requireFullSession 守门)。
|
-- 但不能改账号、不能再批准别的设备、不能签发长效 token(路由层 requireFullSession 守门)。
|
||||||
scope TEXT NOT NULL DEFAULT 'full',
|
scope TEXT NOT NULL DEFAULT 'full',
|
||||||
-- granted_by=扫码批准者的 session id,供审计与「吊销我批准过的借用设备」连带吊销。
|
-- granted_by=扫码批准者的 session id,供审计与「吊销我批准过的借用设备」连带吊销。
|
||||||
granted_by TEXT NOT NULL DEFAULT ''
|
granted_by TEXT NOT NULL DEFAULT '',
|
||||||
|
-- stepped_up_at=本会话最近一次通过 step-up 再认证的 Unix 秒(AUTH.md §6)。在
|
||||||
|
-- StepUpMaxAgeSeconds 窗口内,敏感动作不再重复要求再认证(避免每次都弹再登录)。
|
||||||
|
stepped_up_at INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions (expires_at);
|
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions (expires_at);
|
||||||
|
|||||||
@@ -101,4 +101,5 @@ type WebSession struct {
|
|||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
GrantedBy string `json:"granted_by"`
|
GrantedBy string `json:"granted_by"`
|
||||||
|
SteppedUpAt int64 `json:"stepped_up_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ ORDER BY name;
|
|||||||
DELETE FROM devices
|
DELETE FROM devices
|
||||||
WHERE last_seen < ?;
|
WHERE last_seen < ?;
|
||||||
|
|
||||||
|
-- DeleteOrphanBrowserDevices removes browser device rows with no live web_session
|
||||||
|
-- (the session was revoked or expired), keeping the device list aligned with the
|
||||||
|
-- session list. The ? is the current epoch second. Native (macos/windows/linux/ios)
|
||||||
|
-- and shortcut devices are left alone: they legitimately keep no web_session.
|
||||||
|
-- name: DeleteOrphanBrowserDevices :execrows
|
||||||
|
DELETE FROM devices
|
||||||
|
WHERE type = 'browser'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM web_sessions ws
|
||||||
|
WHERE ws.user_id = devices.user_id
|
||||||
|
AND ws.device_name = devices.name
|
||||||
|
AND ws.expires_at > ?
|
||||||
|
);
|
||||||
|
|
||||||
-- name: DeleteDevice :execrows
|
-- name: DeleteDevice :execrows
|
||||||
DELETE FROM devices
|
DELETE FROM devices
|
||||||
WHERE user_id = ? AND name = ?;
|
WHERE user_id = ? AND name = ?;
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, c
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
|
||||||
|
|
||||||
-- name: GetWebSession :one
|
-- name: GetWebSession :one
|
||||||
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by
|
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by, stepped_up_at
|
||||||
FROM web_sessions
|
FROM web_sessions
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- SetSessionSteppedUp stamps the moment this session last passed step-up re-auth;
|
||||||
|
-- sensitive actions within the freshness window then skip re-auth (AUTH.md 6).
|
||||||
|
-- name: SetSessionSteppedUp :exec
|
||||||
|
UPDATE web_sessions
|
||||||
|
SET stepped_up_at = ?
|
||||||
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: RotateWebSession :exec
|
-- name: RotateWebSession :exec
|
||||||
UPDATE web_sessions
|
UPDATE web_sessions
|
||||||
SET refresh_token = ?, last_used_at = ?, expires_at = ?
|
SET refresh_token = ?, last_used_at = ?, expires_at = ?
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ func (q *Queries) DeleteWebSessionForUser(ctx context.Context, arg DeleteWebSess
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getWebSession = `-- name: GetWebSession :one
|
const getWebSession = `-- name: GetWebSession :one
|
||||||
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by
|
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by, stepped_up_at
|
||||||
FROM web_sessions
|
FROM web_sessions
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
@@ -146,6 +146,7 @@ func (q *Queries) GetWebSession(ctx context.Context, id string) (WebSession, err
|
|||||||
&i.Kind,
|
&i.Kind,
|
||||||
&i.Scope,
|
&i.Scope,
|
||||||
&i.GrantedBy,
|
&i.GrantedBy,
|
||||||
|
&i.SteppedUpAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -230,6 +231,24 @@ func (q *Queries) RotateWebSession(ctx context.Context, arg RotateWebSessionPara
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setSessionSteppedUp = `-- name: SetSessionSteppedUp :exec
|
||||||
|
UPDATE web_sessions
|
||||||
|
SET stepped_up_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetSessionSteppedUpParams struct {
|
||||||
|
SteppedUpAt int64 `json:"stepped_up_at"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSessionSteppedUp stamps the moment this session last passed step-up re-auth;
|
||||||
|
// sensitive actions within the freshness window then skip re-auth (AUTH.md 6).
|
||||||
|
func (q *Queries) SetSessionSteppedUp(ctx context.Context, arg SetSessionSteppedUpParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, setSessionSteppedUp, arg.SteppedUpAt, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const setWebSessionDevice = `-- name: SetWebSessionDevice :exec
|
const setWebSessionDevice = `-- name: SetWebSessionDevice :exec
|
||||||
UPDATE web_sessions
|
UPDATE web_sessions
|
||||||
SET device_name = ?
|
SET device_name = ?
|
||||||
|
|||||||
+63
-21
@@ -19,7 +19,9 @@ import (
|
|||||||
|
|
||||||
// PROJECT_BRIEF.md §2 keeps the browser path on standard OIDC PKCE. Backend
|
// PROJECT_BRIEF.md §2 keeps the browser path on standard OIDC PKCE. Backend
|
||||||
// proxies the token endpoint so the browser doesn't need a CORS-friendly OIDC
|
// proxies the token endpoint so the browser doesn't need a CORS-friendly OIDC
|
||||||
// provider; the access_token still flows back to the browser unchanged.
|
// provider. The browser is then handed a cdrop self-signed access token (sid-bound
|
||||||
|
// to its web_sessions row), not the IdP's RS256 token, so OIDC web sessions verify
|
||||||
|
// statefully and revoke immediately — unified with scan-login (AUTH.md §5).
|
||||||
|
|
||||||
type authConfigResp struct {
|
type authConfigResp struct {
|
||||||
AuthMode string `json:"auth_mode"`
|
AuthMode string `json:"auth_mode"`
|
||||||
@@ -120,33 +122,58 @@ func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Refresh the thin accounts row (display name / avatar / roles / match_key).
|
// Refresh the thin accounts row (display name / avatar / roles / match_key).
|
||||||
s.upsertAccount(r.Context(), identity)
|
s.upsertAccount(r.Context(), identity)
|
||||||
|
|
||||||
// Stash the refresh_token server-side and hand the browser an opaque,
|
// By default the browser holds a cdrop self-signed access token (sid-bound),
|
||||||
// HttpOnly cookie instead. Guarded so a deploy without the encryption key (or
|
// not the IdP's RS256 token — so the OIDC web session is verified statefully on
|
||||||
// an IdP that returned no refresh_token) still logs the user in — they just
|
// every request (verifySelfToken → web_sessions lookup) and revoking the row
|
||||||
// don't get passwordless re-login and re-auth on the next access-token expiry.
|
// logs this device out on its very next call, exactly like scan-login. We mint
|
||||||
|
// it only after stashing the refresh_token server-side (encrypted, behind an
|
||||||
|
// HttpOnly cookie) so the session is durable. The IdP RS256 token stays the
|
||||||
|
// fallback for degraded configs (no encryption key / no refresh_token / an
|
||||||
|
// id_token that fails verification) — it is still independently verified per
|
||||||
|
// request via JWKS (verifyRS256), and the next refresh upgrades the device to
|
||||||
|
// the unified self-token model. (AUTH.md §5; desktop keeps its RS256 token.)
|
||||||
|
accessToken, expiresIn := tr.AccessToken, tr.ExpiresIn
|
||||||
if len(s.sessionKey) > 0 && tr.RefreshToken != "" {
|
if len(s.sessionKey) > 0 && tr.RefreshToken != "" {
|
||||||
if err := s.createWebSession(r, w, user.ID, tr.RefreshToken); err != nil {
|
id, cerr := s.createWebSession(r, w, user.ID, tr.RefreshToken)
|
||||||
slog.Error("create web session failed", "err", err)
|
if cerr != nil {
|
||||||
|
slog.Error("create web session failed", "err", cerr)
|
||||||
|
} else {
|
||||||
|
// The id_token is the SOLE trust anchor for the minted self token (the
|
||||||
|
// IdP RS256 token no longer reaches the browser to be re-verified), so
|
||||||
|
// its signature MUST validate here and its subject must match. On
|
||||||
|
// failure we keep the durable session but hand back the per-request-
|
||||||
|
// verified RS256 token; the next refresh upgrades this device to the
|
||||||
|
// self-token model. (Should not happen with a healthy IdP — warn ops.)
|
||||||
|
sub, _, verr := s.auth.VerifyIDToken(r.Context(), tr.IDToken)
|
||||||
|
if verr != nil || sub != user.ID {
|
||||||
|
slog.Warn("oidc id_token verification failed; using RS256 fallback",
|
||||||
|
"err", verr, "sub_match", sub == user.ID)
|
||||||
|
} else if tok, ein, merr := s.mintSessionToken(user.ID, id, "full"); merr != nil {
|
||||||
|
slog.Error("mint session token failed", "err", merr)
|
||||||
|
} else {
|
||||||
|
accessToken, expiresIn = tok, ein
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, exchangeResp{
|
writeJSON(w, http.StatusOK, exchangeResp{
|
||||||
AccessToken: tr.AccessToken,
|
AccessToken: accessToken,
|
||||||
ExpiresIn: tr.ExpiresIn,
|
ExpiresIn: expiresIn,
|
||||||
User: user,
|
User: user,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// createWebSession encrypts the refresh_token, persists a new session row, and
|
// createWebSession encrypts the refresh_token, persists a new session row, and
|
||||||
// sets the session cookie on the response.
|
// sets the session cookie on the response. Returns the session row id so the
|
||||||
func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID, refreshToken string) error {
|
// caller can bind a cdrop self-signed access token to it (sid).
|
||||||
|
func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID, refreshToken string) (string, error) {
|
||||||
raw, id, err := newSessionToken()
|
raw, id, err := newSessionToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
enc, err := s.encryptRefresh(refreshToken)
|
enc, err := s.encryptRefresh(refreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if err := s.queries.CreateWebSession(r.Context(), db.CreateWebSessionParams{
|
if err := s.queries.CreateWebSession(r.Context(), db.CreateWebSessionParams{
|
||||||
@@ -159,10 +186,10 @@ func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID
|
|||||||
LastUsedAt: now.Unix(),
|
LastUsedAt: now.Unix(),
|
||||||
ExpiresAt: now.Add(webSessionTTL).Unix(),
|
ExpiresAt: now.Add(webSessionTTL).Unix(),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
setSessionCookie(w, raw)
|
setSessionCookie(w, raw)
|
||||||
return nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshResp is what the browser sees: a fresh access_token plus the identity
|
// refreshResp is what the browser sees: a fresh access_token plus the identity
|
||||||
@@ -282,9 +309,23 @@ func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
setSessionCookie(w, c.Value)
|
setSessionCookie(w, c.Value)
|
||||||
|
|
||||||
|
// Hand back a cdrop self-signed access token (sid-bound), not the IdP RS256
|
||||||
|
// token: the browser holds one uniform token type and revoking the session row
|
||||||
|
// logs this device out on its next request. The IdP round-trip above still ran
|
||||||
|
// — it rotated the refresh_token and surfaced IdP-side revocation (a rejected
|
||||||
|
// refresh deletes the session above) — its access_token simply no longer ships.
|
||||||
|
// Mint from the row's canonical user_id; degrade to the IdP token only if the
|
||||||
|
// HS256 key is unconfigured. (AUTH.md §5.)
|
||||||
|
accessToken, expiresIn := tr.AccessToken, tr.ExpiresIn
|
||||||
|
if tok, ein, mErr := s.mintSessionToken(sess.UserID, id, "full"); mErr != nil {
|
||||||
|
slog.Error("mint session token failed", "err", mErr)
|
||||||
|
} else {
|
||||||
|
accessToken, expiresIn = tok, ein
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, refreshResp{
|
writeJSON(w, http.StatusOK, refreshResp{
|
||||||
AccessToken: tr.AccessToken,
|
AccessToken: accessToken,
|
||||||
ExpiresIn: tr.ExpiresIn,
|
ExpiresIn: expiresIn,
|
||||||
User: user,
|
User: user,
|
||||||
DeviceName: sess.DeviceName,
|
DeviceName: sess.DeviceName,
|
||||||
})
|
})
|
||||||
@@ -369,10 +410,11 @@ type tokenIdentity struct {
|
|||||||
|
|
||||||
// extractIdentity parses {sub, preferred_username | name | email, avatar | picture,
|
// extractIdentity parses {sub, preferred_username | name | email, avatar | picture,
|
||||||
// matchClaim, groups} from the id_token if available, else the access_token. The
|
// matchClaim, groups} from the id_token if available, else the access_token. The
|
||||||
// signature is NOT verified here — for OIDC sessions the auth middleware verifies
|
// signature is NOT verified here — callers verify separately around it: at login
|
||||||
// the access_token on every API call via JWKS, so any tamper surfaces there. (Once
|
// (handleAuthExchange) the id_token signature is checked via VerifyIDToken before
|
||||||
// OIDC sessions migrate to cdrop self-signed access tokens, this becomes the only
|
// the extracted subject is trusted to mint a self token; at refresh the IdP just
|
||||||
// trust point and MUST then verify the id_token signature — AUTH.md §5.)
|
// authenticated the refresh_token over a TLS back-channel, so the token it returns
|
||||||
|
// is trusted by transport. Either way the extracted fields are sound. (AUTH.md §5.)
|
||||||
func extractIdentity(idToken, accessToken, matchClaim string) (tokenIdentity, error) {
|
func extractIdentity(idToken, accessToken, matchClaim string) (tokenIdentity, error) {
|
||||||
pick := idToken
|
pick := idToken
|
||||||
if pick == "" {
|
if pick == "" {
|
||||||
|
|||||||
@@ -44,7 +44,15 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
|
|||||||
// handleDeleteDevice 注销设备:删除 (user, name) 行 + Kick 在线 SSE + 立刻广播
|
// handleDeleteDevice 注销设备:删除 (user, name) 行 + Kick 在线 SSE + 立刻广播
|
||||||
// 新的 presence。请求方若注销的是自己当前设备,前端需同步调用 logout,否则
|
// 新的 presence。请求方若注销的是自己当前设备,前端需同步调用 logout,否则
|
||||||
// 任何后续带 X-Device-Name 的认证请求都会被中间件再次 UPSERT 回来。
|
// 任何后续带 X-Device-Name 的认证请求都会被中间件再次 UPSERT 回来。
|
||||||
|
//
|
||||||
|
// 这是登录会话列表里「原生客户端(桌面 / iOS)」一栏的登出路径——它们用 IdP 令牌、
|
||||||
|
// 不入 web_sessions,故按设备名吊销(AUTH.md §4)。注销设备属敏感动作,与吊销网页
|
||||||
|
// 会话同口径要求最近一次 step-up(403 step_up_required,前端据此发起再认证)。
|
||||||
func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) {
|
||||||
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
claims, _ := jwtauth.ClaimsFromContext(r.Context())
|
claims, _ := jwtauth.ClaimsFromContext(r.Context())
|
||||||
name := chi.URLParam(r, "name")
|
name := chi.URLParam(r, "name")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
|
|||||||
+67
-9
@@ -285,7 +285,9 @@ func (s *Server) handleQRRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
RequestIP: req.RequestIp,
|
RequestIP: req.RequestIp,
|
||||||
ExpiresAt: req.ExpiresAt,
|
ExpiresAt: req.ExpiresAt,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
StepUp: s.cfg.StepUpEnabled,
|
// Tell the approver UI to re-auth only if step-up is on AND this session
|
||||||
|
// hasn't recently stepped up (within the window) — no repeated prompts (#3).
|
||||||
|
StepUp: s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,10 +311,14 @@ func (s *Server) handleQRApprove(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Step-up: approving a new device into your account is sensitive. When enabled,
|
// Step-up: approving a new device into your account is sensitive. When enabled,
|
||||||
// require a fresh prompt=login re-auth (the provider's 2FA, if configured, is
|
// require a fresh prompt=login re-auth (the provider's 2FA, if configured, is
|
||||||
// enforced during that exchange) within the freshness window (AUTH.md §6).
|
// enforced during that exchange) — UNLESS this session already stepped up within
|
||||||
if s.cfg.StepUpEnabled && !s.verifyStepUp(r, claims.UserID, body.StepUpCode, body.StepUpVerifier) {
|
// the freshness window, so the same session isn't re-prompted repeatedly (#3).
|
||||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
|
if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) {
|
||||||
return
|
if !s.verifyStepUp(r, claims.UserID, body.StepUpCode, body.StepUpVerifier) {
|
||||||
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recordStepUp(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
scope := "guest" // this milestone grants restricted guest sessions only
|
scope := "guest" // this milestone grants restricted guest sessions only
|
||||||
@@ -367,6 +373,50 @@ func (s *Server) handleQRDeny(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sessionFromCookie resolves the caller's web_session row from the cdrop_session
|
||||||
|
// cookie. The scan-approval endpoints live under /api/auth/*, which the cookie's
|
||||||
|
// Path covers, so both OIDC and self sessions can be located here.
|
||||||
|
func (s *Server) sessionFromCookie(r *http.Request) (db.WebSession, bool) {
|
||||||
|
c, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
return db.WebSession{}, false
|
||||||
|
}
|
||||||
|
sess, err := s.queries.GetWebSession(r.Context(), sessionID(c.Value))
|
||||||
|
if err != nil {
|
||||||
|
return db.WebSession{}, false
|
||||||
|
}
|
||||||
|
return sess, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionRecentlySteppedUp reports whether the caller's session passed step-up
|
||||||
|
// within StepUpMaxAgeSeconds — so a sensitive action skips re-auth and the same
|
||||||
|
// session isn't re-prompted repeatedly (#3, AUTH.md §6).
|
||||||
|
func (s *Server) sessionRecentlySteppedUp(r *http.Request) bool {
|
||||||
|
sess, ok := s.sessionFromCookie(r)
|
||||||
|
if !ok || sess.SteppedUpAt == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
window := int64(s.cfg.StepUpMaxAgeSeconds)
|
||||||
|
if window <= 0 {
|
||||||
|
window = 300
|
||||||
|
}
|
||||||
|
return time.Now().Unix()-sess.SteppedUpAt <= window
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordStepUp stamps the caller's session as freshly stepped-up.
|
||||||
|
func (s *Server) recordStepUp(r *http.Request) {
|
||||||
|
sess, ok := s.sessionFromCookie(r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.queries.SetSessionSteppedUp(r.Context(), db.SetSessionSteppedUpParams{
|
||||||
|
SteppedUpAt: time.Now().Unix(),
|
||||||
|
ID: sess.ID,
|
||||||
|
}); err != nil {
|
||||||
|
slog.Warn("record step-up failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// verifyStepUp exchanges a fresh prompt=login authorization code and confirms it
|
// verifyStepUp exchanges a fresh prompt=login authorization code and confirms it
|
||||||
// proves a recent interactive re-authentication by the same user: the id_token's
|
// proves a recent interactive re-authentication by the same user: the id_token's
|
||||||
// signature validates (JWKS), its sub matches the caller, and its auth_time is
|
// signature validates (JWKS), its sub matches the caller, and its auth_time is
|
||||||
@@ -401,11 +451,19 @@ func (s *Server) verifyStepUp(r *http.Request, userID, code, verifier string) bo
|
|||||||
if sub != userID {
|
if sub != userID {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
maxAge := int64(s.cfg.StepUpMaxAgeSeconds)
|
// Enforce the freshness window only when the IdP supplied auth_time (Casdoor
|
||||||
if maxAge <= 0 {
|
// omits it). When absent, the fresh single-use prompt=login code — exchanged
|
||||||
maxAge = 300
|
// once, just now — is itself the bound on how recent the re-auth was.
|
||||||
|
if authTime > 0 {
|
||||||
|
maxAge := int64(s.cfg.StepUpMaxAgeSeconds)
|
||||||
|
if maxAge <= 0 {
|
||||||
|
maxAge = 300
|
||||||
|
}
|
||||||
|
if time.Now().Unix()-authTime > maxAge {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return time.Now().Unix()-authTime <= maxAge
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookupQRRequest fetches a request and verifies the approval_code in constant
|
// lookupQRRequest fetches a request and verifies the approval_code in constant
|
||||||
|
|||||||
+294
-1
@@ -12,11 +12,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-jose/go-jose/v4"
|
"github.com/go-jose/go-jose/v4"
|
||||||
"github.com/go-jose/go-jose/v4/jwt"
|
"github.com/go-jose/go-jose/v4/jwt"
|
||||||
|
|
||||||
"commilitia.net/cdrop/internal/config"
|
"commilitia.net/cdrop/internal/config"
|
||||||
"commilitia.net/cdrop/internal/db"
|
"commilitia.net/cdrop/internal/db"
|
||||||
|
"commilitia.net/cdrop/internal/hub"
|
||||||
"commilitia.net/cdrop/internal/jwtauth"
|
"commilitia.net/cdrop/internal/jwtauth"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ func newQRTestServer(t *testing.T) *Server {
|
|||||||
if err := db.Bootstrap(context.Background(), conn); err != nil {
|
if err := db.Bootstrap(context.Background(), conn); err != nil {
|
||||||
t.Fatalf("bootstrap: %v", err)
|
t.Fatalf("bootstrap: %v", err)
|
||||||
}
|
}
|
||||||
|
q := db.New(conn)
|
||||||
return &Server{
|
return &Server{
|
||||||
cfg: &config.Config{
|
cfg: &config.Config{
|
||||||
AuthMode: "prod", SessionSecret: qrTestSecret,
|
AuthMode: "prod", SessionSecret: qrTestSecret,
|
||||||
@@ -42,7 +45,8 @@ func newQRTestServer(t *testing.T) *Server {
|
|||||||
QRGuestTTLSeconds: 3600, QRPersistTTLHours: 168,
|
QRGuestTTLSeconds: 3600, QRPersistTTLHours: 168,
|
||||||
SessionTokenTTLSeconds: 900,
|
SessionTokenTTLSeconds: 900,
|
||||||
},
|
},
|
||||||
queries: db.New(conn),
|
queries: q,
|
||||||
|
hub: hub.New(q),
|
||||||
sessionKey: deriveSessionKey(qrTestSecret),
|
sessionKey: deriveSessionKey(qrTestSecret),
|
||||||
sessionTokenKey: jwtauth.DeriveSessionTokenKey(qrTestSecret),
|
sessionTokenKey: jwtauth.DeriveSessionTokenKey(qrTestSecret),
|
||||||
siteOrigin: "https://drop.example.net",
|
siteOrigin: "https://drop.example.net",
|
||||||
@@ -222,6 +226,106 @@ func TestQRFlow_PendingLongPoll(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noopAuthStore satisfies jwtauth.Store for middleware wiring in tests (no device
|
||||||
|
// upsert happens without an X-Device-Name header).
|
||||||
|
type noopAuthStore struct{}
|
||||||
|
|
||||||
|
func (noopAuthStore) UpsertDevice(context.Context, db.UpsertDeviceParams) error { return nil }
|
||||||
|
func (noopAuthStore) GetShortcutToken(context.Context, string) (db.ShortcutToken, error) {
|
||||||
|
return db.ShortcutToken{}, fmt.Errorf("none")
|
||||||
|
}
|
||||||
|
func (noopAuthStore) TouchShortcutTokenUsed(context.Context, db.TouchShortcutTokenUsedParams) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (noopAuthStore) GetWebSession(_ context.Context, id string) (db.WebSession, error) {
|
||||||
|
return db.WebSession{ID: id}, nil // session always live in these tests
|
||||||
|
}
|
||||||
|
|
||||||
|
// End-to-end through the REAL chain (auth.Middleware → requireFullSession): a
|
||||||
|
// guest session token is rejected 403 on an account-management route, a full one
|
||||||
|
// passes. This is the backend enforcement behind AUTH.md §3.2 (guest can't approve
|
||||||
|
// devices / remove devices / mint tokens).
|
||||||
|
func TestRequireFullSession_GuestBlockedFullPasses(t *testing.T) {
|
||||||
|
secret := "test-session-secret-at-least-32-bytes-ok"
|
||||||
|
srv := &Server{
|
||||||
|
cfg: &config.Config{SessionTokenTTLSeconds: 900},
|
||||||
|
sessionTokenKey: jwtauth.DeriveSessionTokenKey(secret),
|
||||||
|
}
|
||||||
|
guest, _, err := srv.mintSessionToken("u", "sid", "guest")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mint guest: %v", err)
|
||||||
|
}
|
||||||
|
full, _, err := srv.mintSessionToken("u", "sid", "full")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mint full: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a := jwtauth.New(&config.Config{AuthMode: "prod", SessionSecret: secret}, noopAuthStore{})
|
||||||
|
handler := a.Middleware(requireFullSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})))
|
||||||
|
probe := func(tok string) int {
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/approve", nil)
|
||||||
|
r.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
if c := probe(guest); c != http.StatusForbidden {
|
||||||
|
t.Errorf("guest token on requireFullSession route: got %d, want 403", c)
|
||||||
|
}
|
||||||
|
if c := probe(full); c != http.StatusOK {
|
||||||
|
t.Errorf("full token on requireFullSession route: got %d, want 200", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoking a session is sensitive: with step-up enabled it is refused (403
|
||||||
|
// step_up_required) until the caller's session has recently stepped up, then it
|
||||||
|
// proceeds (here to 400 missing-id, i.e. past the gate). Step-up state is keyed on
|
||||||
|
// the cookie session, so it is per-device (AUTH.md §1/§6).
|
||||||
|
func TestSessionRevoke_RequiresStepUp(t *testing.T) {
|
||||||
|
s := newQRTestServer(t)
|
||||||
|
s.cfg.StepUpEnabled = true
|
||||||
|
s.cfg.StepUpMaxAgeSeconds = 300
|
||||||
|
|
||||||
|
raw, id, err := newSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token: %v", err)
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if err := s.queries.CreateSelfSession(context.Background(), db.CreateSelfSessionParams{
|
||||||
|
ID: id, UserID: "u", DeviceName: "", UserAgent: "", Kind: "oidc", Scope: "full",
|
||||||
|
GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create session: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
revoke := func() int {
|
||||||
|
r := httptest.NewRequest(http.MethodDelete, "/api/auth/sessions/x", nil)
|
||||||
|
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: raw})
|
||||||
|
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(),
|
||||||
|
&jwtauth.Claims{UserID: "u", SessionScope: "full"}))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleSessionRevoke(w, r)
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not stepped up → blocked before any deletion.
|
||||||
|
if c := revoke(); c != http.StatusForbidden {
|
||||||
|
t.Fatalf("revoke without step-up: got %d, want 403", c)
|
||||||
|
}
|
||||||
|
// Record step-up on this (cookie) session → gate now passes (400 = missing id,
|
||||||
|
// reached past the step-up check).
|
||||||
|
if err := s.queries.SetSessionSteppedUp(context.Background(), db.SetSessionSteppedUpParams{
|
||||||
|
SteppedUpAt: now, ID: id,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("set stepped up: %v", err)
|
||||||
|
}
|
||||||
|
if c := revoke(); c == http.StatusForbidden {
|
||||||
|
t.Errorf("revoke after step-up still 403; gate did not honour stepped_up_at")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// selfTokenScope verifies a self-signed session token under the test's session
|
// selfTokenScope verifies a self-signed session token under the test's session
|
||||||
// key (proving the signature) and returns its scope claim.
|
// key (proving the signature) and returns its scope claim.
|
||||||
func selfTokenScope(t *testing.T, token string) string {
|
func selfTokenScope(t *testing.T, token string) string {
|
||||||
@@ -241,3 +345,192 @@ func selfTokenScope(t *testing.T, token string) string {
|
|||||||
scope, _ := custom["scope"].(string)
|
scope, _ := custom["scope"].(string)
|
||||||
return scope
|
return scope
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// revokeByID drives handleSessionRevoke with the chi {id} param and full claims,
|
||||||
|
// step-up off (the gate is exercised separately).
|
||||||
|
func revokeByID(s *Server, id, userID string) *httptest.ResponseRecorder {
|
||||||
|
r := httptest.NewRequest(http.MethodDelete, "/api/auth/sessions/"+id, nil)
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add("id", id)
|
||||||
|
ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx)
|
||||||
|
ctx = jwtauth.ContextWithClaims(ctx, &jwtauth.Claims{UserID: userID, SessionScope: "full"})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleSessionRevoke(w, r.WithContext(ctx))
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoking a web session removes its device registration too, so a revoked device
|
||||||
|
// disappears from the device list at once (the user can no longer remove devices
|
||||||
|
// by hand). AUTH.md §4.
|
||||||
|
func TestSessionRevoke_DeletesDevice(t *testing.T) {
|
||||||
|
s := newQRTestServer(t) // step-up off by default
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
_, id, err := newSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{
|
||||||
|
ID: id, UserID: "u", DeviceName: "Laptop", UserAgent: "", Kind: "oidc", Scope: "full",
|
||||||
|
GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create session: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{
|
||||||
|
UserID: "u", Name: "Laptop", Type: "browser", LastSeen: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("upsert device: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if w := revokeByID(s, id, "u"); w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("revoke: got %d %s, want 204", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := s.queries.GetWebSession(ctx, id); err == nil {
|
||||||
|
t.Errorf("session still present after revoke")
|
||||||
|
}
|
||||||
|
devs, _ := s.queries.ListDevicesByUser(ctx, "u")
|
||||||
|
for _, d := range devs {
|
||||||
|
if d.Name == "Laptop" {
|
||||||
|
t.Errorf("device 'Laptop' not deleted on session revoke")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session list unifies web_sessions with native client devices (desktop / iOS)
|
||||||
|
// that have no web_session, while never double-listing a device that already has a
|
||||||
|
// session and excluding shortcut-token devices. AUTH.md §4.
|
||||||
|
func TestSessionsList_IncludesNativeDevices(t *testing.T) {
|
||||||
|
s := newQRTestServer(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
_, id, err := newSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{
|
||||||
|
ID: id, UserID: "u", DeviceName: "Web", UserAgent: "", Kind: "oidc", Scope: "full",
|
||||||
|
GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create session: %v", err)
|
||||||
|
}
|
||||||
|
for _, d := range []struct{ name, typ string }{
|
||||||
|
{"Web", "browser"}, // has a web_session — must not be double-listed
|
||||||
|
{"Desk", "macos"}, // native, no session — must appear as native
|
||||||
|
{"iShortcut", "shortcut"}, // scoped token — excluded from session list
|
||||||
|
} {
|
||||||
|
if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{
|
||||||
|
UserID: "u", Name: d.name, Type: d.typ, LastSeen: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("upsert device %s: %v", d.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/auth/sessions", nil)
|
||||||
|
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(),
|
||||||
|
&jwtauth.Claims{UserID: "u", SessionScope: "full"}))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleSessionsList(w, r)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("sessions list: got %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Sessions []sessionView `json:"sessions"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
var web, webNativeDup, desk, shortcut bool
|
||||||
|
for _, v := range resp.Sessions {
|
||||||
|
switch {
|
||||||
|
case v.DeviceName == "Web" && !v.Native && v.Kind == "oidc":
|
||||||
|
web = true
|
||||||
|
case v.DeviceName == "Web" && v.Native:
|
||||||
|
webNativeDup = true
|
||||||
|
case v.DeviceName == "Desk" && v.Native && v.Kind == "macos":
|
||||||
|
desk = true
|
||||||
|
case v.DeviceName == "iShortcut":
|
||||||
|
shortcut = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !web {
|
||||||
|
t.Errorf("web session for 'Web' missing")
|
||||||
|
}
|
||||||
|
if webNativeDup {
|
||||||
|
t.Errorf("'Web' double-listed as a native device")
|
||||||
|
}
|
||||||
|
if !desk {
|
||||||
|
t.Errorf("native device 'Desk' missing from session list")
|
||||||
|
}
|
||||||
|
if shortcut {
|
||||||
|
t.Errorf("shortcut device leaked into session list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sweeper drops browser devices whose web_session is gone (orphans) while
|
||||||
|
// keeping browser devices that still have a live session and all native devices.
|
||||||
|
func TestDeleteOrphanBrowserDevices(t *testing.T) {
|
||||||
|
s := newQRTestServer(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
// Browser "Kept" has a live session; browser "Orphan" has none; native "Desk"
|
||||||
|
// (macos) legitimately has no session and must survive.
|
||||||
|
_, id, err := newSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{
|
||||||
|
ID: id, UserID: "u", DeviceName: "Kept", Kind: "oidc", Scope: "full",
|
||||||
|
CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create session: %v", err)
|
||||||
|
}
|
||||||
|
for _, d := range []struct{ name, typ string }{
|
||||||
|
{"Kept", "browser"}, {"Orphan", "browser"}, {"Desk", "macos"},
|
||||||
|
} {
|
||||||
|
if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{
|
||||||
|
UserID: "u", Name: d.name, Type: d.typ, LastSeen: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("upsert %s: %v", d.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.queries.DeleteOrphanBrowserDevices(ctx, now); err != nil {
|
||||||
|
t.Fatalf("sweep: %v", err)
|
||||||
|
}
|
||||||
|
devs, _ := s.queries.ListDevicesByUser(ctx, "u")
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, d := range devs {
|
||||||
|
got[d.Name] = true
|
||||||
|
}
|
||||||
|
if !got["Kept"] {
|
||||||
|
t.Errorf("browser 'Kept' with a live session was wrongly swept")
|
||||||
|
}
|
||||||
|
if got["Orphan"] {
|
||||||
|
t.Errorf("orphan browser 'Orphan' was not swept")
|
||||||
|
}
|
||||||
|
if !got["Desk"] {
|
||||||
|
t.Errorf("native 'Desk' was wrongly swept (it keeps no web_session)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing a native device (the session list's logout path for desktop / iOS) is
|
||||||
|
// sensitive and requires a recent step-up, mirroring web-session revocation.
|
||||||
|
func TestDeleteDevice_RequiresStepUp(t *testing.T) {
|
||||||
|
s := newQRTestServer(t)
|
||||||
|
s.cfg.StepUpEnabled = true
|
||||||
|
s.cfg.StepUpMaxAgeSeconds = 300
|
||||||
|
|
||||||
|
r := httptest.NewRequest(http.MethodDelete, "/api/devices/Desk", nil)
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add("name", "Desk")
|
||||||
|
ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx)
|
||||||
|
ctx = jwtauth.ContextWithClaims(ctx, &jwtauth.Claims{UserID: "u", SessionScope: "full"})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleDeleteDevice(w, r.WithContext(ctx))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("delete device without step-up: got %d, want 403", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -176,6 +176,13 @@ func (s *Server) routes() {
|
|||||||
r.Get("/auth/qr/request", s.handleQRRequest)
|
r.Get("/auth/qr/request", s.handleQRRequest)
|
||||||
r.Post("/auth/qr/approve", s.handleQRApprove)
|
r.Post("/auth/qr/approve", s.handleQRApprove)
|
||||||
r.Post("/auth/qr/deny", s.handleQRDeny)
|
r.Post("/auth/qr/deny", s.handleQRDeny)
|
||||||
|
// Session management: list logins with their permission level,
|
||||||
|
// really revoke one (logout), and the standalone step-up the
|
||||||
|
// revoke flow re-uses. All under /api/auth so the session cookie
|
||||||
|
// (Path=/api/auth) is available for step-up state.
|
||||||
|
r.Post("/auth/stepup", s.handleStepUp)
|
||||||
|
r.Get("/auth/sessions", s.handleSessionsList)
|
||||||
|
r.Delete("/auth/sessions/{id}", s.handleSessionRevoke)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/transfer", func(r chi.Router) {
|
r.Route("/transfer", func(r chi.Router) {
|
||||||
@@ -219,6 +226,10 @@ type meResp struct {
|
|||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Groups []string `json:"groups"`
|
Groups []string `json:"groups"`
|
||||||
DeviceName string `json:"device_name"`
|
DeviceName string `json:"device_name"`
|
||||||
|
// Scope is the session's capability level: "guest" (scan-login borrow,
|
||||||
|
// capability-limited) or "full" (normal login). The UI hides account-management
|
||||||
|
// affordances on guest sessions (AUTH.md §3.2).
|
||||||
|
Scope string `json:"scope"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -228,10 +239,15 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
device, _ := jwtauth.DeviceNameFromContext(r.Context())
|
device, _ := jwtauth.DeviceNameFromContext(r.Context())
|
||||||
|
scope := "full"
|
||||||
|
if claims.Guest() {
|
||||||
|
scope = "guest"
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, meResp{
|
writeJSON(w, http.StatusOK, meResp{
|
||||||
UserID: claims.UserID,
|
UserID: claims.UserID,
|
||||||
Groups: claims.Groups,
|
Groups: claims.Groups,
|
||||||
DeviceName: device,
|
DeviceName: device,
|
||||||
|
Scope: scope,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"commilitia.net/cdrop/internal/db"
|
||||||
|
"commilitia.net/cdrop/internal/jwtauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session management (AUTH.md §1, §6): list the caller's logins with their
|
||||||
|
// capability level, and really revoke one (delete the row → its access token can
|
||||||
|
// no longer refresh and dies within its short TTL). Revocation is a sensitive
|
||||||
|
// action, so it requires a recent step-up. A standalone step-up endpoint lets the
|
||||||
|
// UI establish that re-auth once (per session/device) and reuse it across actions
|
||||||
|
// within the window — the same session isn't re-prompted repeatedly (#3).
|
||||||
|
|
||||||
|
type stepUpReq struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Verifier string `json:"verifier"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStepUp exchanges a fresh prompt=login code and, on success, stamps the
|
||||||
|
// caller's session as stepped-up (per-session, cookie-keyed). Sensitive actions
|
||||||
|
// within StepUpMaxAgeSeconds then skip re-auth. 403 on failure; 204 when step-up
|
||||||
|
// is disabled (nothing to establish).
|
||||||
|
func (s *Server) handleStepUp(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.cfg.StepUpEnabled {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, _ := jwtauth.ClaimsFromContext(r.Context())
|
||||||
|
var body stepUpReq
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.verifyStepUp(r, claims.UserID, body.Code, body.Verifier) {
|
||||||
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recordStepUp(r)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
Kind string `json:"kind"` // oidc | self | guest | macos | windows | linux | ios
|
||||||
|
Scope string `json:"scope"` // full | guest
|
||||||
|
Current bool `json:"current"`
|
||||||
|
Online bool `json:"online"` // device currently connected (SSE) — UI sorts online first
|
||||||
|
// Native marks a device that authenticates with an IdP token and keeps no
|
||||||
|
// web_session row (desktop / iOS). It is surfaced from the devices registry so
|
||||||
|
// the session list covers every device; its "logout" routes to the device
|
||||||
|
// endpoint rather than the web-session one.
|
||||||
|
Native bool `json:"native"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
LastUsedAt int64 `json:"last_used_at"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNativeDeviceType reports whether a device type is a real native client (one
|
||||||
|
// that authenticates via the IdP and keeps no web_session). browser devices are
|
||||||
|
// expected to carry a web_session; "shortcut" tokens have their own panel.
|
||||||
|
func isNativeDeviceType(t string) bool {
|
||||||
|
switch t {
|
||||||
|
case "macos", "windows", "linux", "ios":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSessionsList returns the caller's login sessions with their scope, so the
|
||||||
|
// UI can show the permission level (完整 / 受限访客) and offer real logout. The list
|
||||||
|
// is unified: web_sessions (browser / scan-login) PLUS native client devices
|
||||||
|
// (desktop / iOS) that have no web_session of their own — so every logged-in
|
||||||
|
// device appears in one place and orphaned device rows fall away (AUTH.md §4).
|
||||||
|
func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, _ := jwtauth.ClaimsFromContext(r.Context())
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
rows, err := s.queries.ListWebSessionsByUser(r.Context(), claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("list sessions failed", "err", err, "user", claims.UserID)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current := ""
|
||||||
|
if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" {
|
||||||
|
current = sessionID(c.Value)
|
||||||
|
}
|
||||||
|
// Cookieless callers (native apps) can't match by cookie; fall back to their
|
||||||
|
// own device name to mark the "current" row.
|
||||||
|
callerDevice, _ := jwtauth.DeviceNameFromContext(r.Context())
|
||||||
|
|
||||||
|
out := make([]sessionView, 0, len(rows))
|
||||||
|
named := make(map[string]struct{}, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.ExpiresAt < now {
|
||||||
|
continue // hide expired sessions (reaped separately)
|
||||||
|
}
|
||||||
|
if row.DeviceName != "" {
|
||||||
|
named[row.DeviceName] = struct{}{}
|
||||||
|
}
|
||||||
|
out = append(out, sessionView{
|
||||||
|
ID: row.ID,
|
||||||
|
DeviceName: row.DeviceName,
|
||||||
|
Kind: row.Kind,
|
||||||
|
Scope: row.Scope,
|
||||||
|
Current: row.ID == current || (current == "" && row.DeviceName != "" && row.DeviceName == callerDevice),
|
||||||
|
Online: row.DeviceName != "" && s.hub.Online(claims.UserID, row.DeviceName),
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
LastUsedAt: row.LastUsedAt,
|
||||||
|
ExpiresAt: row.ExpiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native clients keep no web_session — surface them from the devices registry,
|
||||||
|
// skipping any whose name already has a web_session (no double-listing).
|
||||||
|
if devs, derr := s.queries.ListDevicesByUser(r.Context(), claims.UserID); derr == nil {
|
||||||
|
for _, d := range devs {
|
||||||
|
if !isNativeDeviceType(d.Type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := named[d.Name]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, sessionView{
|
||||||
|
ID: "device:" + d.Name,
|
||||||
|
DeviceName: d.Name,
|
||||||
|
Kind: d.Type,
|
||||||
|
Scope: "full",
|
||||||
|
Native: true,
|
||||||
|
Current: current == "" && d.Name != "" && d.Name == callerDevice,
|
||||||
|
Online: s.hub.Online(claims.UserID, d.Name),
|
||||||
|
CreatedAt: d.LastSeen,
|
||||||
|
LastUsedAt: d.LastSeen,
|
||||||
|
ExpiresAt: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"sessions": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceNameHasLiveSession reports whether the user still has a non-expired
|
||||||
|
// web_session bound to deviceName — used to avoid deleting a device that another
|
||||||
|
// live session (e.g. a re-login that left the old row) still depends on.
|
||||||
|
func (s *Server) deviceNameHasLiveSession(ctx context.Context, userID, deviceName string) bool {
|
||||||
|
rows, err := s.queries.ListWebSessionsByUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for _, ws := range rows {
|
||||||
|
if ws.DeviceName == deviceName && ws.ExpiresAt >= now {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSessionRevoke deletes a session row (scoped to its owner), really
|
||||||
|
// invalidating that login. Requires a recent step-up (403 step_up_required
|
||||||
|
// otherwise). Kicks the device's live SSE so it drops immediately.
|
||||||
|
func (s *Server) handleSessionRevoke(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) {
|
||||||
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, _ := jwtauth.ClaimsFromContext(r.Context())
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if id == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Read the device name before deletion so we can kick its SSE.
|
||||||
|
deviceName := ""
|
||||||
|
if sess, err := s.queries.GetWebSession(r.Context(), id); err == nil && sess.UserID == claims.UserID {
|
||||||
|
deviceName = sess.DeviceName
|
||||||
|
}
|
||||||
|
n, err := s.queries.DeleteWebSessionForUser(r.Context(), db.DeleteWebSessionForUserParams{
|
||||||
|
ID: id,
|
||||||
|
UserID: claims.UserID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("revoke session failed", "err", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if deviceName != "" {
|
||||||
|
// Drop the device registration too, so a revoked device disappears from the
|
||||||
|
// device list at once (we no longer remove devices by hand) — unless another
|
||||||
|
// live session still uses this name.
|
||||||
|
if !s.deviceNameHasLiveSession(r.Context(), claims.UserID, deviceName) {
|
||||||
|
if _, derr := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{
|
||||||
|
UserID: claims.UserID,
|
||||||
|
Name: deviceName,
|
||||||
|
}); derr != nil {
|
||||||
|
slog.Warn("delete device on session revoke failed", "err", derr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.hub.Kick(claims.UserID, deviceName)
|
||||||
|
s.hub.PublishPresence(r.Context(), claims.UserID)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -11,10 +11,14 @@ import (
|
|||||||
// DeviceSweepInterval is how often the device sweeper wakes up.
|
// DeviceSweepInterval is how often the device sweeper wakes up.
|
||||||
const DeviceSweepInterval = 1 * time.Hour
|
const DeviceSweepInterval = 1 * time.Hour
|
||||||
|
|
||||||
// RunDeviceSweeper deletes devices whose last_seen is older than ttl.
|
// RunDeviceSweeper prunes the devices table on a timer so it stays aligned with
|
||||||
// Per user requirement: device registration must not outlive the longest
|
// the session list (AUTH.md §4): it drops (1) devices whose last_seen is older
|
||||||
// valid refresh_token (brief §2: 196h sliding window). Anything ≤ TTL is fine;
|
// than ttl — registration must not outlive the longest valid refresh_token
|
||||||
// 0 disables the sweeper.
|
// (brief §2: 196h sliding window) — and (2) browser devices whose web_session is
|
||||||
|
// gone (revoked or expired), which would otherwise linger until the stale cutoff
|
||||||
|
// now that users can no longer remove devices by hand. Native and shortcut
|
||||||
|
// devices keep no web_session and are pruned by the stale cutoff only. ttl ≤ 0
|
||||||
|
// disables the sweeper.
|
||||||
func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duration) {
|
func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duration) {
|
||||||
if ttl <= 0 {
|
if ttl <= 0 {
|
||||||
slog.Info("device sweeper disabled (ttl <= 0)")
|
slog.Info("device sweeper disabled (ttl <= 0)")
|
||||||
@@ -29,10 +33,14 @@ func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duratio
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
cutoff := time.Now().Add(-ttl).Unix()
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-ttl).Unix()
|
||||||
if err := queries.DeleteStaleDevices(ctx, cutoff); err != nil {
|
if err := queries.DeleteStaleDevices(ctx, cutoff); err != nil {
|
||||||
slog.Error("device sweeper failed", "err", err)
|
slog.Error("device sweeper failed", "err", err)
|
||||||
}
|
}
|
||||||
|
if _, err := queries.DeleteOrphanBrowserDevices(ctx, now.Unix()); err != nil {
|
||||||
|
slog.Error("device sweeper: orphan browser cleanup failed", "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,16 @@ import (
|
|||||||
"commilitia.net/cdrop/internal/db"
|
"commilitia.net/cdrop/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store is the subset of *db.Queries the auth middleware uses: device upserts
|
// Store is the subset of *db.Queries the auth middleware uses: device upserts,
|
||||||
// plus the shortcut-token lookups needed to honour revocation. Declared as an
|
// the shortcut-token lookups needed to honour revocation, and the web_session
|
||||||
// interface so tests can swap in a fake.
|
// lookup that makes a self-signed session token's revocation take effect at once
|
||||||
|
// (a deleted row → the token is rejected on its next request, not after TTL).
|
||||||
|
// Declared as an interface so tests can swap in a fake.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
UpsertDevice(ctx context.Context, arg db.UpsertDeviceParams) error
|
UpsertDevice(ctx context.Context, arg db.UpsertDeviceParams) error
|
||||||
GetShortcutToken(ctx context.Context, jti string) (db.ShortcutToken, error)
|
GetShortcutToken(ctx context.Context, jti string) (db.ShortcutToken, error)
|
||||||
TouchShortcutTokenUsed(ctx context.Context, arg db.TouchShortcutTokenUsedParams) error
|
TouchShortcutTokenUsed(ctx context.Context, arg db.TouchShortcutTokenUsedParams) error
|
||||||
|
GetWebSession(ctx context.Context, id string) (db.WebSession, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Authenticator struct {
|
type Authenticator struct {
|
||||||
@@ -108,7 +111,7 @@ func (a *Authenticator) verify(ctx context.Context, token string, r *http.Reques
|
|||||||
// cdrop self-signed session tokens first: distinct key + typ=session, so a
|
// cdrop self-signed session tokens first: distinct key + typ=session, so a
|
||||||
// shortcut token (different key, requires jti) never validates here and a
|
// shortcut token (different key, requires jti) never validates here and a
|
||||||
// session token never falls through to the shortcut path's DB lookup.
|
// session token never falls through to the shortcut path's DB lookup.
|
||||||
if c, err := a.verifySelfToken(token); err == nil {
|
if c, err := a.verifySelfToken(ctx, token); err == nil {
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
if c, err := a.verifyHS256(ctx, token); err == nil {
|
if c, err := a.verifyHS256(ctx, token); err == nil {
|
||||||
@@ -119,9 +122,13 @@ func (a *Authenticator) verify(ctx context.Context, token string, r *http.Reques
|
|||||||
|
|
||||||
// verifySelfToken validates a cdrop self-signed session access token (AUTH.md
|
// verifySelfToken validates a cdrop self-signed session access token (AUTH.md
|
||||||
// §3.1): HS256 over DeriveSessionTokenKey, carrying typ=session and a full/guest
|
// §3.1): HS256 over DeriveSessionTokenKey, carrying typ=session and a full/guest
|
||||||
// scope. Stateless by design — no DB lookup, so it stays cheap on every request;
|
// scope. This is the unified browser token — scan-login (self/guest) AND OIDC web
|
||||||
// revocation rides the short TTL (the session row is re-checked at /auth/refresh).
|
// logins both ride it now, so the browser holds one token type. After the cheap
|
||||||
func (a *Authenticator) verifySelfToken(token string) (*Claims, error) {
|
// signature/exp/scope checks it does ONE indexed lookup of the token's sid against
|
||||||
|
// web_sessions: a deleted row means the session was revoked, and the token is
|
||||||
|
// rejected on its very next request (immediate "log out this device"). The IdP
|
||||||
|
// RS256 path (verifyRS256) remains only for the desktop client's loopback tokens.
|
||||||
|
func (a *Authenticator) verifySelfToken(ctx context.Context, token string) (*Claims, error) {
|
||||||
if len(a.sessionTokenKey) == 0 {
|
if len(a.sessionTokenKey) == 0 {
|
||||||
return nil, errors.New("session token key not configured")
|
return nil, errors.New("session token key not configured")
|
||||||
}
|
}
|
||||||
@@ -147,6 +154,16 @@ func (a *Authenticator) verifySelfToken(token string) (*Claims, error) {
|
|||||||
if scope != "full" && scope != "guest" {
|
if scope != "full" && scope != "guest" {
|
||||||
return nil, errors.New("session token invalid scope")
|
return nil, errors.New("session token invalid scope")
|
||||||
}
|
}
|
||||||
|
// Bind the token to its live web_session row (sid): once that row is revoked
|
||||||
|
// (deleted), the token is rejected on its very next request — "log out this
|
||||||
|
// device" takes effect immediately rather than after the access token's TTL.
|
||||||
|
sid, _ := custom["sid"].(string)
|
||||||
|
if sid == "" {
|
||||||
|
return nil, errors.New("session token missing sid")
|
||||||
|
}
|
||||||
|
if _, err := a.store.GetWebSession(ctx, sid); err != nil {
|
||||||
|
return nil, errors.New("session revoked")
|
||||||
|
}
|
||||||
return &Claims{UserID: std.Subject, SessionScope: scope}, nil
|
return &Claims{UserID: std.Subject, SessionScope: scope}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,8 +282,9 @@ func (a *Authenticator) verifyRS256(ctx context.Context, token string) (*Claims,
|
|||||||
|
|
||||||
// VerifyIDToken validates an OIDC id_token (RS256 via JWKS, issuer + audience)
|
// VerifyIDToken validates an OIDC id_token (RS256 via JWKS, issuer + audience)
|
||||||
// from a fresh prompt=login exchange and returns its subject and auth_time (the
|
// from a fresh prompt=login exchange and returns its subject and auth_time (the
|
||||||
// epoch second of the actual end-user authentication). Used for step-up re-auth
|
// epoch second of the actual end-user authentication, or 0 when the IdP omits the
|
||||||
// (AUTH.md §6): the caller checks sub matches and auth_time is recent enough.
|
// claim — Casdoor does). Used for step-up re-auth (AUTH.md §6): the caller checks
|
||||||
|
// sub matches and, only when auth_time is present, that it is recent enough.
|
||||||
func (a *Authenticator) VerifyIDToken(ctx context.Context, token string) (subject string, authTime int64, err error) {
|
func (a *Authenticator) VerifyIDToken(ctx context.Context, token string) (subject string, authTime int64, err error) {
|
||||||
if a.jwks == nil {
|
if a.jwks == nil {
|
||||||
return "", 0, errors.New("JWKS not configured")
|
return "", 0, errors.New("JWKS not configured")
|
||||||
@@ -300,10 +318,10 @@ func (a *Authenticator) VerifyIDToken(ctx context.Context, token string) (subjec
|
|||||||
if std.Subject == "" {
|
if std.Subject == "" {
|
||||||
return "", 0, errors.New("missing subject claim")
|
return "", 0, errors.New("missing subject claim")
|
||||||
}
|
}
|
||||||
at, ok := numericClaim(custom["auth_time"])
|
// auth_time is OPTIONAL: required by OIDC only when the IdP chooses to honour
|
||||||
if !ok {
|
// max_age, and Casdoor omits it entirely. Absent → 0; the caller treats the
|
||||||
return "", 0, errors.New("missing auth_time claim")
|
// fresh single-use prompt=login code as the freshness bound instead.
|
||||||
}
|
at, _ := numericClaim(custom["auth_time"])
|
||||||
return std.Subject, at, nil
|
return std.Subject, at, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ import (
|
|||||||
// shortcut-token lookups from an in-memory map (empty → "not found", so plain
|
// shortcut-token lookups from an in-memory map (empty → "not found", so plain
|
||||||
// device-only tests are unaffected).
|
// device-only tests are unaffected).
|
||||||
type fakeDeviceUpserter struct {
|
type fakeDeviceUpserter struct {
|
||||||
calls []db.UpsertDeviceParams
|
calls []db.UpsertDeviceParams
|
||||||
err error
|
err error
|
||||||
tokens map[string]db.ShortcutToken
|
tokens map[string]db.ShortcutToken
|
||||||
|
revokedSIDs map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeDeviceUpserter) UpsertDevice(_ context.Context, arg db.UpsertDeviceParams) error {
|
func (f *fakeDeviceUpserter) UpsertDevice(_ context.Context, arg db.UpsertDeviceParams) error {
|
||||||
@@ -45,6 +46,15 @@ func (f *fakeDeviceUpserter) TouchShortcutTokenUsed(_ context.Context, _ db.Touc
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetWebSession backs the self-token revocation check. revokedSIDs lets a test
|
||||||
|
// simulate a revoked session (deleted row); any other sid resolves to a live row.
|
||||||
|
func (f *fakeDeviceUpserter) GetWebSession(_ context.Context, id string) (db.WebSession, error) {
|
||||||
|
if f.revokedSIDs[id] {
|
||||||
|
return db.WebSession{}, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return db.WebSession{ID: id, UserID: "user-x"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// echo handler that responds with the claims+device discovered in context.
|
// echo handler that responds with the claims+device discovered in context.
|
||||||
func echoMe(w http.ResponseWriter, r *http.Request) {
|
func echoMe(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, _ := ClaimsFromContext(r.Context())
|
claims, _ := ClaimsFromContext(r.Context())
|
||||||
@@ -489,6 +499,26 @@ func TestSanitizeDeviceName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A self-signed session token is bound to its web_session row: once the row is
|
||||||
|
// revoked (deleted), the token is rejected on its next request even though it is
|
||||||
|
// still cryptographically valid and unexpired — "log out this device" is immediate
|
||||||
|
// (AUTH.md §1/§3.1).
|
||||||
|
func TestSelfToken_RejectedAfterSessionRevoked(t *testing.T) {
|
||||||
|
secret := "test-session-secret-at-least-32-bytes-long"
|
||||||
|
exp := time.Now().Add(time.Hour)
|
||||||
|
|
||||||
|
live := New(&config.Config{AuthMode: "prod", SessionSecret: secret}, &fakeDeviceUpserter{})
|
||||||
|
if c, _ := serveBearer(live, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusOK {
|
||||||
|
t.Fatalf("token with a live session: got %d, want 200", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
revoked := New(&config.Config{AuthMode: "prod", SessionSecret: secret},
|
||||||
|
&fakeDeviceUpserter{revokedSIDs: map[string]bool{"test-sid": true}})
|
||||||
|
if c, _ := serveBearer(revoked, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusUnauthorized {
|
||||||
|
t.Errorf("token whose session was revoked must be rejected, got %d", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// signSelfToken mints a cdrop self-signed session token the way httpapi.mintSessionToken
|
// signSelfToken mints a cdrop self-signed session token the way httpapi.mintSessionToken
|
||||||
// does: HS256 over DeriveSessionTokenKey, with typ + scope custom claims and no jti.
|
// does: HS256 over DeriveSessionTokenKey, with typ + scope custom claims and no jti.
|
||||||
func signSelfToken(t *testing.T, secret, sub, typ, scope string, exp time.Time) string {
|
func signSelfToken(t *testing.T, secret, sub, typ, scope string, exp time.Time) string {
|
||||||
@@ -505,7 +535,7 @@ func signSelfToken(t *testing.T, secret, sub, typ, scope string, exp time.Time)
|
|||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
Expiry: jwt.NewNumericDate(exp),
|
Expiry: jwt.NewNumericDate(exp),
|
||||||
}
|
}
|
||||||
tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"typ": typ, "scope": scope}).Serialize()
|
tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"typ": typ, "scope": scope, "sid": "test-sid"}).Serialize()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("serialize: %v", err)
|
t.Fatalf("serialize: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+7
@@ -14,6 +14,7 @@
|
|||||||
"@mantine/notifications": "^7.13.5",
|
"@mantine/notifications": "^7.13.5",
|
||||||
"@tanstack/react-router": "^1.82.0",
|
"@tanstack/react-router": "^1.82.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -2433,6 +2434,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsqr": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@mantine/notifications": "^7.13.5",
|
"@mantine/notifications": "^7.13.5",
|
||||||
"@tanstack/react-router": "^1.82.0",
|
"@tanstack/react-router": "^1.82.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
@@ -20,13 +20,19 @@ export interface AuthShellProps
|
|||||||
* 跳转到主界面,跳转帧里顶栏 logo 会与主界面 header 的 logo 重叠成“两个
|
* 跳转到主界面,跳转帧里顶栏 logo 会与主界面 header 的 logo 重叠成“两个
|
||||||
* logo”,隐藏即可消除。登录页是整页跳 IdP、无此重叠,保留 logo。 */
|
* logo”,隐藏即可消除。登录页是整页跳 IdP、无此重叠,保留 logo。 */
|
||||||
hideBrand?: boolean;
|
hideBrand?: boolean;
|
||||||
|
/** 整页跳过聚珍 CJK 处理(在 shell 根加 data-jz-skip)。状态机页面(扫码 /
|
||||||
|
* 批准 / 显码:mount 后即 getUserMedia/fetch → setState 频繁重渲染)必须用:
|
||||||
|
* 聚珍是渲染后 MutationObserver 改 DOM 的库,与 React 重渲染同一子树本质冲突,
|
||||||
|
* 会触发 insertBefore 崩溃。这些页以短 chrome 文本为主,跳过聚珍仅损失些微
|
||||||
|
* 中西间距,远胜崩溃。 */
|
||||||
|
skipAutospace?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全屏外壳:登录、首次设置、OAuth 回调共用。带轻量品牌 + 主题切换,
|
// 全屏外壳:登录、首次设置、OAuth 回调共用。带轻量品牌 + 主题切换,
|
||||||
// 让用户在登录前也能调主题。
|
// 让用户在登录前也能调主题。
|
||||||
export function AuthShell(props: AuthShellProps)
|
export function AuthShell(props: AuthShellProps)
|
||||||
{
|
{
|
||||||
const { title, subtitle, children, footer, cardBrand, hideBrand } = props;
|
const { title, subtitle, children, footer, cardBrand, hideBrand, skipAutospace } = props;
|
||||||
const theme = useAppStore((s) => s.theme);
|
const theme = useAppStore((s) => s.theme);
|
||||||
const setTheme = useAppStore((s) => s.setTheme);
|
const setTheme = useAppStore((s) => s.setTheme);
|
||||||
const next: ThemeMode = theme === "system" ? "light" : theme === "light" ? "dark" : "system";
|
const next: ThemeMode = theme === "system" ? "light" : theme === "light" ? "dark" : "system";
|
||||||
@@ -38,7 +44,7 @@ export function AuthShell(props: AuthShellProps)
|
|||||||
const brandNode = cardBrand === undefined ? null : cardBrand;
|
const brandNode = cardBrand === undefined ? null : cardBrand;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.shell}>
|
<div className={s.shell} data-jz-skip={skipAutospace ? "" : undefined}>
|
||||||
<div className={s.topbar}>
|
<div className={s.topbar}>
|
||||||
{/* 空 span 占位保持 space-between,主题切换仍靠右。 */}
|
{/* 空 span 占位保持 space-between,主题切换仍靠右。 */}
|
||||||
{hideBrand ? <span /> : <span className={s.brand}><Wordmark /></span>}
|
{hideBrand ? <span /> : <span className={s.brand}><Wordmark /></span>}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { useAppStore, type User } from "../../store";
|
|||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { apiFetch } from "../../net/api";
|
import { apiFetch } from "../../net/api";
|
||||||
import { isDesktop, desktopRefresh, clearDesktopSession } from "../../net/desktop";
|
import { isDesktop, desktopRefresh, clearDesktopSession } from "../../net/desktop";
|
||||||
|
import { fetchMeScope } from "../../net/sessions";
|
||||||
import { stashStepUpPending } from "../qr/stepUp";
|
import { stashStepUpPending } from "../qr/stepUp";
|
||||||
|
import { toast } from "../../ui/feedback";
|
||||||
|
|
||||||
// ---- dev mode -------------------------------------------------------------
|
// ---- dev mode -------------------------------------------------------------
|
||||||
|
|
||||||
@@ -46,6 +48,29 @@ export function logout()
|
|||||||
if (isDesktop()) { clearDesktopSession(); }
|
if (isDesktop()) { clearDesktopSession(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// forceLogout 在「服务端确证会话已失效」时清掉本地登录态——触发点仅一处:
|
||||||
|
// access token 401 后续期也被服务端以 401 拒绝(refreshTokens 返回 "invalid",
|
||||||
|
// 即被远程吊销或已过期)。短期连接失败(网络抖动 / 5xx)绝不走到这里(见
|
||||||
|
// net/api.ts)。与 logout() 不同,它不发任何服务端请求(会话已死,调用只会再次
|
||||||
|
// 401),只就地清空 + 提示,让 __root 的反应式守卫把界面送回登录页
|
||||||
|
// (离线设备「下一次使用即得知」)。
|
||||||
|
//
|
||||||
|
// 幂等:靠「已登出即跳过」实现——首个 401 同步清空 user/accessToken 后,并发涌入
|
||||||
|
// 的其余 401 看到已为空便直接返回,不会重复弹 toast、不重复清桌面 session。
|
||||||
|
export function forceLogout(): void
|
||||||
|
{
|
||||||
|
const st = useAppStore.getState();
|
||||||
|
if (!st.user && !st.accessToken)
|
||||||
|
{
|
||||||
|
// 已被并发的某个 401 清空,或本就处于登出态:不重复处理(避免 toast 刷屏)。
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
st.clearAuth();
|
||||||
|
// 桌面端:删除 Go 侧持久化 session,否则下次启动仍注入这枚已失效的登录态。
|
||||||
|
if (isDesktop()) { clearDesktopSession(); }
|
||||||
|
toast.error(t("auth.sessionLost.title"), t("auth.sessionLost.body"));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- prod (OIDC PKCE) -----------------------------------------------------
|
// ---- prod (OIDC PKCE) -----------------------------------------------------
|
||||||
|
|
||||||
interface AuthConfig
|
interface AuthConfig
|
||||||
@@ -242,48 +267,69 @@ interface CookieRefreshResp
|
|||||||
device_name?: string;
|
device_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CookieRefreshResult 区分「服务端确证会话失效」与「短期失败」,使上层只在前者
|
||||||
|
// 清空登录态:
|
||||||
|
// ok —— 换到新 access token
|
||||||
|
// invalid —— /api/auth/refresh 返回 401:服务端查证会话已失效 / 被吊销 / 过期
|
||||||
|
// (其处理器同时清掉 cookie),凭据确凿不可恢复
|
||||||
|
// transient —— 5xx / 同源校验 403 / 响应残缺等非确证失败(网络层抛错则根本不进此
|
||||||
|
// 函数、照常向上抛)——一律保留登录态,按短期错误处理
|
||||||
|
type CookieRefreshResult =
|
||||||
|
| { kind: "ok"; accessToken: string; user: User; deviceName: string }
|
||||||
|
| { kind: "invalid" }
|
||||||
|
| { kind: "transient" };
|
||||||
|
|
||||||
// cookieRefresh 用 HttpOnly session cookie 静默换一枚新 access_token。无 body——
|
// cookieRefresh 用 HttpOnly session cookie 静默换一枚新 access_token。无 body——
|
||||||
// cookie 自动随同源请求发出(Path=/api/auth)。成功返回 {accessToken, user,
|
// cookie 自动随同源请求发出(Path=/api/auth)。返回见 CookieRefreshResult:只有
|
||||||
// deviceName};cookie 缺失 / 过期 / 被 IdP 拒绝 → null(调用方据此落到登录页)。
|
// 服务端明确以 401 拒绝才算 invalid,短期失败一律 transient,不误伤登录态。
|
||||||
async function cookieRefresh(): Promise<{ accessToken: string; user: User; deviceName: string } | null>
|
async function cookieRefresh(): Promise<CookieRefreshResult>
|
||||||
{
|
{
|
||||||
const r = await fetch("/api/auth/refresh", { method: "POST" });
|
const r = await fetch("/api/auth/refresh", { method: "POST" });
|
||||||
if (!r.ok) { return null; }
|
if (!r.ok)
|
||||||
|
{
|
||||||
|
return { kind: r.status === 401 ? "invalid" : "transient" };
|
||||||
|
}
|
||||||
const data = await r.json().catch(() => null) as CookieRefreshResp | null;
|
const data = await r.json().catch(() => null) as CookieRefreshResp | null;
|
||||||
if (!data?.access_token || !data.user?.id) { return null; }
|
if (!data?.access_token || !data.user?.id) { return { kind: "transient" }; }
|
||||||
return {
|
return {
|
||||||
|
kind: "ok",
|
||||||
accessToken: data.access_token,
|
accessToken: data.access_token,
|
||||||
user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar },
|
user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar },
|
||||||
deviceName: data.device_name ?? "",
|
deviceName: data.device_name ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshTokens 换新 access_token;api.ts 在请求拿到 401 时惰性调用。返回是否成功。
|
// RefreshOutcome 是续期对上层的三态结论;只有 "invalid"(服务端确证失效)才允许
|
||||||
export async function refreshTokens(): Promise<boolean>
|
// 上层 forceLogout,"transient" 必须保留登录态(短期连接失败不清状态)。
|
||||||
|
export type RefreshOutcome = "refreshed" | "invalid" | "transient";
|
||||||
|
|
||||||
|
// refreshTokens 换新 access_token;api.ts 在请求拿到 401 时惰性调用。
|
||||||
|
export async function refreshTokens(): Promise<RefreshOutcome>
|
||||||
{
|
{
|
||||||
const store = useAppStore.getState();
|
const store = useAppStore.getState();
|
||||||
if (store.authMode !== "prod") { return false; }
|
if (store.authMode !== "prod") { return "transient"; }
|
||||||
|
|
||||||
// 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 Go,JS 不持有),
|
// 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 Go,JS 不持有),
|
||||||
// 直接无参调用。
|
// 直接无参调用。Go 侧暂不区分「被吊销」与「短期失败」,故失败一律按 transient
|
||||||
|
// 处理(不清空登录态)——桌面端即时吊销留后续(AUTH.md §3.1)。
|
||||||
if (isDesktop())
|
if (isDesktop())
|
||||||
{
|
{
|
||||||
const res = await desktopRefresh();
|
const res = await desktopRefresh();
|
||||||
if (!res?.access_token) { return false; }
|
if (!res?.access_token) { return "transient"; }
|
||||||
const cur = useAppStore.getState();
|
const cur = useAppStore.getState();
|
||||||
if (!cur.user) { return false; }
|
if (!cur.user) { return "transient"; }
|
||||||
cur.setAuth({ accessToken: res.access_token, user: cur.user });
|
cur.setAuth({ accessToken: res.access_token, user: cur.user });
|
||||||
return true;
|
return "refreshed";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 浏览器端:走 HttpOnly cookie session(refresh_token 在服务端,JS 不持有)。
|
// 浏览器端:走 HttpOnly cookie session(refresh_token 在服务端,JS 不持有)。
|
||||||
const res = await cookieRefresh();
|
const res = await cookieRefresh();
|
||||||
if (!res) { return false; }
|
if (res.kind !== "ok") { return res.kind; } // "invalid" | "transient",透传给上层
|
||||||
const cur = useAppStore.getState();
|
const cur = useAppStore.getState();
|
||||||
cur.setAuth({ accessToken: res.accessToken, user: res.user });
|
cur.setAuth({ accessToken: res.accessToken, user: res.user });
|
||||||
// 设备名以服务端为权威;本地缺失(PWA 存储被清)时回填。
|
// 设备名以服务端为权威;本地缺失(PWA 存储被清)时回填。
|
||||||
if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); }
|
if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); }
|
||||||
return true;
|
return "refreshed";
|
||||||
}
|
}
|
||||||
|
|
||||||
// bootstrapAuth 在 App 挂载前尝试「免重登」:浏览器端若本会话尚无 access_token,
|
// bootstrapAuth 在 App 挂载前尝试「免重登」:浏览器端若本会话尚无 access_token,
|
||||||
@@ -297,12 +343,24 @@ export async function bootstrapAuth(): Promise<void>
|
|||||||
if (store.accessToken && store.user) { return; } // 同会话已登录(sessionStorage 命中)
|
if (store.accessToken && store.user) { return; } // 同会话已登录(sessionStorage 命中)
|
||||||
|
|
||||||
const res = await cookieRefresh();
|
const res = await cookieRefresh();
|
||||||
if (!res) { return; }
|
if (res.kind !== "ok") { return; } // 开机水合失败(含 invalid / transient)→ 照常落登录页
|
||||||
const cur = useAppStore.getState();
|
const cur = useAppStore.getState();
|
||||||
cur.setAuth({ accessToken: res.accessToken, user: res.user });
|
cur.setAuth({ accessToken: res.accessToken, user: res.user });
|
||||||
if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); }
|
if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshSessionScope 拉一次 /api/me 校正本会话权限级别(full / guest)并写进
|
||||||
|
// store。在登录成功 / 开机水合后调用一次,使受限访客的账号管理入口被隐藏。失败
|
||||||
|
// 静默回退 "full"——只多展示入口,后端仍 403 兜底。dev 模式无 guest 概念,跳过。
|
||||||
|
export async function refreshSessionScope(): Promise<void>
|
||||||
|
{
|
||||||
|
const store = useAppStore.getState();
|
||||||
|
if (store.authMode !== "prod") { return; }
|
||||||
|
if (!store.user || !store.accessToken) { return; }
|
||||||
|
const scope = await fetchMeScope();
|
||||||
|
useAppStore.getState().setSessionScope(scope);
|
||||||
|
}
|
||||||
|
|
||||||
// syncWebDeviceName 把本机设备名推到服务端 session(cookie 鉴权),让 PWA 存储被清
|
// syncWebDeviceName 把本机设备名推到服务端 session(cookie 鉴权),让 PWA 存储被清
|
||||||
// 后开机仍能从 cookie session 恢复设备名、不再误跳 /setup。仅浏览器 prod;桌面端走
|
// 后开机仍能从 cookie session 恢复设备名、不再误跳 /setup。仅浏览器 prod;桌面端走
|
||||||
// persistDesktopDeviceName(Go 持久化),dev 无 session。失败静默——只是体验降级。
|
// persistDesktopDeviceName(Go 持久化),dev 无 session。失败静默——只是体验降级。
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function DesktopSettings()
|
|||||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||||
<Text size="sm" fw={500}>{t("settings.desktop.downloadDir")}</Text>
|
<Text size="sm" fw={500}>{t("settings.desktop.downloadDir")}</Text>
|
||||||
<Text size="xs" c="dimmed">{t("settings.desktop.downloadDirHint")}</Text>
|
<Text size="xs" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.desktop.downloadDirHint")}</Text>
|
||||||
<Text
|
<Text
|
||||||
size="xs"
|
size="xs"
|
||||||
style={{ fontFamily: "var(--font-mono, ui-monospace, monospace)", wordBreak: "break-all" }}
|
style={{ fontFamily: "var(--font-mono, ui-monospace, monospace)", wordBreak: "break-all" }}
|
||||||
@@ -107,7 +107,7 @@ export function DesktopSettings()
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
<div style={{ height: 1, background: "var(--divider)" }} />
|
<div style={{ height: 1, background: "var(--divider)" }} />
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed" className="justify" data-jz-level="paragraph">
|
||||||
{t("settings.desktop.ttlNote")}
|
{t("settings.desktop.ttlNote")}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -128,7 +128,7 @@ function ToggleRow(props: {
|
|||||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||||
<Text size="sm" fw={500}>{title}</Text>
|
<Text size="sm" fw={500}>{title}</Text>
|
||||||
<Text size="xs" c="dimmed">{hint}</Text>
|
<Text size="xs" c="dimmed" className="justify" data-jz-level="paragraph">{hint}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Switch
|
<Switch
|
||||||
checked={checked}
|
checked={checked}
|
||||||
|
|||||||
@@ -160,8 +160,20 @@
|
|||||||
|
|
||||||
.headerCount { color: var(--text-muted); font-size: var(--fs-12); margin-left: 4px; }
|
.headerCount { color: var(--text-muted); font-size: var(--fs-12); margin-left: 4px; }
|
||||||
|
|
||||||
|
/* header 右侧动作组:「显示/隐藏离线」与「扫码登录新设备」并排,同款文本按钮语汇。 */
|
||||||
|
.headerActions
|
||||||
|
{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.headerSwitch
|
.headerSwitch
|
||||||
{
|
{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -173,3 +185,4 @@
|
|||||||
transition: background-color var(--dur-fast) var(--ease-out);
|
transition: background-color var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
.headerSwitch:hover { background: var(--accent-soft); color: var(--accent); }
|
.headerSwitch:hover { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.headerSwitch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState, type DragEvent } from "react";
|
import { useMemo, useState, type DragEvent } from "react";
|
||||||
import { Check } from "lucide-react";
|
import { Check, ScanLine } from "lucide-react";
|
||||||
import { DeviceTypeIcon, StateDot } from "../../ui/glyphs";
|
import { DeviceTypeIcon, StateDot } from "../../ui/glyphs";
|
||||||
import type { DeviceKind } from "../../ui/glyphs";
|
import type { DeviceKind } from "../../ui/glyphs";
|
||||||
import type { DeviceInfo } from "../../store";
|
import type { DeviceInfo } from "../../store";
|
||||||
@@ -15,11 +15,13 @@ export interface DeviceStripProps
|
|||||||
onSelect: (name: string | null) => void;
|
onSelect: (name: string | null) => void;
|
||||||
/** 拖拽文件落到设备卡上,跳过 composer 直接发送。 */
|
/** 拖拽文件落到设备卡上,跳过 composer 直接发送。 */
|
||||||
onDropFile?: (deviceName: string, file: File) => void;
|
onDropFile?: (deviceName: string, file: File) => void;
|
||||||
|
/** 「扫码登录新设备」入口:应用内相机扫码把另一台设备接入账号。 */
|
||||||
|
onLinkDevice?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeviceStrip(props: DeviceStripProps)
|
export function DeviceStrip(props: DeviceStripProps)
|
||||||
{
|
{
|
||||||
const { devices, selfDeviceName, selectedDevice, onSelect, onDropFile } = props;
|
const { devices, selfDeviceName, selectedDevice, onSelect, onDropFile, onLinkDevice } = props;
|
||||||
const [ showOffline, setShowOffline ] = useState(false);
|
const [ showOffline, setShowOffline ] = useState(false);
|
||||||
|
|
||||||
const peers = useMemo(
|
const peers = useMemo(
|
||||||
@@ -42,17 +44,29 @@ export function DeviceStrip(props: DeviceStripProps)
|
|||||||
{t("home.deviceList.onlineSuffix", { count: onlineCount })}
|
{t("home.deviceList.onlineSuffix", { count: onlineCount })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{offlineCount > 0 && (
|
<div className={s.headerActions}>
|
||||||
<button
|
{offlineCount > 0 && (
|
||||||
type="button"
|
<button
|
||||||
className={s.headerSwitch}
|
type="button"
|
||||||
onClick={() => setShowOffline((v) => !v)}
|
className={s.headerSwitch}
|
||||||
>
|
onClick={() => setShowOffline((v) => !v)}
|
||||||
{showOffline
|
>
|
||||||
? "隐藏离线"
|
{showOffline
|
||||||
: t("home.deviceList.showOffline", { count: offlineCount })}
|
? t("home.deviceList.hideOffline")
|
||||||
</button>
|
: t("home.deviceList.showOffline", { count: offlineCount })}
|
||||||
)}
|
</button>
|
||||||
|
)}
|
||||||
|
{onLinkDevice && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={s.headerSwitch}
|
||||||
|
onClick={onLinkDevice}
|
||||||
|
>
|
||||||
|
<ScanLine size={13} aria-hidden />
|
||||||
|
{t("qr.entry.scanNewDevice")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{visible.length === 0 ? (
|
{visible.length === 0 ? (
|
||||||
|
|||||||
@@ -15,12 +15,24 @@
|
|||||||
|
|
||||||
const PENDING_KEY = "cdrop.qr.stepup_pending"; // { verifier, returnTo }
|
const PENDING_KEY = "cdrop.qr.stepup_pending"; // { verifier, returnTo }
|
||||||
const STASH_KEY = "cdrop.qr.stepup_stash"; // { code, verifier }
|
const STASH_KEY = "cdrop.qr.stepup_stash"; // { code, verifier }
|
||||||
|
// 登录会话登出的 step-up:跨整页跳转记住「要登出的是哪条会话」。回到设置页后取出,
|
||||||
|
// 重试那条 DELETE。批准页的 step-up 不用它(要批准的 request 已编在 returnTo 里)。
|
||||||
|
const REVOKE_KEY = "cdrop.qr.stepup_revoke"; // sessionId(string)
|
||||||
|
|
||||||
// 仅允许站内 /link 路径回流,杜绝 open-redirect(外部 URL / 协议相对地址)。
|
// 仅允许两个站内回流目标,杜绝 open-redirect(外部 URL / 协议相对地址 / 任意路径):
|
||||||
// 与 returnTo.ts 同口径——step-up 往返回来后的整页跳转目标只能是批准页。
|
// - /link[?…] ——批准页(扫码登录的 step-up);
|
||||||
function isSafeLinkPath(path: string): boolean
|
// - /settings ——设置页(登录会话登出的 step-up)。
|
||||||
|
// step-up 往返回来后的整页跳转目标只能是这两处之一;其余一律拒。校验 path 必须以
|
||||||
|
// "/link" 或 "/settings" 起头且后随串只能是空或 "?…",挡住 "/linkmalicious" /
|
||||||
|
// "//evil.com"(协议相对)/ "/settings/../x" 之类绕过。
|
||||||
|
function isSafeReturnPath(path: string): boolean
|
||||||
{
|
{
|
||||||
return path.startsWith("/link?") || path === "/link";
|
for (const base of [ "/link", "/settings" ])
|
||||||
|
{
|
||||||
|
if (path === base) { return true; }
|
||||||
|
if (path.startsWith(base + "?")) { return true; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StepUpPending
|
export interface StepUpPending
|
||||||
@@ -40,7 +52,7 @@ export interface StepUpStash
|
|||||||
export function stashStepUpPending(pending: StepUpPending): boolean
|
export function stashStepUpPending(pending: StepUpPending): boolean
|
||||||
{
|
{
|
||||||
if (typeof window === "undefined") { return false; }
|
if (typeof window === "undefined") { return false; }
|
||||||
if (!pending.verifier || !isSafeLinkPath(pending.returnTo)) { return false; }
|
if (!pending.verifier || !isSafeReturnPath(pending.returnTo)) { return false; }
|
||||||
window.sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
|
window.sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -53,7 +65,7 @@ export function peekStepUpPending(): StepUpPending | null
|
|||||||
const raw = window.sessionStorage.getItem(PENDING_KEY);
|
const raw = window.sessionStorage.getItem(PENDING_KEY);
|
||||||
if (!raw) { return null; }
|
if (!raw) { return null; }
|
||||||
const p = safeParse<StepUpPending>(raw);
|
const p = safeParse<StepUpPending>(raw);
|
||||||
if (!p?.verifier || !isSafeLinkPath(p.returnTo)) { return null; }
|
if (!p?.verifier || !isSafeReturnPath(p.returnTo)) { return null; }
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +103,24 @@ export function clearStepUpStash(): void
|
|||||||
window.sessionStorage.removeItem(STASH_KEY);
|
window.sessionStorage.removeItem(STASH_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stashPendingRevoke 在发起会话登出的 step-up 再认证前记下待登出的 sessionId,
|
||||||
|
// 跨整页跳转留到回到设置页后取出重试。
|
||||||
|
export function stashPendingRevoke(sessionId: string): void
|
||||||
|
{
|
||||||
|
if (typeof window === "undefined") { return; }
|
||||||
|
if (!sessionId) { return; }
|
||||||
|
window.sessionStorage.setItem(REVOKE_KEY, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumePendingRevoke 取出并清除待登出的 sessionId(一次性,用完即清)。
|
||||||
|
export function consumePendingRevoke(): string | null
|
||||||
|
{
|
||||||
|
if (typeof window === "undefined") { return null; }
|
||||||
|
const id = window.sessionStorage.getItem(REVOKE_KEY);
|
||||||
|
window.sessionStorage.removeItem(REVOKE_KEY);
|
||||||
|
return id || null;
|
||||||
|
}
|
||||||
|
|
||||||
function safeParse<T>(raw: string): T | null
|
function safeParse<T>(raw: string): T | null
|
||||||
{
|
{
|
||||||
try { return JSON.parse(raw) as T; }
|
try { return JSON.parse(raw) as T; }
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export function ShortcutTokens()
|
|||||||
return (
|
return (
|
||||||
<Panel title={t("settings.shortcut.title")}>
|
<Panel title={t("settings.shortcut.title")}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text size="sm" c="dimmed">{t("settings.shortcut.intro")}</Text>
|
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.shortcut.intro")}</Text>
|
||||||
|
|
||||||
<Group align="flex-end" gap="sm" wrap="nowrap">
|
<Group align="flex-end" gap="sm" wrap="nowrap">
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
@@ -273,24 +273,24 @@ function IssuedReveal(props: { issued: IssueResp; base: string; onDismiss: () =>
|
|||||||
|
|
||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
<Text size="sm" fw={600}>{t("settings.shortcut.guideTitle")}</Text>
|
<Text size="sm" fw={600}>{t("settings.shortcut.guideTitle")}</Text>
|
||||||
<Text size="sm" c="dimmed">{t("settings.shortcut.guideIntro")}</Text>
|
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.shortcut.guideIntro")}</Text>
|
||||||
<Text size="sm" c="dimmed">{t("settings.shortcut.guideDeviceName")}</Text>
|
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.shortcut.guideDeviceName")}</Text>
|
||||||
|
|
||||||
<Text size="sm" fw={500}>{t("settings.shortcut.guidePullTitle")}</Text>
|
<Text size="sm" fw={500}>{t("settings.shortcut.guidePullTitle")}</Text>
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Text size="sm">1. {t("settings.shortcut.guidePull1")}</Text>
|
<Text size="sm" className="justify" data-jz-level="paragraph">1. {t("settings.shortcut.guidePull1")}</Text>
|
||||||
<Text size="sm">2. {t("settings.shortcut.guidePull2")}</Text>
|
<Text size="sm" className="justify" data-jz-level="paragraph">2. {t("settings.shortcut.guidePull2")}</Text>
|
||||||
<Text size="sm">3. {t("settings.shortcut.guidePull3")}</Text>
|
<Text size="sm" className="justify" data-jz-level="paragraph">3. {t("settings.shortcut.guidePull3")}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Text size="sm" fw={500}>{t("settings.shortcut.guidePushTitle")}</Text>
|
<Text size="sm" fw={500}>{t("settings.shortcut.guidePushTitle")}</Text>
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Text size="sm">1. {t("settings.shortcut.guidePush1")}</Text>
|
<Text size="sm" className="justify" data-jz-level="paragraph">1. {t("settings.shortcut.guidePush1")}</Text>
|
||||||
<Text size="sm">2. {t("settings.shortcut.guidePush2")}</Text>
|
<Text size="sm" className="justify" data-jz-level="paragraph">2. {t("settings.shortcut.guidePush2")}</Text>
|
||||||
<Text size="sm">3. {t("settings.shortcut.guidePush3")}</Text>
|
<Text size="sm" className="justify" data-jz-level="paragraph">3. {t("settings.shortcut.guidePush3")}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Text size="xs" c="dimmed">{t("settings.shortcut.guideToleranceNote")}</Text>
|
<Text size="xs" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.shortcut.guideToleranceNote")}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export const enUS: Partial<TranslationDict> = {
|
|||||||
"app.disconnectedHint":
|
"app.disconnectedHint":
|
||||||
"Lost connection to the server — retrying automatically. No need to refresh.",
|
"Lost connection to the server — retrying automatically. No need to refresh.",
|
||||||
|
|
||||||
|
// ---- session lost (revoked remotely / expired) ----------------------
|
||||||
|
"auth.sessionLost.title": "Signed out",
|
||||||
|
"auth.sessionLost.body":
|
||||||
|
"This device's login was revoked or expired. Please sign in again.",
|
||||||
|
|
||||||
// ---- header / user menu ---------------------------------------------
|
// ---- header / user menu ---------------------------------------------
|
||||||
"nav.accountMenu": "Account menu",
|
"nav.accountMenu": "Account menu",
|
||||||
"nav.thisDeviceLabel": "This device: ",
|
"nav.thisDeviceLabel": "This device: ",
|
||||||
@@ -34,6 +39,7 @@ export const enUS: Partial<TranslationDict> = {
|
|||||||
"home.deviceList.title": "Devices",
|
"home.deviceList.title": "Devices",
|
||||||
"home.deviceList.onlineSuffix": "({{count}} online)",
|
"home.deviceList.onlineSuffix": "({{count}} online)",
|
||||||
"home.deviceList.showOffline": "Show offline ({{count}})",
|
"home.deviceList.showOffline": "Show offline ({{count}})",
|
||||||
|
"home.deviceList.hideOffline": "Hide offline",
|
||||||
"home.deviceList.empty":
|
"home.deviceList.empty":
|
||||||
"No peer devices yet. Open the app in another tab with a different device name (Settings → Device name) to see it here.",
|
"No peer devices yet. Open the app in another tab with a different device name (Settings → Device name) to see it here.",
|
||||||
"home.tabs.file": "File",
|
"home.tabs.file": "File",
|
||||||
@@ -95,6 +101,20 @@ export const enUS: Partial<TranslationDict> = {
|
|||||||
|
|
||||||
// ---- QR sign-in · entry --------------------------------------------
|
// ---- QR sign-in · entry --------------------------------------------
|
||||||
"qr.entry.fromLogin": "Sign in with a QR code",
|
"qr.entry.fromLogin": "Sign in with a QR code",
|
||||||
|
"qr.entry.scanNewDevice": "Scan to link a device",
|
||||||
|
|
||||||
|
// In-app scanner: scan a new device's QR code from this signed-in device to link it.
|
||||||
|
"qr.scan.title": "Scan to link a device",
|
||||||
|
"qr.scan.subtitle": "Point the code shown on the other device at the frame. Approval stays on this signed-in device.",
|
||||||
|
"qr.scan.starting": "Starting the camera…",
|
||||||
|
"qr.scan.aiming": "Line the code up inside the frame",
|
||||||
|
"qr.scan.foreign": "That isn't a sign-in code for this site. Aim at the code from “Sign in with a QR code” on the new device.",
|
||||||
|
"qr.scan.noCamera": "This device has no camera available. Use a phone or tablet with a camera to scan instead.",
|
||||||
|
"qr.scan.denied": "Camera access was blocked. Allow the camera in your browser's site settings, then reopen this page.",
|
||||||
|
"qr.scan.error": "Couldn't open the camera. Try again, or scan from a device with a camera.",
|
||||||
|
"qr.scan.howto1": "On the new device, open “Sign in with a QR code” to show a code.",
|
||||||
|
"qr.scan.howto2": "Line that code up in the frame above — approval starts automatically.",
|
||||||
|
"qr.scan.back": "Back",
|
||||||
|
|
||||||
// ---- QR sign-in · show page (new device) ---------------------------
|
// ---- QR sign-in · show page (new device) ---------------------------
|
||||||
"qr.show.title": "Sign in with a QR code",
|
"qr.show.title": "Sign in with a QR code",
|
||||||
@@ -109,6 +129,8 @@ export const enUS: Partial<TranslationDict> = {
|
|||||||
"qr.show.expired": "This code has expired. Generate a new one and scan again.",
|
"qr.show.expired": "This code has expired. Generate a new one and scan again.",
|
||||||
"qr.show.denied": "This request was declined. If that was you, generate a new code and try again.",
|
"qr.show.denied": "This request was declined. If that was you, generate a new code and try again.",
|
||||||
"qr.show.regenerate": "Generate a new code",
|
"qr.show.regenerate": "Generate a new code",
|
||||||
|
"qr.show.back": "Back to home",
|
||||||
|
"qr.show.backToName": "Back",
|
||||||
|
|
||||||
// ---- QR sign-in · approve page (phone) -----------------------------
|
// ---- QR sign-in · approve page (phone) -----------------------------
|
||||||
"qr.approve.title": "Approve a new device",
|
"qr.approve.title": "Approve a new device",
|
||||||
@@ -117,11 +139,12 @@ export const enUS: Partial<TranslationDict> = {
|
|||||||
"qr.approve.badLink": "This approval link is incomplete. Generate a new code on the device and scan it again.",
|
"qr.approve.badLink": "This approval link is incomplete. Generate a new code on the device and scan it again.",
|
||||||
"qr.approve.signInFirst": "Sign in to your account first, then approve this device.",
|
"qr.approve.signInFirst": "Sign in to your account first, then approve this device.",
|
||||||
"qr.approve.trustLabel": "Trust duration",
|
"qr.approve.trustLabel": "Trust duration",
|
||||||
"qr.approve.trust.title": "Trust this device",
|
"qr.approve.trust.title": "Trust this device (7 days)",
|
||||||
"qr.approve.trust.hint": "Skip approval for 7 days, renewed on each use.",
|
"qr.approve.trust.hint": "Skip approval for 7 days, renewed on each use.",
|
||||||
"qr.approve.once.title": "Just this once",
|
"qr.approve.once.title": "Just this once (1 hour)",
|
||||||
"qr.approve.once.hint": "Signed in for 1 hour, then scan again.",
|
"qr.approve.once.hint": "Signed in for 1 hour, then scan again.",
|
||||||
"qr.approve.scopeGuest": "This device signs in as a limited guest: it can send and receive files and messages, but can't change your account or approve other devices.",
|
"qr.approve.scopeFull": "This device signs fully into your account for 7 days: it can send and receive files and messages, and manage your account and devices.",
|
||||||
|
"qr.approve.scopeGuest": "Signs in as a limited guest — it can send and receive files and messages, but can't change your account or approve other devices; expires after 1 hour.",
|
||||||
"qr.approve.approve": "Approve sign-in",
|
"qr.approve.approve": "Approve sign-in",
|
||||||
"qr.approve.stepUp.notice": "For security, verify your identity before approving — the button below takes you through a fresh sign-in.",
|
"qr.approve.stepUp.notice": "For security, verify your identity before approving — the button below takes you through a fresh sign-in.",
|
||||||
"qr.approve.stepUp.button": "Verify and approve",
|
"qr.approve.stepUp.button": "Verify and approve",
|
||||||
@@ -151,16 +174,38 @@ export const enUS: Partial<TranslationDict> = {
|
|||||||
"Removes this device's registration and signs you out.",
|
"Removes this device's registration and signs you out.",
|
||||||
"settings.unregister.currentConfirm":
|
"settings.unregister.currentConfirm":
|
||||||
"Unregistering this device will remove its registration and sign you out. Continue?",
|
"Unregistering this device will remove its registration and sign you out. Continue?",
|
||||||
"settings.unregister.peerConfirm": "Remove device \"{{name}}\"?",
|
|
||||||
"settings.peers.title": "Other devices",
|
|
||||||
"settings.peers.empty": "No other devices.",
|
|
||||||
"settings.peers.remove": "Remove",
|
|
||||||
"settings.peers.removing": "Removing…",
|
|
||||||
"settings.error.delete": "Action failed: {{message}}",
|
"settings.error.delete": "Action failed: {{message}}",
|
||||||
|
|
||||||
|
// ---- Limited-guest badge --------------------------------------------
|
||||||
|
"settings.guest.badge": "Limited guest",
|
||||||
|
"settings.guest.title": "This device is a limited guest",
|
||||||
|
"settings.guest.body": "This device is signed in as a limited guest: it can send and receive files, messages, and clipboard, but cannot manage the account, remove devices, or authorize new ones. To get full access, sign in again on your main device.",
|
||||||
|
|
||||||
|
// ---- Login sessions / device management -----------------------------
|
||||||
|
"settings.sessions.title": "Login sessions",
|
||||||
|
"settings.sessions.hint": "Every device currently signed in to this account — browser, desktop, and mobile clients; online ones are listed first. Signing a device out ends its session immediately; it must sign in again on that device to continue.",
|
||||||
|
"settings.sessions.guestNote": "Limited guests can't manage login sessions. To manage them, sign in fully on your main device.",
|
||||||
|
"settings.sessions.empty": "No other login sessions.",
|
||||||
|
"settings.sessions.loadError": "Failed to load login sessions.",
|
||||||
|
"settings.sessions.retry": "Retry",
|
||||||
|
"settings.sessions.current": "This device",
|
||||||
|
"settings.sessions.scopeFull": "Full",
|
||||||
|
"settings.sessions.scopeGuest": "Limited guest",
|
||||||
|
"settings.sessions.kind.oidc": "Account login",
|
||||||
|
"settings.sessions.kind.self": "Local account",
|
||||||
|
"settings.sessions.kind.guest": "Scanned guest",
|
||||||
|
"settings.sessions.lastUsed": "Last active {{time}}",
|
||||||
|
"settings.sessions.neverUsed": "Not active yet",
|
||||||
|
"settings.sessions.signOut": "Sign out",
|
||||||
|
"settings.sessions.signingOut": "Signing out…",
|
||||||
|
"settings.sessions.signOutCurrent": "Sign out this device",
|
||||||
|
"settings.sessions.confirmOther": "Sign out device \"{{name}}\"? Its session will be invalidated immediately and it must sign in again.",
|
||||||
|
"settings.sessions.confirmCurrent": "Signing out this device is the same as logging out. Continue?",
|
||||||
|
"settings.sessions.revoked": "Signed that device out",
|
||||||
|
"settings.sessions.stepUpRejected": "Identity verification failed or expired. Please try again.",
|
||||||
"settings.online": "Online",
|
"settings.online": "Online",
|
||||||
"settings.offline": "Offline",
|
"settings.offline": "Offline",
|
||||||
"settings.lastSeen": "Last seen {{time}}",
|
"settings.lastSeen": "Last seen {{time}}",
|
||||||
"settings.lastSeenNever": "Never connected",
|
|
||||||
"settings.push.title": "Push notifications",
|
"settings.push.title": "Push notifications",
|
||||||
"settings.push.hint": "When this page is closed, new files, messages, and transfer results arrive as system notifications; while it's open, you'll see in-app alerts instead.",
|
"settings.push.hint": "When this page is closed, new files, messages, and transfer results arrive as system notifications; while it's open, you'll see in-app alerts instead.",
|
||||||
"settings.push.enable": "Enable push",
|
"settings.push.enable": "Enable push",
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export const zhCN = {
|
|||||||
"app.disconnectedTitle": "正在重连",
|
"app.disconnectedTitle": "正在重连",
|
||||||
"app.disconnectedHint": "与服务器的连接已断开,正在自动重连,无需刷新页面。",
|
"app.disconnectedHint": "与服务器的连接已断开,正在自动重连,无需刷新页面。",
|
||||||
|
|
||||||
|
// ---- 会话失效(被远程吊销 / 过期)------------------------------------
|
||||||
|
"auth.sessionLost.title": "登录已失效",
|
||||||
|
"auth.sessionLost.body": "此设备的登录凭据已被注销或过期,请重新登录。",
|
||||||
|
|
||||||
// ---- 顶部栏 / 用户菜单 ------------------------------------------------
|
// ---- 顶部栏 / 用户菜单 ------------------------------------------------
|
||||||
"nav.accountMenu": "账号菜单",
|
"nav.accountMenu": "账号菜单",
|
||||||
"nav.thisDeviceLabel": "本机:",
|
"nav.thisDeviceLabel": "本机:",
|
||||||
@@ -32,6 +36,7 @@ export const zhCN = {
|
|||||||
"home.deviceList.title": "设备",
|
"home.deviceList.title": "设备",
|
||||||
"home.deviceList.onlineSuffix": "({{count}} 台在线)",
|
"home.deviceList.onlineSuffix": "({{count}} 台在线)",
|
||||||
"home.deviceList.showOffline": "显示离线设备({{count}})",
|
"home.deviceList.showOffline": "显示离线设备({{count}})",
|
||||||
|
"home.deviceList.hideOffline": "隐藏离线设备",
|
||||||
"home.deviceList.empty":
|
"home.deviceList.empty":
|
||||||
"暂无其他设备。在另一标签页或设备上以相同账户登录,并设置一个不同的设备名(设置 → 设备名称),即可在此处看到它。",
|
"暂无其他设备。在另一标签页或设备上以相同账户登录,并设置一个不同的设备名(设置 → 设备名称),即可在此处看到它。",
|
||||||
"home.tabs.file": "文件",
|
"home.tabs.file": "文件",
|
||||||
@@ -93,6 +98,20 @@ export const zhCN = {
|
|||||||
|
|
||||||
// ---- 扫码登录 · 入口 -------------------------------------------------
|
// ---- 扫码登录 · 入口 -------------------------------------------------
|
||||||
"qr.entry.fromLogin": "扫码登录此设备",
|
"qr.entry.fromLogin": "扫码登录此设备",
|
||||||
|
"qr.entry.scanNewDevice": "扫码登录新设备",
|
||||||
|
|
||||||
|
// 应用内扫码器:在已登录设备上扫新设备的二维码,把它接入账号。
|
||||||
|
"qr.scan.title": "扫码登录新设备",
|
||||||
|
"qr.scan.subtitle": "把另一台设备显示的二维码对准取景框。批准全程留在这台已登录设备上。",
|
||||||
|
"qr.scan.starting": "正在启动相机…",
|
||||||
|
"qr.scan.aiming": "把二维码对准取景框",
|
||||||
|
"qr.scan.foreign": "这不是本站的登录二维码。请对准新设备上“扫码登录此设备”生成的码。",
|
||||||
|
"qr.scan.noCamera": "这台设备没有可用的摄像头。请改用带相机的手机或平板扫码。",
|
||||||
|
"qr.scan.denied": "相机权限被拒绝。请在浏览器站点设置里允许使用相机,再重新打开此页。",
|
||||||
|
"qr.scan.error": "无法打开相机。请重试,或改用带相机的设备扫码。",
|
||||||
|
"qr.scan.howto1": "在新设备上打开“扫码登录此设备”,生成一张二维码。",
|
||||||
|
"qr.scan.howto2": "把那张二维码对准上方取景框,识别后会自动进入批准。",
|
||||||
|
"qr.scan.back": "返回",
|
||||||
|
|
||||||
// ---- 扫码登录 · 显码页(新设备)-------------------------------------
|
// ---- 扫码登录 · 显码页(新设备)-------------------------------------
|
||||||
"qr.show.title": "扫码登录此设备",
|
"qr.show.title": "扫码登录此设备",
|
||||||
@@ -107,20 +126,23 @@ export const zhCN = {
|
|||||||
"qr.show.expired": "二维码已过期。重新生成一张再扫。",
|
"qr.show.expired": "二维码已过期。重新生成一张再扫。",
|
||||||
"qr.show.denied": "这次接入被拒绝。如确为你本人操作,重新生成二维码再试。",
|
"qr.show.denied": "这次接入被拒绝。如确为你本人操作,重新生成二维码再试。",
|
||||||
"qr.show.regenerate": "重新生成二维码",
|
"qr.show.regenerate": "重新生成二维码",
|
||||||
|
"qr.show.back": "返回主页",
|
||||||
|
"qr.show.backToName": "返回上一步",
|
||||||
|
|
||||||
// ---- 扫码登录 · 批准页(手机)-------------------------------------
|
// ---- 扫码登录 · 批准页(手机)-------------------------------------
|
||||||
"qr.approve.title": "批准新设备",
|
"qr.approve.title": "批准新设备",
|
||||||
"qr.approve.subtitle": "确认让这台设备登入你的账号。请核对设备与来源无误。",
|
"qr.approve.subtitle": "确认让这台设备登录你的账号。请核对设备与来源无误。",
|
||||||
"qr.approve.loading": "正在读取设备信息…",
|
"qr.approve.loading": "正在读取设备信息…",
|
||||||
"qr.approve.badLink": "这个批准链接不完整。请在新设备上重新生成二维码再扫。",
|
"qr.approve.badLink": "这个批准链接不完整。请在新设备上重新生成二维码再扫。",
|
||||||
"qr.approve.signInFirst": "先登录你的账号,再批准这台设备接入。",
|
"qr.approve.signInFirst": "先登录你的账号,再批准这台设备接入。",
|
||||||
"qr.approve.trustLabel": "信任时长",
|
"qr.approve.trustLabel": "信任时长",
|
||||||
"qr.approve.trust.title": "信任此设备",
|
"qr.approve.trust.title": "信任此设备(7 天)",
|
||||||
"qr.approve.trust.hint": "7 天内免再次批准,每次使用自动续期。",
|
"qr.approve.trust.hint": "7 天内免再次批准,每次使用自动续期。",
|
||||||
"qr.approve.once.title": "仅此一次",
|
"qr.approve.once.title": "仅此一次(1 小时)",
|
||||||
"qr.approve.once.hint": "登录有效 1 小时,到期需重新扫码。",
|
"qr.approve.once.hint": "登录有效 1 小时,到期需重新扫码。",
|
||||||
"qr.approve.scopeGuest": "这台设备将以受限访客身份登入:可收发文件与消息,但不能更改账号、不能批准其他设备。",
|
"qr.approve.scopeFull": "这台设备将完整登录你的账号、7 天内有效:可正常收发文件与消息,并能管理账号与设备。",
|
||||||
"qr.approve.approve": "批准登入",
|
"qr.approve.scopeGuest": "以受限访客身份登录——可收发文件与消息,但不能更改账号、不能批准其他设备;1 小时后失效。",
|
||||||
|
"qr.approve.approve": "批准登录",
|
||||||
"qr.approve.stepUp.notice": "为安全起见,批准前需先验证你的身份——点下方按钮会引导你重新登录一次。",
|
"qr.approve.stepUp.notice": "为安全起见,批准前需先验证你的身份——点下方按钮会引导你重新登录一次。",
|
||||||
"qr.approve.stepUp.button": "验证并批准",
|
"qr.approve.stepUp.button": "验证并批准",
|
||||||
"qr.approve.stepUp.rejected": "身份验证未通过或已过期。请再验证一次,确认是你本人后即可批准。",
|
"qr.approve.stepUp.rejected": "身份验证未通过或已过期。请再验证一次,确认是你本人后即可批准。",
|
||||||
@@ -128,7 +150,7 @@ export const zhCN = {
|
|||||||
"qr.approve.doneApprovedTitle": "已批准",
|
"qr.approve.doneApprovedTitle": "已批准",
|
||||||
"qr.approve.doneApproved": "这台设备已接入你的账号,现在即可在它上面收发文件。",
|
"qr.approve.doneApproved": "这台设备已接入你的账号,现在即可在它上面收发文件。",
|
||||||
"qr.approve.doneDeniedTitle": "已拒绝",
|
"qr.approve.doneDeniedTitle": "已拒绝",
|
||||||
"qr.approve.doneDenied": "这次接入已被拒绝,那台设备不会登入你的账号。",
|
"qr.approve.doneDenied": "这次接入已被拒绝,那台设备不会登录你的账号。",
|
||||||
"qr.approve.expiredTitle": "二维码已过期",
|
"qr.approve.expiredTitle": "二维码已过期",
|
||||||
"qr.approve.expired": "这张二维码已失效。请在新设备上重新生成再扫。",
|
"qr.approve.expired": "这张二维码已失效。请在新设备上重新生成再扫。",
|
||||||
"qr.approve.errorTitle": "出错了",
|
"qr.approve.errorTitle": "出错了",
|
||||||
@@ -148,16 +170,38 @@ export const zhCN = {
|
|||||||
"settings.unregister.currentHint": "将退出登录并移除此设备的注册。",
|
"settings.unregister.currentHint": "将退出登录并移除此设备的注册。",
|
||||||
"settings.unregister.currentConfirm":
|
"settings.unregister.currentConfirm":
|
||||||
"注销当前设备会移除此设备的注册并退出登录,确认继续?",
|
"注销当前设备会移除此设备的注册并退出登录,确认继续?",
|
||||||
"settings.unregister.peerConfirm": "确认要移除设备“{{name}}”?",
|
|
||||||
"settings.peers.title": "其他设备",
|
|
||||||
"settings.peers.empty": "暂无其他设备。",
|
|
||||||
"settings.peers.remove": "移除",
|
|
||||||
"settings.peers.removing": "正在移除…",
|
|
||||||
"settings.error.delete": "操作失败:{{message}}",
|
"settings.error.delete": "操作失败:{{message}}",
|
||||||
|
|
||||||
|
// ---- 受限访客标识 ----------------------------------------------------
|
||||||
|
"settings.guest.badge": "受限访客",
|
||||||
|
"settings.guest.title": "这台设备是受限访客",
|
||||||
|
"settings.guest.body": "本设备以受限访客身份登录:可正常收发文件、消息与剪贴板,但不能管理账号、移除设备或授权新设备。需要完整权限,请在你的主设备上重新登录。",
|
||||||
|
|
||||||
|
// ---- 登录会话 / 设备管理 ---------------------------------------------
|
||||||
|
"settings.sessions.title": "登录会话",
|
||||||
|
"settings.sessions.hint": "这里列出当前登录此账号的所有设备,含浏览器、桌面与移动客户端;在线的排在上方。登出某台设备会立即结束它的登录,需在该设备上重新登录才能继续使用。",
|
||||||
|
"settings.sessions.guestNote": "受限访客无法管理登录会话;如需管理,请在你的主设备上完整登录。",
|
||||||
|
"settings.sessions.empty": "暂无其他登录会话。",
|
||||||
|
"settings.sessions.loadError": "登录会话加载失败。",
|
||||||
|
"settings.sessions.retry": "重试",
|
||||||
|
"settings.sessions.current": "本机",
|
||||||
|
"settings.sessions.scopeFull": "完整",
|
||||||
|
"settings.sessions.scopeGuest": "受限访客",
|
||||||
|
"settings.sessions.kind.oidc": "账号登录",
|
||||||
|
"settings.sessions.kind.self": "本地账号",
|
||||||
|
"settings.sessions.kind.guest": "扫码访客",
|
||||||
|
"settings.sessions.lastUsed": "最近活跃 {{time}}",
|
||||||
|
"settings.sessions.neverUsed": "尚未活跃",
|
||||||
|
"settings.sessions.signOut": "登出",
|
||||||
|
"settings.sessions.signingOut": "正在登出…",
|
||||||
|
"settings.sessions.signOutCurrent": "登出本机",
|
||||||
|
"settings.sessions.confirmOther": "确认登出设备“{{name}}”?该会话将立即失效,需重新登录。",
|
||||||
|
"settings.sessions.confirmCurrent": "登出本机会等同于退出登录,确认继续?",
|
||||||
|
"settings.sessions.revoked": "已登出该设备",
|
||||||
|
"settings.sessions.stepUpRejected": "身份验证未通过或已过期,请再试一次。",
|
||||||
"settings.online": "在线",
|
"settings.online": "在线",
|
||||||
"settings.offline": "离线",
|
"settings.offline": "离线",
|
||||||
"settings.lastSeen": "上次活跃 {{time}}",
|
"settings.lastSeen": "上次活跃 {{time}}",
|
||||||
"settings.lastSeenNever": "尚未上线",
|
|
||||||
"settings.push.title": "推送通知",
|
"settings.push.title": "推送通知",
|
||||||
"settings.push.hint": "页面关闭时,新文件、消息和传输结果会以系统通知提醒;页面打开时仍走应用内提示。",
|
"settings.push.hint": "页面关闭时,新文件、消息和传输结果会以系统通知提醒;页面打开时仍走应用内提示。",
|
||||||
"settings.push.enable": "启用推送",
|
"settings.push.enable": "启用推送",
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export const zhTW: Partial<TranslationDict> = {
|
|||||||
"app.disconnectedTitle": "正在重新連線",
|
"app.disconnectedTitle": "正在重新連線",
|
||||||
"app.disconnectedHint": "與伺服器的連線已中斷,正在自動重新連線,無需重新整理頁面。",
|
"app.disconnectedHint": "與伺服器的連線已中斷,正在自動重新連線,無需重新整理頁面。",
|
||||||
|
|
||||||
|
// ---- 工作階段失效(被遠端撤銷 / 逾期)-------------------------------
|
||||||
|
"auth.sessionLost.title": "登入已失效",
|
||||||
|
"auth.sessionLost.body": "此裝置的登入憑證已被撤銷或逾期,請重新登入。",
|
||||||
|
|
||||||
// ---- 頂部列 / 使用者選單 ---------------------------------------------
|
// ---- 頂部列 / 使用者選單 ---------------------------------------------
|
||||||
"nav.accountMenu": "帳號選單",
|
"nav.accountMenu": "帳號選單",
|
||||||
"nav.thisDeviceLabel": "本機:",
|
"nav.thisDeviceLabel": "本機:",
|
||||||
@@ -36,6 +40,7 @@ export const zhTW: Partial<TranslationDict> = {
|
|||||||
"home.deviceList.title": "裝置",
|
"home.deviceList.title": "裝置",
|
||||||
"home.deviceList.onlineSuffix": "({{count}} 部線上)",
|
"home.deviceList.onlineSuffix": "({{count}} 部線上)",
|
||||||
"home.deviceList.showOffline": "顯示離線裝置({{count}})",
|
"home.deviceList.showOffline": "顯示離線裝置({{count}})",
|
||||||
|
"home.deviceList.hideOffline": "隱藏離線裝置",
|
||||||
"home.deviceList.empty":
|
"home.deviceList.empty":
|
||||||
"尚無其他裝置。在另一個分頁或裝置上以同一帳號登入,並設定一個不同的裝置名稱(設定 → 裝置名稱),即可在此處看到它。",
|
"尚無其他裝置。在另一個分頁或裝置上以同一帳號登入,並設定一個不同的裝置名稱(設定 → 裝置名稱),即可在此處看到它。",
|
||||||
"home.tabs.file": "檔案",
|
"home.tabs.file": "檔案",
|
||||||
@@ -97,6 +102,20 @@ export const zhTW: Partial<TranslationDict> = {
|
|||||||
|
|
||||||
// ---- 掃碼登入 · 入口 -------------------------------------------------
|
// ---- 掃碼登入 · 入口 -------------------------------------------------
|
||||||
"qr.entry.fromLogin": "掃碼登入此裝置",
|
"qr.entry.fromLogin": "掃碼登入此裝置",
|
||||||
|
"qr.entry.scanNewDevice": "掃碼登入新裝置",
|
||||||
|
|
||||||
|
// 應用內掃碼器:在已登入裝置上掃新裝置的 QR 碼,把它接入帳號。
|
||||||
|
"qr.scan.title": "掃碼登入新裝置",
|
||||||
|
"qr.scan.subtitle": "把另一部裝置顯示的 QR 碼對準取景框。批准全程留在這部已登入裝置上。",
|
||||||
|
"qr.scan.starting": "正在啟動相機…",
|
||||||
|
"qr.scan.aiming": "把 QR 碼對準取景框",
|
||||||
|
"qr.scan.foreign": "這不是本站的登入 QR 碼。請對準新裝置上「掃碼登入此裝置」產生的碼。",
|
||||||
|
"qr.scan.noCamera": "這部裝置沒有可用的相機。請改用有相機的手機或平板掃碼。",
|
||||||
|
"qr.scan.denied": "相機權限被拒絕。請在瀏覽器網站設定裡允許使用相機,再重新開啟此頁。",
|
||||||
|
"qr.scan.error": "無法開啟相機。請重試,或改用有相機的裝置掃碼。",
|
||||||
|
"qr.scan.howto1": "在新裝置上開啟「掃碼登入此裝置」,產生一張 QR 碼。",
|
||||||
|
"qr.scan.howto2": "把那張 QR 碼對準上方取景框,辨識後會自動進入批准。",
|
||||||
|
"qr.scan.back": "返回",
|
||||||
|
|
||||||
// ---- 掃碼登入 · 顯碼頁(新裝置)-------------------------------------
|
// ---- 掃碼登入 · 顯碼頁(新裝置)-------------------------------------
|
||||||
"qr.show.title": "掃碼登入此裝置",
|
"qr.show.title": "掃碼登入此裝置",
|
||||||
@@ -111,6 +130,8 @@ export const zhTW: Partial<TranslationDict> = {
|
|||||||
"qr.show.expired": "QR 碼已逾時。重新產生一張再掃。",
|
"qr.show.expired": "QR 碼已逾時。重新產生一張再掃。",
|
||||||
"qr.show.denied": "這次接入被拒絕。如確為你本人操作,重新產生 QR 碼再試。",
|
"qr.show.denied": "這次接入被拒絕。如確為你本人操作,重新產生 QR 碼再試。",
|
||||||
"qr.show.regenerate": "重新產生 QR 碼",
|
"qr.show.regenerate": "重新產生 QR 碼",
|
||||||
|
"qr.show.back": "返回首頁",
|
||||||
|
"qr.show.backToName": "返回上一步",
|
||||||
|
|
||||||
// ---- 掃碼登入 · 批准頁(手機)-------------------------------------
|
// ---- 掃碼登入 · 批准頁(手機)-------------------------------------
|
||||||
"qr.approve.title": "批准新裝置",
|
"qr.approve.title": "批准新裝置",
|
||||||
@@ -119,11 +140,12 @@ export const zhTW: Partial<TranslationDict> = {
|
|||||||
"qr.approve.badLink": "這個批准連結不完整。請在新裝置上重新產生 QR 碼再掃。",
|
"qr.approve.badLink": "這個批准連結不完整。請在新裝置上重新產生 QR 碼再掃。",
|
||||||
"qr.approve.signInFirst": "先登入你的帳號,再批准這部裝置接入。",
|
"qr.approve.signInFirst": "先登入你的帳號,再批准這部裝置接入。",
|
||||||
"qr.approve.trustLabel": "信任時長",
|
"qr.approve.trustLabel": "信任時長",
|
||||||
"qr.approve.trust.title": "信任此裝置",
|
"qr.approve.trust.title": "信任此裝置(7 天)",
|
||||||
"qr.approve.trust.hint": "7 天內免再次批准,每次使用自動續期。",
|
"qr.approve.trust.hint": "7 天內免再次批准,每次使用自動續期。",
|
||||||
"qr.approve.once.title": "僅此一次",
|
"qr.approve.once.title": "僅此一次(1 小時)",
|
||||||
"qr.approve.once.hint": "登入有效 1 小時,逾時需重新掃碼。",
|
"qr.approve.once.hint": "登入有效 1 小時,逾時需重新掃碼。",
|
||||||
"qr.approve.scopeGuest": "這部裝置將以受限訪客身分登入:可收發檔案與訊息,但不能變更帳號、不能批准其他裝置。",
|
"qr.approve.scopeFull": "這部裝置將完整登入你的帳號、7 天內有效:可正常收發檔案與訊息,並能管理帳號與裝置。",
|
||||||
|
"qr.approve.scopeGuest": "以受限訪客身分登入——可收發檔案與訊息,但不能變更帳號、不能批准其他裝置;1 小時後失效。",
|
||||||
"qr.approve.approve": "批准登入",
|
"qr.approve.approve": "批准登入",
|
||||||
"qr.approve.stepUp.notice": "為安全起見,批准前需先驗證你的身分——點下方按鈕會引導你重新登入一次。",
|
"qr.approve.stepUp.notice": "為安全起見,批准前需先驗證你的身分——點下方按鈕會引導你重新登入一次。",
|
||||||
"qr.approve.stepUp.button": "驗證並批准",
|
"qr.approve.stepUp.button": "驗證並批准",
|
||||||
@@ -152,16 +174,38 @@ export const zhTW: Partial<TranslationDict> = {
|
|||||||
"settings.unregister.currentHint": "將登出並移除本裝置的註冊。",
|
"settings.unregister.currentHint": "將登出並移除本裝置的註冊。",
|
||||||
"settings.unregister.currentConfirm":
|
"settings.unregister.currentConfirm":
|
||||||
"解除註冊本裝置會移除其註冊並登出,確認繼續?",
|
"解除註冊本裝置會移除其註冊並登出,確認繼續?",
|
||||||
"settings.unregister.peerConfirm": "確認要移除裝置「{{name}}」?",
|
|
||||||
"settings.peers.title": "其他裝置",
|
|
||||||
"settings.peers.empty": "尚無其他裝置。",
|
|
||||||
"settings.peers.remove": "移除",
|
|
||||||
"settings.peers.removing": "正在移除…",
|
|
||||||
"settings.error.delete": "操作失敗:{{message}}",
|
"settings.error.delete": "操作失敗:{{message}}",
|
||||||
|
|
||||||
|
// ---- 受限訪客標識 ----------------------------------------------------
|
||||||
|
"settings.guest.badge": "受限訪客",
|
||||||
|
"settings.guest.title": "這部裝置是受限訪客",
|
||||||
|
"settings.guest.body": "本裝置以受限訪客身分登入:可正常收發檔案、訊息與剪貼簿,但不能管理帳號、移除裝置或授權新裝置。需要完整權限,請在你的主裝置上重新登入。",
|
||||||
|
|
||||||
|
// ---- 登入工作階段 / 裝置管理 ------------------------------------------
|
||||||
|
"settings.sessions.title": "登入工作階段",
|
||||||
|
"settings.sessions.hint": "這裡列出目前登入此帳號的所有裝置,含瀏覽器、桌面與行動用戶端;線上的排在上方。登出某台裝置會立即結束它的登入,需在該裝置上重新登入才能繼續使用。",
|
||||||
|
"settings.sessions.guestNote": "受限訪客無法管理登入工作階段;如需管理,請在你的主裝置上完整登入。",
|
||||||
|
"settings.sessions.empty": "尚無其他登入工作階段。",
|
||||||
|
"settings.sessions.loadError": "登入工作階段載入失敗。",
|
||||||
|
"settings.sessions.retry": "重試",
|
||||||
|
"settings.sessions.current": "本機",
|
||||||
|
"settings.sessions.scopeFull": "完整",
|
||||||
|
"settings.sessions.scopeGuest": "受限訪客",
|
||||||
|
"settings.sessions.kind.oidc": "帳號登入",
|
||||||
|
"settings.sessions.kind.self": "本機帳號",
|
||||||
|
"settings.sessions.kind.guest": "掃碼訪客",
|
||||||
|
"settings.sessions.lastUsed": "最近活躍 {{time}}",
|
||||||
|
"settings.sessions.neverUsed": "尚未活躍",
|
||||||
|
"settings.sessions.signOut": "登出",
|
||||||
|
"settings.sessions.signingOut": "正在登出…",
|
||||||
|
"settings.sessions.signOutCurrent": "登出本機",
|
||||||
|
"settings.sessions.confirmOther": "確認登出裝置「{{name}}」?該工作階段將立即失效,需重新登入。",
|
||||||
|
"settings.sessions.confirmCurrent": "登出本機等同於登出帳號,確認繼續?",
|
||||||
|
"settings.sessions.revoked": "已登出該裝置",
|
||||||
|
"settings.sessions.stepUpRejected": "身分驗證未通過或已過期,請再試一次。",
|
||||||
"settings.online": "線上",
|
"settings.online": "線上",
|
||||||
"settings.offline": "離線",
|
"settings.offline": "離線",
|
||||||
"settings.lastSeen": "上次活躍 {{time}}",
|
"settings.lastSeen": "上次活躍 {{time}}",
|
||||||
"settings.lastSeenNever": "尚未上線",
|
|
||||||
"settings.push.title": "推播通知",
|
"settings.push.title": "推播通知",
|
||||||
"settings.push.hint": "頁面關閉時,新檔案、訊息與傳輸結果會以系統通知提醒;頁面開啟時仍走應用內提示。",
|
"settings.push.hint": "頁面關閉時,新檔案、訊息與傳輸結果會以系統通知提醒;頁面開啟時仍走應用內提示。",
|
||||||
"settings.push.enable": "啟用推播",
|
"settings.push.enable": "啟用推播",
|
||||||
|
|||||||
+19
-3
@@ -1,5 +1,5 @@
|
|||||||
import { useAppStore } from "../store";
|
import { useAppStore } from "../store";
|
||||||
import { refreshTokens } from "../features/auth/auth";
|
import { refreshTokens, forceLogout } from "../features/auth/auth";
|
||||||
import { readInjectedApiBase, readInjectedDeviceType } from "../store/helpers";
|
import { readInjectedApiBase, readInjectedDeviceType } from "../store/helpers";
|
||||||
import { toAsciiDeviceName } from "../utils/format";
|
import { toAsciiDeviceName } from "../utils/format";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -26,19 +26,35 @@ function withBase(input: RequestInfo): RequestInfo
|
|||||||
// - always sets X-Device-Name from the store
|
// - always sets X-Device-Name from the store
|
||||||
// - in prod, on a 401 response transparently refreshes the access token via
|
// - in prod, on a 401 response transparently refreshes the access token via
|
||||||
// /api/auth/refresh and retries once
|
// /api/auth/refresh and retries once
|
||||||
|
// - in prod, ONLY if the server authoritatively confirms the session is gone
|
||||||
|
// (refresh → HTTP 401), forces a local logout so the app returns to login
|
||||||
//
|
//
|
||||||
// Returns the fetch Response untouched so callers can drive streaming bodies
|
// Returns the fetch Response untouched so callers can drive streaming bodies
|
||||||
// (SSE, relay GET stream) themselves.
|
// (SSE, relay GET stream) themselves.
|
||||||
|
//
|
||||||
|
// State is cleared ONLY on server-confirmed credential invalidation. Short-term
|
||||||
|
// failures never clear it: a transient refresh response (5xx / same-origin 403)
|
||||||
|
// yields "transient", and a network error during refresh THROWS out of here —
|
||||||
|
// both keep the session, leaving the original 401 for the caller as a retryable
|
||||||
|
// error. So a flaky network or a server blip can't log the user out; only a
|
||||||
|
// genuine revoke/expire (refresh → 401) does.
|
||||||
export async function apiFetch(input: RequestInfo, init?: RequestInit): Promise<Response>
|
export async function apiFetch(input: RequestInfo, init?: RequestInit): Promise<Response>
|
||||||
{
|
{
|
||||||
let r = await doFetch(input, init);
|
let r = await doFetch(input, init);
|
||||||
if (r.status === 401 && useAppStore.getState().authMode === "prod")
|
if (r.status === 401 && useAppStore.getState().authMode === "prod")
|
||||||
{
|
{
|
||||||
const ok = await refreshTokens();
|
const outcome = await refreshTokens();
|
||||||
if (ok)
|
if (outcome === "refreshed")
|
||||||
{
|
{
|
||||||
r = await doFetch(input, init);
|
r = await doFetch(input, init);
|
||||||
}
|
}
|
||||||
|
else if (outcome === "invalid")
|
||||||
|
{
|
||||||
|
// 服务端确证会话已失效(/auth/refresh 返回 401,已被吊销 / 过期)→
|
||||||
|
// 清空本地登录态,由 __root 守卫把界面送回登录页。返回这次 401 让调用
|
||||||
|
// 方照常收尾。"transient"(5xx / 网络抖动)不进此分支:保留登录态。
|
||||||
|
forceLogout();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-2
@@ -217,8 +217,9 @@ export class StepUpRequiredError extends Error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// qrApprove 批准该设备登入本账号。scope 本期固定 guest(受限访客);persist 决定
|
// qrApprove 批准该设备登入本账号。scope 决定授予的会话级别:full=完整登录、
|
||||||
// 信任时长(persist=7 天滑动 / once=1 小时)。成功 204。
|
// guest=受限访客;与 persist 时长一一对应(full+persist=7 天滑动、guest+once=1 小时)。
|
||||||
|
// 成功 204。
|
||||||
//
|
//
|
||||||
// step_up 开启时须带上一次新鲜 prompt=login 再认证拿到的 stepUpCode + stepUpVerifier,
|
// step_up 开启时须带上一次新鲜 prompt=login 再认证拿到的 stepUpCode + stepUpVerifier,
|
||||||
// 后端就地向 IdP 换 id_token、验签 + 校 auth_time 新鲜 + sub 匹配;缺失 / 过期 →
|
// 后端就地向 IdP 换 id_token、验签 + 校 auth_time 新鲜 + sub 匹配;缺失 / 过期 →
|
||||||
@@ -278,3 +279,34 @@ function sleep(ms: number): Promise<void>
|
|||||||
{
|
{
|
||||||
return new Promise((resolve) => { window.setTimeout(resolve, ms); });
|
return new Promise((resolve) => { window.setTimeout(resolve, ms); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 应用内扫码:批准链接解析 --------------------------------------------------
|
||||||
|
|
||||||
|
export interface LinkParams
|
||||||
|
{
|
||||||
|
requestId: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLinkApprovalUrl 把扫到的二维码内容解析成批准页参数,严格校验后才放行——
|
||||||
|
// 这是应用内扫码器的安全闸门,决定扫到的码能否在已登录会话内被导航。
|
||||||
|
// 只接受「本站 origin + /link 路径 + 同时带 r 与 c」的 URL:
|
||||||
|
// - 同源(origin 严格等于当前页 origin)——挡开放重定向 / 钓鱼站二维码;
|
||||||
|
// - 路径恰为 /link——挡指向本站其他页的码;
|
||||||
|
// - r/c 皆非空——挡残缺码。
|
||||||
|
// 任一不满足返回 null(调用方据此提示「继续对准」,不导航)。
|
||||||
|
export function parseLinkApprovalUrl(raw: string): LinkParams | null
|
||||||
|
{
|
||||||
|
let url: URL;
|
||||||
|
try { url = new URL(raw); }
|
||||||
|
catch { return null; }
|
||||||
|
|
||||||
|
if (url.origin !== window.location.origin) { return null; }
|
||||||
|
if (url.pathname !== "/link") { return null; }
|
||||||
|
|
||||||
|
const requestId = url.searchParams.get("r");
|
||||||
|
const code = url.searchParams.get("c");
|
||||||
|
if (!requestId || !code) { return null; }
|
||||||
|
|
||||||
|
return { requestId, code };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { apiFetch, apiJSON } from "./api";
|
||||||
|
|
||||||
|
// 登录会话 / 设备的网络封装。全部走 apiFetch(带 Bearer + 401 自动续期);
|
||||||
|
// refresh_token 永不进 JS——吊销由后端令短 TTL 的 access_token 随之失效完成。
|
||||||
|
|
||||||
|
// ---- /api/me:本会话权限级别 ------------------------------------------------
|
||||||
|
|
||||||
|
export type SessionScopeValue = "full" | "guest";
|
||||||
|
|
||||||
|
interface MeRaw
|
||||||
|
{
|
||||||
|
// /api/me 现回传本会话的 scope(full=完整登录、guest=受限访客)。旧后端 / 异常
|
||||||
|
// 缺字段时按 "full" 解析(只多展示入口,后端仍 403 兜底)。
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchMeScope 拉取 /api/me 并解析本会话权限级别。失败 / 缺字段回退 "full"。
|
||||||
|
export async function fetchMeScope(): Promise<SessionScopeValue>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const data = await apiJSON<MeRaw>("/api/me");
|
||||||
|
return data.scope === "guest" ? "guest" : "full";
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "full";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 登录会话列表 / 吊销 / step-up ------------------------------------------
|
||||||
|
|
||||||
|
// 会话「种类」:oidc / self / guest 为网页 / 扫码会话;macos / windows / linux / ios
|
||||||
|
// 为原生客户端(桌面 / 移动)——它们用 IdP 令牌、不入 web_sessions,由设备登记表呈现。
|
||||||
|
export type SessionKind =
|
||||||
|
| "oidc" | "self" | "guest"
|
||||||
|
| "macos" | "windows" | "linux" | "ios";
|
||||||
|
|
||||||
|
const KNOWN_KINDS: readonly SessionKind[] = [
|
||||||
|
"oidc", "self", "guest", "macos", "windows", "linux", "ios",
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface AuthSession
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
deviceName: string;
|
||||||
|
kind: SessionKind;
|
||||||
|
scope: SessionScopeValue;
|
||||||
|
// current=true:这条就是本机当前会话(登出它等价于退出登录)。
|
||||||
|
current: boolean;
|
||||||
|
// online=true:该设备当前在线(有活跃 SSE 连接)。用于列表排序与在线指示。
|
||||||
|
online: boolean;
|
||||||
|
// native=true:原生客户端(无 web_session),登出走设备登记端点而非会话端点。
|
||||||
|
native: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
lastUsedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthSessionRaw
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
device_name?: string;
|
||||||
|
kind?: string;
|
||||||
|
scope?: string;
|
||||||
|
current?: boolean;
|
||||||
|
online?: boolean;
|
||||||
|
native?: boolean;
|
||||||
|
created_at?: number;
|
||||||
|
last_used_at?: number;
|
||||||
|
expires_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionsResp
|
||||||
|
{
|
||||||
|
sessions: AuthSessionRaw[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKind(kind: string | undefined): SessionKind
|
||||||
|
{
|
||||||
|
return KNOWN_KINDS.includes(kind as SessionKind) ? (kind as SessionKind) : "oidc";
|
||||||
|
}
|
||||||
|
|
||||||
|
// listSessions 拉取当前账号下的全部登录会话(含本机),每条带 online(当前是否在线)。
|
||||||
|
// 受限访客无权管理登录会话、后端会 403,故调用方(SessionsPanel)在 guest 时根本不发
|
||||||
|
// 此请求、直接展示克制说明,不会走到这里。
|
||||||
|
export async function listSessions(): Promise<AuthSession[]>
|
||||||
|
{
|
||||||
|
const data = await apiJSON<SessionsResp>("/api/auth/sessions");
|
||||||
|
const rows = Array.isArray(data.sessions) ? data.sessions : [];
|
||||||
|
return rows.map((r) => (
|
||||||
|
{
|
||||||
|
id: r.id,
|
||||||
|
deviceName: r.device_name ?? "",
|
||||||
|
kind: normalizeKind(r.kind),
|
||||||
|
scope: r.scope === "guest" ? "guest" : "full",
|
||||||
|
current: r.current === true,
|
||||||
|
online: r.online === true,
|
||||||
|
native: r.native === true,
|
||||||
|
createdAt: r.created_at ?? 0,
|
||||||
|
lastUsedAt: r.last_used_at ?? 0,
|
||||||
|
expiresAt: r.expires_at ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepUpRequiredError:DELETE /api/auth/sessions/{id} 因再认证缺失 / 过期被后端拒
|
||||||
|
// (403 {error:"step_up_required"})。调用方据此发起一次 step-up 再认证而非当成
|
||||||
|
// 普通错误。
|
||||||
|
export class StepUpRequiredError extends Error
|
||||||
|
{
|
||||||
|
constructor()
|
||||||
|
{
|
||||||
|
super("step_up_required");
|
||||||
|
this.name = "StepUpRequiredError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokeSession 真正吊销一条登录会话。网页 / 扫码会话走 /auth/sessions/{id}(删会话
|
||||||
|
// 行 + 连带删设备);原生客户端(id 形如 "device:设备名")无 web_session,改走
|
||||||
|
// /devices/{name} 按设备名注销。两者均:成功 204;后端要求再认证时抛
|
||||||
|
// StepUpRequiredError(403 step_up_required),其余非 2xx 抛普通错误。
|
||||||
|
const NATIVE_ID_PREFIX = "device:";
|
||||||
|
|
||||||
|
export async function revokeSession(id: string): Promise<void>
|
||||||
|
{
|
||||||
|
const path = id.startsWith(NATIVE_ID_PREFIX)
|
||||||
|
? `/api/devices/${encodeURIComponent(id.slice(NATIVE_ID_PREFIX.length))}`
|
||||||
|
: `/api/auth/sessions/${encodeURIComponent(id)}`;
|
||||||
|
const r = await apiFetch(path, { method: "DELETE" });
|
||||||
|
if (!r.ok)
|
||||||
|
{
|
||||||
|
const text = await r.text().catch(() => r.statusText);
|
||||||
|
if (r.status === 403 && /step_up_required/.test(text))
|
||||||
|
{
|
||||||
|
throw new StepUpRequiredError();
|
||||||
|
}
|
||||||
|
throw new Error(`revoke session ${r.status}: ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// postStepUp 把一次新鲜 prompt=login 再认证拿到的 {code, verifier} 交给后端就地
|
||||||
|
// 核验,成功后服务端在本会话记下 stepped_up_at(5 分钟窗口内复用,不再反复要求)。
|
||||||
|
// 成功 204;step-up 未开后端亦 204(视作通过);失败 403。
|
||||||
|
export async function postStepUp(code: string, verifier: string): Promise<void>
|
||||||
|
{
|
||||||
|
const r = await apiFetch("/api/auth/stepup", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code, verifier }),
|
||||||
|
});
|
||||||
|
if (!r.ok)
|
||||||
|
{
|
||||||
|
const text = await r.text().catch(() => r.statusText);
|
||||||
|
throw new Error(`step-up ${r.status}: ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,14 +11,14 @@ import { Outlet, Link, createRootRoute, useLocation, useNavigate } from "@tansta
|
|||||||
import { Check, ChevronDown, LogOut, Monitor, Moon, Settings, Sun, Wifi, WifiOff } from "lucide-react";
|
import { Check, ChevronDown, LogOut, Monitor, Moon, Settings, Sun, Wifi, WifiOff } from "lucide-react";
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { useAppStore, type ThemeMode } from "../store";
|
import { useAppStore, type ThemeMode } from "../store";
|
||||||
import { logout } from "../features/auth/auth";
|
import { logout, refreshSessionScope } from "../features/auth/auth";
|
||||||
import { uploadClipboard } from "../features/clipboard/clipboard";
|
import { uploadClipboard } from "../features/clipboard/clipboard";
|
||||||
import { onNativeClipboard } from "../net/desktop";
|
import { onNativeClipboard } from "../net/desktop";
|
||||||
import { startHub } from "../features/transfer/hub";
|
import { startHub } from "../features/transfer/hub";
|
||||||
import { resyncPush } from "../net/push";
|
import { resyncPush } from "../net/push";
|
||||||
import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers";
|
import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers";
|
||||||
import { t, type Locale } from "../i18n";
|
import { t, type Locale } from "../i18n";
|
||||||
import { Callout, IconButton, Tooltip } from "../ui/primitives";
|
import { Badge, Callout, IconButton, Tooltip } from "../ui/primitives";
|
||||||
import { Wordmark } from "../ui/brand";
|
import { Wordmark } from "../ui/brand";
|
||||||
|
|
||||||
export const Route = createRootRoute({ component: RootLayout });
|
export const Route = createRootRoute({ component: RootLayout });
|
||||||
@@ -26,6 +26,8 @@ export const Route = createRootRoute({ component: RootLayout });
|
|||||||
function RootLayout()
|
function RootLayout()
|
||||||
{
|
{
|
||||||
const user = useAppStore((s) => s.user);
|
const user = useAppStore((s) => s.user);
|
||||||
|
const authMode = useAppStore((s) => s.authMode);
|
||||||
|
const sessionScope = useAppStore((s) => s.sessionScope);
|
||||||
const selfDeviceName = useAppStore((s) => s.selfDeviceName);
|
const selfDeviceName = useAppStore((s) => s.selfDeviceName);
|
||||||
const sseConnected = useAppStore((s) => s.sseConnected);
|
const sseConnected = useAppStore((s) => s.sseConnected);
|
||||||
const sseReconnecting = useAppStore((s) => s.sseReconnecting);
|
const sseReconnecting = useAppStore((s) => s.sseReconnecting);
|
||||||
@@ -39,6 +41,19 @@ function RootLayout()
|
|||||||
// 这里只渲染 <Outlet/>,不包 AppShell,从而隐去顶栏。
|
// 这里只渲染 <Outlet/>,不包 AppShell,从而隐去顶栏。
|
||||||
const fullscreen = isAuthRoute(location.pathname);
|
const fullscreen = isAuthRoute(location.pathname);
|
||||||
|
|
||||||
|
// 反应式登出守卫:当登录态在运行中被清空(access token 被远程吊销 / 过期 →
|
||||||
|
// apiFetch 触发 forceLogout,见 net/api.ts),把界面送回登录页。路由的
|
||||||
|
// beforeLoad 只在导航时跑,停在当前页时不会自动重定向,故这里补一道反应式跳转。
|
||||||
|
// 仅 prod:dev 无远程吊销概念,登录态由 loginDev 同步建立。已在认证类路由上则
|
||||||
|
// 不跳(避免与 /login 自身、扫码显码 / 批准页打架)。
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if (authMode !== "prod") { return; }
|
||||||
|
if (user) { return; }
|
||||||
|
if (isAuthRoute(location.pathname)) { return; }
|
||||||
|
navigate({ to: "/login", search: { dev_user: undefined } });
|
||||||
|
}, [ authMode, user, location.pathname, navigate ]);
|
||||||
|
|
||||||
// SSE 在登录后立即开启;user.id 或 selfDeviceName 任一变化即重连
|
// SSE 在登录后立即开启;user.id 或 selfDeviceName 任一变化即重连
|
||||||
// (改名后会自动以新设备名重新注册)。
|
// (改名后会自动以新设备名重新注册)。
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
@@ -46,6 +61,9 @@ function RootLayout()
|
|||||||
if (!user || !selfDeviceName) { return; }
|
if (!user || !selfDeviceName) { return; }
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
startHub(ctrl.signal);
|
startHub(ctrl.signal);
|
||||||
|
// 校正本会话权限级别(/api/me 的 scope):受限访客据此隐藏账号管理入口。
|
||||||
|
// 覆盖所有登录路径(OAuth / 扫码新设备 / 开机水合 / dev),一处搞定。
|
||||||
|
void refreshSessionScope();
|
||||||
// 预取 Cloudflare TURN 凭据(或 STUN 回退),首次 WebRTC 同步可用。
|
// 预取 Cloudflare TURN 凭据(或 STUN 回退),首次 WebRTC 同步可用。
|
||||||
void refreshICEServers();
|
void refreshICEServers();
|
||||||
// 已开启推送的浏览器:登录后把订阅重新登记到当前用户名下(自愈换用户 /
|
// 已开启推送的浏览器:登录后把订阅重新登记到当前用户名下(自愈换用户 /
|
||||||
@@ -125,7 +143,12 @@ function RootLayout()
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Stack gap={2} px="sm" pt="xs" pb={6}>
|
<Stack gap={2} px="sm" pt="xs" pb={6}>
|
||||||
<Text size="sm" fw={500} truncate>{user.name}</Text>
|
<Group gap={6} wrap="nowrap" align="center">
|
||||||
|
<Text size="sm" fw={500} truncate>{user.name}</Text>
|
||||||
|
{sessionScope === "guest" && (
|
||||||
|
<Badge tone="warn">{t("settings.guest.badge")}</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{t("nav.thisDeviceLabel")}
|
{t("nav.thisDeviceLabel")}
|
||||||
<Text span fw={500}>{selfDeviceName}</Text>
|
<Text span fw={500}>{selfDeviceName}</Text>
|
||||||
@@ -316,6 +339,7 @@ function isAuthRoute(pathname: string): boolean
|
|||||||
|| pathname === "/setup"
|
|| pathname === "/setup"
|
||||||
|| pathname === "/link"
|
|| pathname === "/link"
|
||||||
|| pathname === "/link/new"
|
|| pathname === "/link/new"
|
||||||
|
|| pathname === "/link/scan"
|
||||||
|| pathname.startsWith("/oauth/");
|
|| pathname.startsWith("/oauth/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { Container } from "@mantine/core";
|
import { Container } from "@mantine/core";
|
||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||||
import { useAppStore } from "../store";
|
import { useAppStore } from "../store";
|
||||||
import { consumeLinkReturn } from "../features/qr/returnTo";
|
import { consumeLinkReturn } from "../features/qr/returnTo";
|
||||||
import { DeviceStrip } from "../features/devices/DeviceStrip";
|
import { DeviceStrip } from "../features/devices/DeviceStrip";
|
||||||
@@ -33,7 +33,9 @@ void CLIPBOARD_MAX_BYTES;
|
|||||||
|
|
||||||
function HomePage()
|
function HomePage()
|
||||||
{
|
{
|
||||||
|
const navigate = useNavigate();
|
||||||
const user = useAppStore((s) => s.user);
|
const user = useAppStore((s) => s.user);
|
||||||
|
const sessionScope = useAppStore((s) => s.sessionScope);
|
||||||
const devices = useAppStore((s) => s.devices);
|
const devices = useAppStore((s) => s.devices);
|
||||||
const selfDeviceName = useAppStore((s) => s.selfDeviceName);
|
const selfDeviceName = useAppStore((s) => s.selfDeviceName);
|
||||||
const activeTransfers = useAppStore((s) => s.activeTransfers);
|
const activeTransfers = useAppStore((s) => s.activeTransfers);
|
||||||
@@ -96,6 +98,13 @@ function HomePage()
|
|||||||
selectedDevice={selectedDevice}
|
selectedDevice={selectedDevice}
|
||||||
onSelect={setSelectedDevice}
|
onSelect={setSelectedDevice}
|
||||||
onDropFile={handleDropFile}
|
onDropFile={handleDropFile}
|
||||||
|
// 受限访客不能授权新设备:不传 onLinkDevice,DeviceStrip 据此
|
||||||
|
// 隐藏「扫码登录新设备」入口(条件渲染已在组件内)。
|
||||||
|
onLinkDevice={
|
||||||
|
sessionScope === "guest"
|
||||||
|
? undefined
|
||||||
|
: () => navigate({ to: "/link/scan" })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={s.dualColumn}>
|
<div className={s.dualColumn}>
|
||||||
|
|||||||
@@ -77,27 +77,34 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
background: var(--surface);
|
/* 未选中态明显更轻:sunken 底 + 细分隔线左条,与选中态拉开对比。 */
|
||||||
|
background: var(--surface-sunken);
|
||||||
border: 1px solid var(--divider);
|
border: 1px solid var(--divider);
|
||||||
border-left: 3px solid var(--divider);
|
border-left: 3px solid var(--divider);
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text);
|
color: var(--text-muted);
|
||||||
transition:
|
transition:
|
||||||
border-color var(--dur-fast) var(--ease-out),
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
background-color var(--dur-fast) var(--ease-out);
|
background-color var(--dur-fast) var(--ease-out),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.choice:hover { border-color: var(--divider-strong); }
|
.choice:hover { border-color: var(--divider-strong); }
|
||||||
|
|
||||||
/* 选中态:accent 左色条 + soft 底(同 Callout / Activity 的状态编码语汇)。 */
|
/* 选中态:实心 accent 边框 + 加粗左色条 + accent 描边环 + soft 底(同
|
||||||
|
DeviceStrip.portSelected 的强选中语汇),与未选中态形成明显对比。 */
|
||||||
.choiceOn
|
.choiceOn
|
||||||
{
|
{
|
||||||
background: var(--accent-softer);
|
background: var(--accent-soft);
|
||||||
border-color: var(--accent-ring);
|
border-color: var(--accent);
|
||||||
border-left-color: var(--accent);
|
border-left: 4px solid var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.choiceOn:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
.choice:focus-visible
|
.choice:focus-visible
|
||||||
{
|
{
|
||||||
outline: 2px solid var(--accent-ring);
|
outline: 2px solid var(--accent-ring);
|
||||||
@@ -130,6 +137,9 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 选中项标题着 accent 色,进一步强化「当前选的是这个」。 */
|
||||||
|
.choiceOn .choiceTitle { color: var(--accent); }
|
||||||
|
|
||||||
.choiceHint
|
.choiceHint
|
||||||
{
|
{
|
||||||
font-size: var(--fs-12);
|
font-size: var(--fs-12);
|
||||||
@@ -137,6 +147,9 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 选中项说明文字恢复常规对比(未选中时整块被压暗)。 */
|
||||||
|
.choiceOn .choiceHint { color: var(--text-muted); }
|
||||||
|
|
||||||
/* 批准 / 拒绝:批准是主色行动,拒绝是安静的 ghost。 */
|
/* 批准 / 拒绝:批准是主色行动,拒绝是安静的 ghost。 */
|
||||||
.actions
|
.actions
|
||||||
{
|
{
|
||||||
|
|||||||
+36
-11
@@ -23,6 +23,8 @@ export const Route = createFileRoute("/link")({
|
|||||||
({
|
({
|
||||||
r: typeof search.r === "string" ? search.r : undefined,
|
r: typeof search.r === "string" ? search.r : undefined,
|
||||||
c: typeof search.c === "string" ? search.c : undefined,
|
c: typeof search.c === "string" ? search.c : undefined,
|
||||||
|
// p:信任时长选择(persist/once)。step-up 整页跳转回来后据此恢复,避免选择丢失。
|
||||||
|
p: typeof search.p === "string" ? search.p : undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,12 +33,14 @@ type Outcome = "approved" | "denied" | "expired" | "error" | null;
|
|||||||
function LinkApprovePage()
|
function LinkApprovePage()
|
||||||
{
|
{
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { r: requestId, c: code } = Route.useSearch();
|
const { r: requestId, c: code, p: persistParam } = Route.useSearch();
|
||||||
const user = useAppStore((s) => s.user);
|
const user = useAppStore((s) => s.user);
|
||||||
|
|
||||||
const [ info, setInfo ] = useState<QrRequestInfo | null>(null);
|
const [ info, setInfo ] = useState<QrRequestInfo | null>(null);
|
||||||
const [ loadError, setLoadError ] = useState<string | null>(null);
|
const [ loadError, setLoadError ] = useState<string | null>(null);
|
||||||
const [ persist, setPersist ] = useState<QrPersist>("persist");
|
// 初值优先取 URL 的 p(step-up 跳转回来后恢复选择),否则默认更安全的「仅此次」
|
||||||
|
// (受限访客)——避免一路默认到完整会话(full)。
|
||||||
|
const [ persist, setPersist ] = useState<QrPersist>(persistParam === "persist" ? "persist" : "once");
|
||||||
const [ submitting, setSubmitting ] = useState(false);
|
const [ submitting, setSubmitting ] = useState(false);
|
||||||
const [ outcome, setOutcome ] = useState<Outcome>(null);
|
const [ outcome, setOutcome ] = useState<Outcome>(null);
|
||||||
const [ actionError, setActionError ] = useState<string | null>(null);
|
const [ actionError, setActionError ] = useState<string | null>(null);
|
||||||
@@ -92,7 +96,10 @@ function LinkApprovePage()
|
|||||||
setStepUpRejected(false);
|
setStepUpRejected(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await qrApprove(requestId, code, "guest", persist, stash?.code, stash?.verifier);
|
// persist=「信任此设备」→ 完整会话(scope=full);once=「仅此次」→ 受限访客
|
||||||
|
// (scope=guest)。后端按 scope 决定授予的会话级别,二者与 persist 时长一一对应。
|
||||||
|
const scope = persist === "persist" ? "full" : "guest";
|
||||||
|
await qrApprove(requestId, code, scope, persist, stash?.code, stash?.verifier);
|
||||||
setOutcome("approved");
|
setOutcome("approved");
|
||||||
}
|
}
|
||||||
catch (e)
|
catch (e)
|
||||||
@@ -124,7 +131,15 @@ function LinkApprovePage()
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
setStepUpRejected(false);
|
setStepUpRejected(false);
|
||||||
try { await startStepUpReauth(window.location.pathname + window.location.search); }
|
try
|
||||||
|
{
|
||||||
|
// 把当前「信任时长」选择编进 returnTo:step-up 整页跳转回来后页面重载、
|
||||||
|
// React state 复位,必须从 URL 恢复 persist,否则会回落默认、令本应受限
|
||||||
|
// 的设备被建成完整会话(#2 根因)。
|
||||||
|
const back = new URLSearchParams(window.location.search);
|
||||||
|
back.set("p", persist);
|
||||||
|
await startStepUpReauth(window.location.pathname + "?" + back.toString());
|
||||||
|
}
|
||||||
catch (e)
|
catch (e)
|
||||||
{
|
{
|
||||||
setActionError(e instanceof Error ? e.message : String(e));
|
setActionError(e instanceof Error ? e.message : String(e));
|
||||||
@@ -155,10 +170,13 @@ function LinkApprovePage()
|
|||||||
if (!requestId || !code)
|
if (!requestId || !code)
|
||||||
{
|
{
|
||||||
return (
|
return (
|
||||||
<AuthShell title={t("qr.approve.title")} hideBrand>
|
<AuthShell title={t("qr.approve.title")} hideBrand skipAutospace>
|
||||||
<Callout tone="error" icon={<AlertTriangle size={16} />}>
|
<Callout tone="error" icon={<AlertTriangle size={16} />}>
|
||||||
{t("qr.approve.badLink")}
|
{t("qr.approve.badLink")}
|
||||||
</Callout>
|
</Callout>
|
||||||
|
<Button variant="secondary" size="lg" block onClick={() => navigate({ to: "/" })}>
|
||||||
|
{t("qr.approve.backHome")}
|
||||||
|
</Button>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -170,6 +188,7 @@ function LinkApprovePage()
|
|||||||
title={t("qr.approve.title")}
|
title={t("qr.approve.title")}
|
||||||
subtitle={t("qr.approve.signInFirst")}
|
subtitle={t("qr.approve.signInFirst")}
|
||||||
hideBrand
|
hideBrand
|
||||||
|
skipAutospace
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -189,8 +208,11 @@ function LinkApprovePage()
|
|||||||
if (loadError)
|
if (loadError)
|
||||||
{
|
{
|
||||||
return (
|
return (
|
||||||
<AuthShell title={t("qr.approve.title")} hideBrand>
|
<AuthShell title={t("qr.approve.title")} hideBrand skipAutospace>
|
||||||
<Callout tone="error" icon={<AlertTriangle size={16} />}>{loadError}</Callout>
|
<Callout tone="error" icon={<AlertTriangle size={16} />}>{loadError}</Callout>
|
||||||
|
<Button variant="secondary" size="lg" block onClick={() => navigate({ to: "/" })}>
|
||||||
|
{t("qr.approve.backHome")}
|
||||||
|
</Button>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -198,12 +220,12 @@ function LinkApprovePage()
|
|||||||
if (!info)
|
if (!info)
|
||||||
{
|
{
|
||||||
return (
|
return (
|
||||||
<AuthShell title={t("qr.approve.title")} subtitle={t("qr.approve.loading")} hideBrand />
|
<AuthShell title={t("qr.approve.title")} subtitle={t("qr.approve.loading")} hideBrand skipAutospace />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell title={t("qr.approve.title")} subtitle={t("qr.approve.subtitle")} hideBrand>
|
<AuthShell title={t("qr.approve.title")} subtitle={t("qr.approve.subtitle")} hideBrand skipAutospace>
|
||||||
{/* 待批准设备:单行身份块——图标 + 名字 + 来源 IP(monospace 元数据)。 */}
|
{/* 待批准设备:单行身份块——图标 + 名字 + 来源 IP(monospace 元数据)。 */}
|
||||||
<div className={s.device}>
|
<div className={s.device}>
|
||||||
<span className={s.deviceIcon}>
|
<span className={s.deviceIcon}>
|
||||||
@@ -237,9 +259,12 @@ function LinkApprovePage()
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 权限范围说明:本期固定受限访客,明确告知边界(非装饰)。 */}
|
{/* 权限范围说明:随选中项切换——信任=完整登录、仅此次=受限访客,明确告知
|
||||||
|
这台设备将以什么身份登录(非装饰)。 */}
|
||||||
<Callout tone="info" icon={<ShieldCheck size={16} />}>
|
<Callout tone="info" icon={<ShieldCheck size={16} />}>
|
||||||
{t("qr.approve.scopeGuest")}
|
{persist === "persist"
|
||||||
|
? t("qr.approve.scopeFull")
|
||||||
|
: t("qr.approve.scopeGuest")}
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
{/* step-up:批准前的明确安全步骤——需先验证身份(provider 2FA 即此处过)。 */}
|
{/* step-up:批准前的明确安全步骤——需先验证身份(provider 2FA 即此处过)。 */}
|
||||||
@@ -332,7 +357,7 @@ function OutcomeView(props: { outcome: Exclude<Outcome, null>; onHome: () => voi
|
|||||||
title: t("qr.approve.errorTitle"), body: t("qr.approve.error") },
|
title: t("qr.approve.errorTitle"), body: t("qr.approve.error") },
|
||||||
}[outcome];
|
}[outcome];
|
||||||
return (
|
return (
|
||||||
<AuthShell title={map.title} hideBrand>
|
<AuthShell title={map.title} hideBrand skipAutospace>
|
||||||
<Callout tone={map.tone} icon={map.icon}>{map.body}</Callout>
|
<Callout tone={map.tone} icon={map.icon}>{map.body}</Callout>
|
||||||
<Button variant="secondary" size="lg" block onClick={onHome}>
|
<Button variant="secondary" size="lg" block onClick={onHome}>
|
||||||
{t("qr.approve.backHome")}
|
{t("qr.approve.backHome")}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { AlertTriangle, ArrowRight, RefreshCw, ScanLine } from "lucide-react";
|
import { AlertTriangle, ArrowLeft, ArrowRight, RefreshCw, ScanLine } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { AuthShell } from "../features/auth/AuthShell";
|
import { AuthShell } from "../features/auth/AuthShell";
|
||||||
import { syncWebDeviceName } from "../features/auth/auth";
|
import { syncWebDeviceName } from "../features/auth/auth";
|
||||||
@@ -109,6 +109,16 @@ function LinkNewPage()
|
|||||||
void begin(name.trim());
|
void begin(name.trim());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 回上一步:弃掉当前二维码会话,退回命名步骤(轮询随 session 清空由 effect 清理)。
|
||||||
|
const handleBackToName = () =>
|
||||||
|
{
|
||||||
|
cancelPollRef.current?.();
|
||||||
|
cancelPollRef.current = null;
|
||||||
|
setSession(null);
|
||||||
|
setError(null);
|
||||||
|
setStep("name");
|
||||||
|
};
|
||||||
|
|
||||||
if (step === "name")
|
if (step === "name")
|
||||||
{
|
{
|
||||||
return (
|
return (
|
||||||
@@ -116,6 +126,7 @@ function LinkNewPage()
|
|||||||
title={t("qr.show.title")}
|
title={t("qr.show.title")}
|
||||||
subtitle={t("qr.show.subtitle")}
|
subtitle={t("qr.show.subtitle")}
|
||||||
hideBrand
|
hideBrand
|
||||||
|
skipAutospace
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<Callout tone="error" icon={<AlertTriangle size={16} />}>{error}</Callout>
|
<Callout tone="error" icon={<AlertTriangle size={16} />}>{error}</Callout>
|
||||||
@@ -146,12 +157,21 @@ function LinkNewPage()
|
|||||||
>
|
>
|
||||||
{t("qr.show.generate")}
|
{t("qr.show.generate")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="lg"
|
||||||
|
block
|
||||||
|
leftIcon={<ArrowLeft size={16} />}
|
||||||
|
onClick={() => navigate({ to: "/" })}
|
||||||
|
>
|
||||||
|
{t("qr.show.back")}
|
||||||
|
</Button>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell title={t("qr.show.scanTitle")} hideBrand>
|
<AuthShell title={t("qr.show.scanTitle")} hideBrand skipAutospace>
|
||||||
{error && (
|
{error && (
|
||||||
<Callout tone="error" icon={<AlertTriangle size={16} />}>{error}</Callout>
|
<Callout tone="error" icon={<AlertTriangle size={16} />}>{error}</Callout>
|
||||||
)}
|
)}
|
||||||
@@ -189,6 +209,19 @@ function LinkNewPage()
|
|||||||
{t("qr.show.regenerate")}
|
{t("qr.show.regenerate")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 始终给一个明确退出口:回到命名步骤,避免卡在显码 / 终态页。 */}
|
||||||
|
{phase !== "approved" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="lg"
|
||||||
|
block
|
||||||
|
leftIcon={<ArrowLeft size={16} />}
|
||||||
|
onClick={handleBackToName}
|
||||||
|
>
|
||||||
|
{t("qr.show.backToName")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/* 应用内扫码器:取景框是这一屏的签名时刻——与显码页的 QrCanvas 互为镜像。
|
||||||
|
显码页把一枚码托在四角定位记号里给人看,扫码页则在同一组记号里「找」一枚码。
|
||||||
|
同一套 accent L 形角标,同一套 recessed plate 语汇,零新全局样式。 */
|
||||||
|
|
||||||
|
.stage
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-2) 0 var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 取景框:方形 viewport,下沉 plate 把相机画面托成「一个对准的对象」。 */
|
||||||
|
.viewport
|
||||||
|
{
|
||||||
|
position: relative;
|
||||||
|
width: 248px;
|
||||||
|
height: 248px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 视频铺满取景框,居中裁切。镜像关闭——后置 / 外接相机扫码不应翻转。 */
|
||||||
|
.video
|
||||||
|
{
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 四角定位记号:与 QrCanvas 同款 accent L 形角标,内缩到取景框内沿。 */
|
||||||
|
.tick
|
||||||
|
{
|
||||||
|
position: absolute;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl { top: var(--space-3); left: var(--space-3);
|
||||||
|
border-right: none; border-bottom: none; border-top-left-radius: 4px; }
|
||||||
|
.tr { top: var(--space-3); right: var(--space-3);
|
||||||
|
border-left: none; border-bottom: none; border-top-right-radius: 4px; }
|
||||||
|
.bl { bottom: var(--space-3); left: var(--space-3);
|
||||||
|
border-right: none; border-top: none; border-bottom-left-radius: 4px; }
|
||||||
|
.br { bottom: var(--space-3); right: var(--space-3);
|
||||||
|
border-left: none; border-top: none; border-bottom-right-radius: 4px; }
|
||||||
|
|
||||||
|
/* 扫描线:唯一的动效「时刻」,accent 横线在取景框内上下扫动,传达「正在找」。
|
||||||
|
reduced-motion 下归零(见底部 media query)。 */
|
||||||
|
.beam
|
||||||
|
{
|
||||||
|
position: absolute;
|
||||||
|
left: var(--space-3);
|
||||||
|
right: var(--space-3);
|
||||||
|
top: var(--space-3);
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 8px 0 var(--accent-ring);
|
||||||
|
opacity: 0.85;
|
||||||
|
animation: scan-beam 2.4s var(--ease-in-out) infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scan-beam
|
||||||
|
{
|
||||||
|
0% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(202px); }
|
||||||
|
100% { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 命中过渡:解出有效码后取景框整体淡出,承接导航到批准页。 */
|
||||||
|
.viewportHit
|
||||||
|
{
|
||||||
|
border-color: var(--accent-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusLine
|
||||||
|
{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 两步指引:与显码页同款语义序号列表。 */
|
||||||
|
.steps
|
||||||
|
{
|
||||||
|
margin: 0;
|
||||||
|
padding-left: var(--space-5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
line-height: var(--lh-body);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps li::marker
|
||||||
|
{
|
||||||
|
color: var(--accent);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce)
|
||||||
|
{
|
||||||
|
.beam { animation: none; opacity: 0; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { AlertTriangle, ArrowLeft, Camera, CameraOff, ScanLine } from "lucide-react";
|
||||||
|
import jsQR from "jsqr";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { AuthShell } from "../features/auth/AuthShell";
|
||||||
|
import { parseLinkApprovalUrl } from "../net/qr";
|
||||||
|
import { useAppStore } from "../store";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { Button, Callout } from "../ui/primitives";
|
||||||
|
import s from "./link_.scan.module.css";
|
||||||
|
|
||||||
|
// 应用内扫码器:在已登录的 cdrop 里直接调相机扫码,解出 /link?r=&c= 后在同一已鉴权
|
||||||
|
// 会话内导航到批准页——绕开「系统相机打开链接落到未登录浏览器」的断流。批准走既有
|
||||||
|
// /link 页(step-up 开启时该页会自动要求再认证),本组件只负责「扫到合法码 → 导航」。
|
||||||
|
export const Route = createFileRoute("/link_/scan")({
|
||||||
|
component: LinkScanPage,
|
||||||
|
// 守卫:受限访客(scope=guest)不能授权新设备(后端 approve 亦 403),故连扫码
|
||||||
|
// 授权页都不让进——直接弹回首页,避免误触。未登录交由批准页处理,这里只挡访客。
|
||||||
|
beforeLoad: () =>
|
||||||
|
{
|
||||||
|
if (useAppStore.getState().sessionScope === "guest")
|
||||||
|
{
|
||||||
|
throw redirect({ to: "/" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 扫码器的几种态:请求相机中 / 正在扫 / 命中(淡出过渡)/ 无摄像头 / 权限被拒 / 其他错误。
|
||||||
|
type ScanState = "starting" | "scanning" | "hit" | "noCamera" | "denied" | "error";
|
||||||
|
|
||||||
|
function LinkScanPage()
|
||||||
|
{
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
// 解码用的离屏 canvas,逐帧把视频画上去再喂给 jsQR。
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
// 命中后置位:阻止 rAF 循环继续解码 / 重复导航。
|
||||||
|
const doneRef = useRef(false);
|
||||||
|
|
||||||
|
const [ state, setState ] = useState<ScanState>("starting");
|
||||||
|
const [ errorMsg, setErrorMsg ] = useState<string | null>(null);
|
||||||
|
// 扫到无关 / 非本站二维码:提示「继续对准」,但相机继续扫,不打断。
|
||||||
|
const [ sawForeignCode, setSawForeignCode ] = useState(false);
|
||||||
|
|
||||||
|
// stop 释放相机:停轨道 + 取消 rAF。卸载、命中、出错时都调用,确保相机指示灯熄灭。
|
||||||
|
const stop = useCallback(() =>
|
||||||
|
{
|
||||||
|
if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }
|
||||||
|
const stream = streamRef.current;
|
||||||
|
if (stream) { stream.getTracks().forEach((tr) => tr.stop()); streamRef.current = null; }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// tick:单帧解码循环。把当前视频帧画到离屏 canvas,jsQR 解一次;解出有效本站
|
||||||
|
// /link 码即停机 + 应用内导航,否则下一帧继续。
|
||||||
|
const tick = useCallback(() =>
|
||||||
|
{
|
||||||
|
if (doneRef.current) { return; }
|
||||||
|
const video = videoRef.current;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!video || !canvas || video.readyState < video.HAVE_ENOUGH_DATA)
|
||||||
|
{
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = video.videoWidth;
|
||||||
|
const h = video.videoHeight;
|
||||||
|
if (!w || !h)
|
||||||
|
{
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!ctx)
|
||||||
|
{
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(video, 0, 0, w, h);
|
||||||
|
const image = ctx.getImageData(0, 0, w, h);
|
||||||
|
const result = jsQR(image.data, w, h, { inversionAttempts: "dontInvert" });
|
||||||
|
|
||||||
|
if (result && result.data)
|
||||||
|
{
|
||||||
|
const link = parseLinkApprovalUrl(result.data);
|
||||||
|
if (link)
|
||||||
|
{
|
||||||
|
// 命中合法本站码:停机、淡出,应用内导航到批准页(留在已登录 SPA 会话)。
|
||||||
|
doneRef.current = true;
|
||||||
|
stop();
|
||||||
|
setState("hit");
|
||||||
|
// p 不带:批准页会回落到更安全的默认「仅此次」(受限访客)。
|
||||||
|
navigate({ to: "/link", search: { r: link.requestId, c: link.code, p: undefined } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 扫到了码,但不是本站 /link 码(别的二维码 / 钓鱼链接):提示继续对准。
|
||||||
|
setSawForeignCode(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
}, [ navigate, stop ]);
|
||||||
|
|
||||||
|
// 进页面即请求相机并起解码循环。无摄像头 / 权限被拒分别落到清晰的引导态。
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
doneRef.current = false;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const start = async () =>
|
||||||
|
{
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia)
|
||||||
|
{
|
||||||
|
setState("noCamera");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
// 优先后置相机(手机扫码自然朝外);桌面无后置则退到任意可用相机。
|
||||||
|
video: { facingMode: { ideal: "environment" } },
|
||||||
|
audio: false,
|
||||||
|
});
|
||||||
|
if (cancelled) { stream.getTracks().forEach((tr) => tr.stop()); return; }
|
||||||
|
streamRef.current = stream;
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video)
|
||||||
|
{
|
||||||
|
video.srcObject = stream;
|
||||||
|
// iOS Safari 要求 playsInline + 显式 play(),否则不出画面。
|
||||||
|
await video.play().catch(() => { /* 自动播放被拦:画面仍随轨道更新 */ });
|
||||||
|
}
|
||||||
|
setState("scanning");
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
catch (e)
|
||||||
|
{
|
||||||
|
if (cancelled) { return; }
|
||||||
|
const name = e instanceof DOMException ? e.name : "";
|
||||||
|
if (name === "NotAllowedError" || name === "SecurityError")
|
||||||
|
{
|
||||||
|
setState("denied");
|
||||||
|
}
|
||||||
|
else if (name === "NotFoundError" || name === "OverconstrainedError")
|
||||||
|
{
|
||||||
|
setState("noCamera");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
setErrorMsg(e instanceof Error ? e.message : String(e));
|
||||||
|
setState("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void start();
|
||||||
|
return () => { cancelled = true; stop(); };
|
||||||
|
}, [ tick, stop ]);
|
||||||
|
|
||||||
|
const goBack = () => navigate({ to: "/" });
|
||||||
|
|
||||||
|
// ---- 渲染分支 --------------------------------------------------------
|
||||||
|
|
||||||
|
// 无摄像头(桌面常见):优雅降级,引导改用手机扫,不报错。
|
||||||
|
if (state === "noCamera")
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
<AuthShell title={t("qr.scan.title")} hideBrand skipAutospace>
|
||||||
|
<Callout tone="info" icon={<CameraOff size={16} />}>
|
||||||
|
{t("qr.scan.noCamera")}
|
||||||
|
</Callout>
|
||||||
|
<Button variant="secondary" size="lg" block leftIcon={<ArrowLeft size={16} />} onClick={goBack}>
|
||||||
|
{t("qr.scan.back")}
|
||||||
|
</Button>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限被拒:明确告诉用户去哪儿改回来。
|
||||||
|
if (state === "denied")
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
<AuthShell title={t("qr.scan.title")} hideBrand skipAutospace>
|
||||||
|
<Callout tone="warn" icon={<CameraOff size={16} />}>
|
||||||
|
{t("qr.scan.denied")}
|
||||||
|
</Callout>
|
||||||
|
<Button variant="secondary" size="lg" block leftIcon={<ArrowLeft size={16} />} onClick={goBack}>
|
||||||
|
{t("qr.scan.back")}
|
||||||
|
</Button>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "error")
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
<AuthShell title={t("qr.scan.title")} hideBrand skipAutospace>
|
||||||
|
<Callout tone="error" icon={<AlertTriangle size={16} />}>
|
||||||
|
{errorMsg ?? t("qr.scan.error")}
|
||||||
|
</Callout>
|
||||||
|
<Button variant="secondary" size="lg" block leftIcon={<ArrowLeft size={16} />} onClick={goBack}>
|
||||||
|
{t("qr.scan.back")}
|
||||||
|
</Button>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthShell title={t("qr.scan.title")} subtitle={t("qr.scan.subtitle")} hideBrand skipAutospace>
|
||||||
|
<div className={s.stage}>
|
||||||
|
<div className={`${s.viewport} ${state === "hit" ? s.viewportHit : ""}`}>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className={s.video}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
// 仅装饰性预览,扫码内容不读屏;语义流程在批准页。
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{state === "scanning" && <span className={s.beam} aria-hidden="true" />}
|
||||||
|
<span className={`${s.tick} ${s.tl}`} aria-hidden="true" />
|
||||||
|
<span className={`${s.tick} ${s.tr}`} aria-hidden="true" />
|
||||||
|
<span className={`${s.tick} ${s.bl}`} aria-hidden="true" />
|
||||||
|
<span className={`${s.tick} ${s.br}`} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<canvas ref={canvasRef} style={{ display: "none" }} />
|
||||||
|
|
||||||
|
<div className={s.statusLine}>
|
||||||
|
{state === "starting"
|
||||||
|
? <><Camera size={14} />{t("qr.scan.starting")}</>
|
||||||
|
: <><ScanLine size={14} />{t("qr.scan.aiming")}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 扫到非本站码:安静提示继续对准,相机不中断。 */}
|
||||||
|
{sawForeignCode && state === "scanning" && (
|
||||||
|
<Callout tone="info" icon={<ScanLine size={16} />}>
|
||||||
|
{t("qr.scan.foreign")}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ol className={s.steps} data-jz-level="paragraph">
|
||||||
|
<li>{t("qr.scan.howto1")}</li>
|
||||||
|
<li>{t("qr.scan.howto2")}</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className={s.actions}>
|
||||||
|
<Button variant="ghost" size="lg" block leftIcon={<ArrowLeft size={16} />} onClick={goBack}>
|
||||||
|
{t("qr.scan.back")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
+12
-19
@@ -1,4 +1,4 @@
|
|||||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { AlertTriangle, LogIn, QrCode } from "lucide-react";
|
import { AlertTriangle, LogIn, QrCode } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fetchAuthConfig, loginDev, loginProd } from "../features/auth/auth";
|
import { fetchAuthConfig, loginDev, loginProd } from "../features/auth/auth";
|
||||||
@@ -131,25 +131,18 @@ function LoginPage()
|
|||||||
>
|
>
|
||||||
{t("login.prod.button")}
|
{t("login.prod.button")}
|
||||||
</Button>
|
</Button>
|
||||||
{/* 扫码登录入口:从已登录的另一台设备批准本机接入,无需在此重登。
|
{/* 扫码登录入口:与 OAuth 登录平级的另一种登录方式——从已登录的
|
||||||
克制——一行带图标的次级链接,不与主 CTA 争重。 */}
|
另一台设备批准本机接入,无需在此重登。secondary 变体、等宽、
|
||||||
<Link
|
同 size,与主登录按钮同级视觉重量。 */}
|
||||||
to="/link/new"
|
<Button
|
||||||
search={{}}
|
variant="secondary"
|
||||||
style={{
|
size="lg"
|
||||||
display: "inline-flex",
|
block
|
||||||
alignItems: "center",
|
leftIcon={<QrCode size={16} />}
|
||||||
justifyContent: "center",
|
onClick={() => navigate({ to: "/link/new" })}
|
||||||
gap: "var(--space-2)",
|
|
||||||
marginTop: "calc(var(--space-1) * -1)",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
fontSize: "var(--fs-13)",
|
|
||||||
textDecoration: "none",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<QrCode size={15} />
|
{t("qr.entry.fromLogin")}
|
||||||
<span>{t("qr.entry.fromLogin")}</span>
|
</Button>
|
||||||
</Link>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
|
|||||||
+289
-107
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Anchor,
|
Anchor,
|
||||||
Container,
|
Container,
|
||||||
@@ -8,9 +8,12 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
|
||||||
import { Bell, LogOut, Trash2 } from "lucide-react";
|
import { AlertTriangle, Bell, LogOut, ShieldAlert, Trash2 } from "lucide-react";
|
||||||
import { logout, syncWebDeviceName } from "../features/auth/auth";
|
import { logout, startStepUpReauth, syncWebDeviceName } from "../features/auth/auth";
|
||||||
import { DesktopSettings } from "../features/desktop/DesktopSettings";
|
import { DesktopSettings } from "../features/desktop/DesktopSettings";
|
||||||
|
import {
|
||||||
|
consumePendingRevoke, consumeStepUpCode, stashPendingRevoke,
|
||||||
|
} from "../features/qr/stepUp";
|
||||||
import { isDesktop, persistDesktopDeviceName } from "../net/desktop";
|
import { isDesktop, persistDesktopDeviceName } from "../net/desktop";
|
||||||
import { apiFetch } from "../net/api";
|
import { apiFetch } from "../net/api";
|
||||||
import {
|
import {
|
||||||
@@ -20,25 +23,15 @@ import {
|
|||||||
pushSupported,
|
pushSupported,
|
||||||
type PushState,
|
type PushState,
|
||||||
} from "../net/push";
|
} from "../net/push";
|
||||||
import { useAppStore, type DeviceInfo } from "../store";
|
import {
|
||||||
|
listSessions, postStepUp, revokeSession, StepUpRequiredError,
|
||||||
|
type AuthSession,
|
||||||
|
} from "../net/sessions";
|
||||||
|
import { useAppStore } from "../store";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { formatRelative, isAsciiDeviceName } from "../utils/format";
|
import { formatRelative, isAsciiDeviceName } from "../utils/format";
|
||||||
import { Badge, Button, Panel, TextField } from "../ui/primitives";
|
import { Badge, Button, Callout, Panel, TextField } from "../ui/primitives";
|
||||||
import { StateDot } from "../ui/glyphs";
|
import { StateDot } from "../ui/glyphs";
|
||||||
|
|
||||||
// deviceTypeLabel 把后端存的设备类型本地化展示(浏览器 / macOS 客户端 / …)。
|
|
||||||
// 容错匹配(与 DeviceStrip 的 mapKind 一致),未知类型回退原值。
|
|
||||||
function deviceTypeLabel(type: string): string
|
|
||||||
{
|
|
||||||
const k = type.toLowerCase();
|
|
||||||
if (k.includes("mac") || k === "darwin") { return t("deviceType.macos"); }
|
|
||||||
if (k.includes("win")) { return t("deviceType.windows"); }
|
|
||||||
if (k.includes("linux")) { return t("deviceType.linux"); }
|
|
||||||
if (k === "shortcut") { return t("deviceType.shortcut"); }
|
|
||||||
if (k.includes("ios") || k === "iphone" || k === "ipad") { return t("deviceType.ios"); }
|
|
||||||
if (k === "browser") { return t("deviceType.browser"); }
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
import { toast } from "../ui/feedback";
|
import { toast } from "../ui/feedback";
|
||||||
|
|
||||||
export const Route = createFileRoute("/settings")({
|
export const Route = createFileRoute("/settings")({
|
||||||
@@ -55,19 +48,14 @@ function SettingsPage()
|
|||||||
{
|
{
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = useAppStore((s) => s.user);
|
const user = useAppStore((s) => s.user);
|
||||||
|
const sessionScope = useAppStore((s) => s.sessionScope);
|
||||||
|
const isGuest = sessionScope === "guest";
|
||||||
const selfDeviceName = useAppStore((s) => s.selfDeviceName);
|
const selfDeviceName = useAppStore((s) => s.selfDeviceName);
|
||||||
const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName);
|
const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName);
|
||||||
const storeDevices = useAppStore((s) => s.devices);
|
|
||||||
|
|
||||||
const [ pendingName, setPendingName ] = useState(selfDeviceName ?? "");
|
const [ pendingName, setPendingName ] = useState(selfDeviceName ?? "");
|
||||||
const [ removing, setRemoving ] = useState<Set<string>>(new Set());
|
|
||||||
const [ renaming, setRenaming ] = useState(false);
|
const [ renaming, setRenaming ] = useState(false);
|
||||||
|
|
||||||
const peerDevices = useMemo(
|
|
||||||
() => storeDevices.filter((d) => d.name !== selfDeviceName),
|
|
||||||
[ storeDevices, selfDeviceName ],
|
|
||||||
);
|
|
||||||
|
|
||||||
const trimmedName = pendingName.trim();
|
const trimmedName = pendingName.trim();
|
||||||
const canSaveName = !!trimmedName && trimmedName !== selfDeviceName;
|
const canSaveName = !!trimmedName && trimmedName !== selfDeviceName;
|
||||||
|
|
||||||
@@ -118,48 +106,6 @@ function SettingsPage()
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemovePeer = async (name: string) =>
|
|
||||||
{
|
|
||||||
if (!window.confirm(t("settings.unregister.peerConfirm", { name }))) { return; }
|
|
||||||
|
|
||||||
setRemoving((prev) =>
|
|
||||||
{
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.add(name);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const r = await apiFetch(
|
|
||||||
`/api/devices/${encodeURIComponent(name)}`,
|
|
||||||
{ method: "DELETE" },
|
|
||||||
);
|
|
||||||
if (!r.ok)
|
|
||||||
{
|
|
||||||
const text = await r.text().catch(() => r.statusText);
|
|
||||||
throw new Error(`${r.status}: ${text}`);
|
|
||||||
}
|
|
||||||
// 服务端的 publishPresence 会通过 SSE 推送新的设备列表,store
|
|
||||||
// 会随之更新,这里不需要手动 setDevices。
|
|
||||||
toast.ok("设备已移除", name);
|
|
||||||
}
|
|
||||||
catch (e)
|
|
||||||
{
|
|
||||||
toast.error(t("settings.error.delete", {
|
|
||||||
message: e instanceof Error ? e.message : String(e),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
setRemoving((prev) =>
|
|
||||||
{
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(name);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnregisterSelf = async () =>
|
const handleUnregisterSelf = async () =>
|
||||||
{
|
{
|
||||||
if (!selfDeviceName) { return; }
|
if (!selfDeviceName) { return; }
|
||||||
@@ -194,12 +140,27 @@ function SettingsPage()
|
|||||||
<Container size="sm" py="lg">
|
<Container size="sm" py="lg">
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
<Title order={2}>{t("settings.title")}</Title>
|
<Group gap="xs" align="center" wrap="nowrap">
|
||||||
|
<Title order={2}>{t("settings.title")}</Title>
|
||||||
|
{isGuest && <Badge tone="warn">{t("settings.guest.badge")}</Badge>}
|
||||||
|
</Group>
|
||||||
<Anchor component={Link} to="/" size="sm">
|
<Anchor component={Link} to="/" size="sm">
|
||||||
{t("nav.backToHome")}
|
{t("nav.backToHome")}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{/* 受限访客标识:说明这台设备不能管理账号 / 授权设备,要完整权限请在
|
||||||
|
主设备上重新登录。放在最显眼处(标题下第一块)。 */}
|
||||||
|
{isGuest && (
|
||||||
|
<Callout
|
||||||
|
tone="warn"
|
||||||
|
icon={<ShieldAlert size={16} />}
|
||||||
|
title={t("settings.guest.title")}
|
||||||
|
>
|
||||||
|
{t("settings.guest.body")}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
<Panel title={t("settings.currentDevice.title")}>
|
<Panel title={t("settings.currentDevice.title")}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<TextField
|
<TextField
|
||||||
@@ -226,7 +187,7 @@ function SettingsPage()
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<div style={{ height: 1, background: "var(--divider)" }} />
|
<div style={{ height: 1, background: "var(--divider)" }} />
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">
|
||||||
{t("settings.unregister.currentHint")}
|
{t("settings.unregister.currentHint")}
|
||||||
</Text>
|
</Text>
|
||||||
<Group>
|
<Group>
|
||||||
@@ -245,22 +206,10 @@ function SettingsPage()
|
|||||||
|
|
||||||
{pushSupported() && <PushPanel />}
|
{pushSupported() && <PushPanel />}
|
||||||
|
|
||||||
<Panel title={t("settings.peers.title")}>
|
<SessionsPanel
|
||||||
{peerDevices.length === 0 ? (
|
isGuest={isGuest}
|
||||||
<Text size="sm" c="dimmed">{t("settings.peers.empty")}</Text>
|
onSignedOutSelf={handleSignOut}
|
||||||
) : (
|
/>
|
||||||
<Stack gap="xs">
|
|
||||||
{peerDevices.map((d) => (
|
|
||||||
<PeerRow
|
|
||||||
key={d.name}
|
|
||||||
device={d}
|
|
||||||
removing={removing.has(d.name)}
|
|
||||||
onRemove={() => handleRemovePeer(d.name)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel title={t("settings.account.title")}>
|
<Panel title={t("settings.account.title")}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
@@ -335,9 +284,9 @@ function PushPanel()
|
|||||||
return (
|
return (
|
||||||
<Panel title={t("settings.push.title")}>
|
<Panel title={t("settings.push.title")}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text size="sm" c="dimmed">{t("settings.push.hint")}</Text>
|
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.push.hint")}</Text>
|
||||||
{denied && !subscribed && (
|
{denied && !subscribed && (
|
||||||
<Text size="sm" style={{ color: "var(--state-error)" }}>
|
<Text size="sm" className="justify" data-jz-level="paragraph" style={{ color: "var(--state-error)" }}>
|
||||||
{t("settings.push.deniedHint")}
|
{t("settings.push.deniedHint")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -371,37 +320,270 @@ function PushPanel()
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PeerRow(props: { device: DeviceInfo; removing: boolean; onRemove: () => void })
|
// sortSessions 把会话排成「在线在上、未活跃(offline)在下」,组内按最近活跃时间降序
|
||||||
|
// (活跃越近越靠前)。不改变原数组,返回新数组。
|
||||||
|
function sortSessions(list: AuthSession[]): AuthSession[]
|
||||||
{
|
{
|
||||||
const { device, removing, onRemove } = props;
|
return [ ...list ].sort((a, b) =>
|
||||||
const lastSeenText = device.lastSeen
|
{
|
||||||
? t("settings.lastSeen", { time: formatRelative(device.lastSeen) })
|
if (a.online !== b.online) { return a.online ? -1 : 1; }
|
||||||
: t("settings.lastSeenNever");
|
return b.lastUsedAt - a.lastUsedAt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionsPanel:登录会话管理——浏览器 / 扫码登录的设备会话。每条显示在线状态点 +
|
||||||
|
// 设备名 + 权限级别 Badge + 「本机」标记 + 最近活跃,并提供「登出」(真正吊销该登录
|
||||||
|
// 的访问令牌)。在线会话排在上、未活跃(offline)的排在下;离线会话仍可登出。
|
||||||
|
//
|
||||||
|
// step-up:DELETE 返回 403 step_up_required 时,把待登出的 sessionId 暂存 → 发起
|
||||||
|
// 一次 prompt=login 再认证(returnTo=/settings)→ 整页跳走 → 回来后由本组件 mount
|
||||||
|
// effect 取出暂存的 {code, verifier} + sessionId,POST /auth/stepup 记录窗口,再
|
||||||
|
// 重试那条 DELETE。step-up 窗口内(后端 5 分钟)后续登出不再 403、直接成功。
|
||||||
|
//
|
||||||
|
// 受限访客(isGuest):无权管理登录会话、后端 GET 会 403,故根本不发该请求,直接展示
|
||||||
|
// 一句克制的说明 Callout(不出现任何加载 / 失败态)。
|
||||||
|
function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
|
||||||
|
{
|
||||||
|
const { isGuest, onSignedOutSelf } = props;
|
||||||
|
const [ sessions, setSessions ] = useState<AuthSession[] | null>(null);
|
||||||
|
const [ loadError, setLoadError ] = useState(false);
|
||||||
|
const [ busyId, setBusyId ] = useState<string | null>(null);
|
||||||
|
const [ stepUpRejected, setStepUpRejected ] = useState(false);
|
||||||
|
|
||||||
|
// reload 拉取并写入列表,同时把结果回传给调用方(step-up 重试路径需在 revoke
|
||||||
|
// 前从这份列表里查出 current,不能在 revoke 之后再拉——若登出的正是本机会话,
|
||||||
|
// 令牌已失效、那次 listSessions 会 401)。在线会话排前、未活跃的排后,组内按最近
|
||||||
|
// 活跃时间降序。失败回 null。受限访客不会走到这里(调用方在 guest 分支直接返回)。
|
||||||
|
const reload = useCallback(async (): Promise<AuthSession[] | null> =>
|
||||||
|
{
|
||||||
|
setLoadError(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const list = sortSessions(await listSessions());
|
||||||
|
setSessions(list);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
catch { setLoadError(true); setSessions(null); return null; }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// doRevoke 真正执行一条登出。current(本机)会话登出后清本地 auth、回登录页
|
||||||
|
// (等价退出登录);其余刷新列表。403 step_up_required 由调用方分流,不入此处。
|
||||||
|
const finishRevoke = useCallback((sess: AuthSession) =>
|
||||||
|
{
|
||||||
|
if (sess.current) { onSignedOutSelf(); return; }
|
||||||
|
toast.ok(t("settings.sessions.revoked"));
|
||||||
|
void reload();
|
||||||
|
}, [ onSignedOutSelf, reload ]);
|
||||||
|
|
||||||
|
// 进页面拉一次。返回时若检测到 step-up 往返暂存({code, verifier} + 待登出 id),
|
||||||
|
// 先 POST /auth/stepup 记录窗口,再重试那条 DELETE——一次性串起整页跳转两端。
|
||||||
|
// 受限访客无权管理会话、后端 GET 会 403,故根本不发请求、直接走说明态。
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if (isGuest) { return; }
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const run = async () =>
|
||||||
|
{
|
||||||
|
// step-up 往返恢复:取出暂存(一次性)。无暂存则普通进页只拉列表。
|
||||||
|
const stash = consumeStepUpCode();
|
||||||
|
const pendingId = consumePendingRevoke();
|
||||||
|
const list = await reload();
|
||||||
|
if (cancelled) { return; }
|
||||||
|
|
||||||
|
if (stash && pendingId)
|
||||||
|
{
|
||||||
|
// 在 revoke 前就从这份列表里查出待登出的那条(尤其 current 标记):
|
||||||
|
// 若登出的正是本机会话,revoke 后令牌即失效,不能再 listSessions。
|
||||||
|
const target = list?.find((x) => x.id === pendingId);
|
||||||
|
setBusyId(pendingId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await postStepUp(stash.code, stash.verifier);
|
||||||
|
await revokeSession(pendingId);
|
||||||
|
if (cancelled) { return; }
|
||||||
|
if (target) { finishRevoke(target); }
|
||||||
|
else { toast.ok(t("settings.sessions.revoked")); await reload(); }
|
||||||
|
}
|
||||||
|
catch (e)
|
||||||
|
{
|
||||||
|
if (cancelled) { return; }
|
||||||
|
if (e instanceof StepUpRequiredError) { setStepUpRejected(true); }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
toast.error(t("settings.error.delete", {
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
void reload();
|
||||||
|
}
|
||||||
|
finally { if (!cancelled) { setBusyId(null); } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void run();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [ isGuest, reload, finishRevoke ]);
|
||||||
|
|
||||||
|
const handleRevoke = async (sess: AuthSession) =>
|
||||||
|
{
|
||||||
|
const confirmMsg = sess.current
|
||||||
|
? t("settings.sessions.confirmCurrent")
|
||||||
|
: t("settings.sessions.confirmOther", { name: sess.deviceName });
|
||||||
|
if (!window.confirm(confirmMsg)) { return; }
|
||||||
|
|
||||||
|
setBusyId(sess.id);
|
||||||
|
setStepUpRejected(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await revokeSession(sess.id);
|
||||||
|
finishRevoke(sess);
|
||||||
|
}
|
||||||
|
catch (e)
|
||||||
|
{
|
||||||
|
if (e instanceof StepUpRequiredError)
|
||||||
|
{
|
||||||
|
// 需再认证:暂存待登出 id,发起 prompt=login 往返,回到 /settings 后
|
||||||
|
// 由 mount effect 接力重试。成功则整页跳走、本组件卸载。
|
||||||
|
stashPendingRevoke(sess.id);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await startStepUpReauth("/settings");
|
||||||
|
return; // 跳转去 provider,不再 setBusyId。
|
||||||
|
}
|
||||||
|
catch (reauthErr)
|
||||||
|
{
|
||||||
|
toast.error(t("settings.error.delete", {
|
||||||
|
message: reauthErr instanceof Error ? reauthErr.message : String(reauthErr),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
toast.error(t("settings.error.delete", {
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { setBusyId(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// 受限访客:不展示列表 / 加载 / 失败态,只给一句克制的说明——管理登录会话需在主设备
|
||||||
|
// 上完整登录。后端 GET 本就 403,前面 effect 也已跳过请求,这里纯文案分支。
|
||||||
|
if (isGuest)
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
<Panel title={t("settings.sessions.title")}>
|
||||||
|
<Callout tone="warn" icon={<ShieldAlert size={16} />}>
|
||||||
|
{t("settings.sessions.guestNote")}
|
||||||
|
</Callout>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel title={t("settings.sessions.title")}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.sessions.hint")}</Text>
|
||||||
|
{stepUpRejected && (
|
||||||
|
<Callout tone="warn" icon={<AlertTriangle size={16} />}>
|
||||||
|
{t("settings.sessions.stepUpRejected")}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
{loadError ? (
|
||||||
|
<Group>
|
||||||
|
<Text size="sm" style={{ color: "var(--state-error)" }}>
|
||||||
|
{t("settings.sessions.loadError")}
|
||||||
|
</Text>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => void reload()}>
|
||||||
|
{t("settings.sessions.retry")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
) : sessions === null ? (
|
||||||
|
// 仍在加载(首屏闪一帧):不渲染占位文案,避免「暂无」误闪。
|
||||||
|
null
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed">{t("settings.sessions.empty")}</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{sessions.map((sess) => (
|
||||||
|
<SessionRow
|
||||||
|
key={sess.id}
|
||||||
|
session={sess}
|
||||||
|
busy={busyId === sess.id}
|
||||||
|
// 在线、离线会话都可登出(离线亦走 step-up,流程一致)。
|
||||||
|
onRevoke={() => handleRevoke(sess)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 会话种类标签:网页 / 扫码会话用 settings.sessions.kind.*;原生客户端
|
||||||
|
// (macos / windows / linux / ios)复用设备类型标签(「macOS 客户端」等)。
|
||||||
|
function sessionKindLabel(kind: AuthSession["kind"]): string
|
||||||
|
{
|
||||||
|
if (kind === "oidc" || kind === "self" || kind === "guest")
|
||||||
|
{
|
||||||
|
return t(`settings.sessions.kind.${kind}` as const);
|
||||||
|
}
|
||||||
|
return t(`deviceType.${kind}` as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionRow(props: { session: AuthSession; busy: boolean; onRevoke?: () => void })
|
||||||
|
{
|
||||||
|
const { session, busy, onRevoke } = props;
|
||||||
|
const lastUsedText = session.lastUsedAt
|
||||||
|
? t("settings.sessions.lastUsed", { time: formatRelative(session.lastUsedAt) })
|
||||||
|
: t("settings.sessions.neverUsed");
|
||||||
|
const kindLabel = sessionKindLabel(session.kind);
|
||||||
|
const onlineLabel = t(session.online ? "settings.online" : "settings.offline");
|
||||||
return (
|
return (
|
||||||
<Panel variant="sunken" padding="tight">
|
<Panel variant="sunken" padding="tight">
|
||||||
<Group justify="space-between" wrap="nowrap" align="center">
|
<Group justify="space-between" wrap="nowrap" align="center">
|
||||||
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
|
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<StateDot tone={device.online ? "online" : "offline"} />
|
<StateDot
|
||||||
<Text fw={500} size="sm" truncate>{device.name}</Text>
|
tone={session.online ? "online" : "offline"}
|
||||||
<Badge tone={device.online ? "online" : "neutral"}>
|
title={onlineLabel}
|
||||||
{t(device.online ? "settings.online" : "settings.offline")}
|
/>
|
||||||
|
<Text fw={500} size="sm" truncate>
|
||||||
|
{session.deviceName || kindLabel}
|
||||||
|
</Text>
|
||||||
|
<Badge tone={session.scope === "guest" ? "warn" : "accent"}>
|
||||||
|
{session.scope === "guest"
|
||||||
|
? t("settings.sessions.scopeGuest")
|
||||||
|
: t("settings.sessions.scopeFull")}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{session.current && (
|
||||||
|
<Badge tone="online">{t("settings.sessions.current")}</Badge>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" c="dimmed" truncate>
|
<Text size="xs" c="dimmed" truncate>
|
||||||
{deviceTypeLabel(device.type)} · {lastSeenText}
|
{onlineLabel} · {kindLabel} · {lastUsedText}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Button
|
{onRevoke && (
|
||||||
size="sm"
|
<Button
|
||||||
variant="ghost"
|
size="sm"
|
||||||
loading={removing}
|
variant="ghost"
|
||||||
onClick={onRemove}
|
loading={busy}
|
||||||
style={{ color: "var(--state-error)" }}
|
leftIcon={<LogOut size={13} />}
|
||||||
>
|
onClick={onRevoke}
|
||||||
{removing ? t("settings.peers.removing") : t("settings.peers.remove")}
|
style={{ color: "var(--state-error)" }}
|
||||||
</Button>
|
>
|
||||||
|
{busy
|
||||||
|
? t("settings.sessions.signingOut")
|
||||||
|
: session.current
|
||||||
|
? t("settings.sessions.signOutCurrent")
|
||||||
|
: t("settings.sessions.signOut")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TransferRecord, ThemeMode, User } from "./types";
|
import type { TransferRecord, ThemeMode, User, SessionScope } from "./types";
|
||||||
|
|
||||||
// DONE 是"全部数据已交付"的语义终态,把进行时遥测残留归一到这一事实,避免
|
// DONE 是"全部数据已交付"的语义终态,把进行时遥测残留归一到这一事实,避免
|
||||||
// history 行还显示"传输中字节 / 待发送 X MB"等错觉。FAILED / CANCELLED 不归
|
// history 行还显示"传输中字节 / 待发送 X MB"等错觉。FAILED / CANCELLED 不归
|
||||||
@@ -23,6 +23,7 @@ export function normalizeTerminal(cur: TransferRecord, finalState: string): Tran
|
|||||||
|
|
||||||
export const ACCESS_TOKEN_KEY = "cdrop.access_token";
|
export const ACCESS_TOKEN_KEY = "cdrop.access_token";
|
||||||
export const USER_KEY = "cdrop.user";
|
export const USER_KEY = "cdrop.user";
|
||||||
|
export const SESSION_SCOPE_KEY = "cdrop.session_scope";
|
||||||
export const SELF_DEVICE_KEY = "cdrop.self_device";
|
export const SELF_DEVICE_KEY = "cdrop.self_device";
|
||||||
export const THEME_KEY = "cdrop.theme";
|
export const THEME_KEY = "cdrop.theme";
|
||||||
|
|
||||||
@@ -125,6 +126,16 @@ export function readSessionUser(): User | null
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 本会话权限级别随 access_token 一并持久化(sessionStorage,同 tab 刷新存活、
|
||||||
|
// 关标签即清)。缺失 / 非法回退 "full"——/api/me 会在登录 / 水合后校正它,回退到
|
||||||
|
// 完整态只会多展示入口(后端仍 403),不会把受限态误升级。
|
||||||
|
export function readSessionScope(): SessionScope
|
||||||
|
{
|
||||||
|
if (typeof window === "undefined") { return "full"; }
|
||||||
|
const raw = window.sessionStorage.getItem(SESSION_SCOPE_KEY);
|
||||||
|
return raw === "guest" ? "guest" : "full";
|
||||||
|
}
|
||||||
|
|
||||||
export function readSelfDevice(): string | null
|
export function readSelfDevice(): string | null
|
||||||
{
|
{
|
||||||
if (typeof window === "undefined") { return null; }
|
if (typeof window === "undefined") { return null; }
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const useAppStore = create<AppState>()((...a) =>
|
|||||||
export type {
|
export type {
|
||||||
AppState,
|
AppState,
|
||||||
AuthMode,
|
AuthMode,
|
||||||
|
SessionScope,
|
||||||
ThemeMode,
|
ThemeMode,
|
||||||
User,
|
User,
|
||||||
DeviceInfo,
|
DeviceInfo,
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import type { StateCreator } from "zustand";
|
import type { StateCreator } from "zustand";
|
||||||
import type { AppState, AuthMode } from "../types";
|
import type { AppState, AuthMode, SessionScope } from "../types";
|
||||||
import {
|
import {
|
||||||
ACCESS_TOKEN_KEY,
|
ACCESS_TOKEN_KEY,
|
||||||
|
SESSION_SCOPE_KEY,
|
||||||
USER_KEY,
|
USER_KEY,
|
||||||
readSessionAccess,
|
readSessionAccess,
|
||||||
|
readSessionScope,
|
||||||
readSessionUser,
|
readSessionUser,
|
||||||
} from "../helpers";
|
} from "../helpers";
|
||||||
|
|
||||||
export type AuthSlice = Pick<
|
export type AuthSlice = Pick<
|
||||||
AppState,
|
AppState,
|
||||||
"authMode" | "accessToken" | "user" | "setAuth" | "clearAuth"
|
"authMode" | "accessToken" | "user" | "sessionScope"
|
||||||
|
| "setAuth" | "setSessionScope" | "clearAuth"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const initialAuthMode: AuthMode = import.meta.env.DEV ? "dev" : "prod";
|
const initialAuthMode: AuthMode = import.meta.env.DEV ? "dev" : "prod";
|
||||||
@@ -23,6 +26,7 @@ export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set)
|
|||||||
authMode: initialAuthMode,
|
authMode: initialAuthMode,
|
||||||
accessToken: readSessionAccess(),
|
accessToken: readSessionAccess(),
|
||||||
user: readSessionUser(),
|
user: readSessionUser(),
|
||||||
|
sessionScope: readSessionScope(),
|
||||||
|
|
||||||
setAuth: (a) =>
|
setAuth: (a) =>
|
||||||
{
|
{
|
||||||
@@ -37,13 +41,27 @@ export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set)
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// setSessionScope 由 /api/me 解析结果驱动(登录成功 / 开机水合后调用)。
|
||||||
|
// 持久化到 sessionStorage,使同 tab 刷新沿用,不必再次往返 /api/me 才能正确
|
||||||
|
// 隐藏受限入口。
|
||||||
|
setSessionScope: (scope: SessionScope) =>
|
||||||
|
{
|
||||||
|
if (typeof window !== "undefined")
|
||||||
|
{
|
||||||
|
window.sessionStorage.setItem(SESSION_SCOPE_KEY, scope);
|
||||||
|
}
|
||||||
|
set({ sessionScope: scope });
|
||||||
|
},
|
||||||
|
|
||||||
clearAuth: () =>
|
clearAuth: () =>
|
||||||
{
|
{
|
||||||
if (typeof window !== "undefined")
|
if (typeof window !== "undefined")
|
||||||
{
|
{
|
||||||
window.sessionStorage.removeItem(ACCESS_TOKEN_KEY);
|
window.sessionStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
window.sessionStorage.removeItem(USER_KEY);
|
window.sessionStorage.removeItem(USER_KEY);
|
||||||
|
window.sessionStorage.removeItem(SESSION_SCOPE_KEY);
|
||||||
}
|
}
|
||||||
set({ accessToken: null, user: null });
|
// scope 回 "full":登出后下一个登录者默认完整态,再由其自己的 /api/me 校正。
|
||||||
|
set({ accessToken: null, user: null, sessionScope: "full" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export interface DeviceInfo
|
|||||||
|
|
||||||
export type AuthMode = "dev" | "prod";
|
export type AuthMode = "dev" | "prod";
|
||||||
|
|
||||||
|
// 本会话的权限级别(来自 /api/me 的 scope):full=完整登录、guest=受限访客。
|
||||||
|
// 受限访客不能管理账号 / 授权设备——前端据此隐藏相关入口(后端亦 403 兜底)。
|
||||||
|
export type SessionScope = "full" | "guest";
|
||||||
|
|
||||||
export type ThemeMode = "light" | "dark" | "system";
|
export type ThemeMode = "light" | "dark" | "system";
|
||||||
|
|
||||||
// 客户端侧细颗粒度阶段,与服务端的 state(PENDING/ACCEPTED/...)正交;
|
// 客户端侧细颗粒度阶段,与服务端的 state(PENDING/ACCEPTED/...)正交;
|
||||||
@@ -118,7 +122,12 @@ export interface AppState
|
|||||||
authMode: AuthMode;
|
authMode: AuthMode;
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
user: User | null;
|
user: User | null;
|
||||||
|
// 本会话权限级别(/api/me 的 scope)。默认 "full";登录成功 / 开机水合后据
|
||||||
|
// /api/me 校正为 "guest" 时,UI 隐藏「扫码登录新设备」「移除设备」「登出他人」
|
||||||
|
// 等账号管理入口,并展示「受限访客」标识。
|
||||||
|
sessionScope: SessionScope;
|
||||||
setAuth: (a: { accessToken: string; user: User }) => void;
|
setAuth: (a: { accessToken: string; user: User }) => void;
|
||||||
|
setSessionScope: (scope: SessionScope) => void;
|
||||||
clearAuth: () => void;
|
clearAuth: () => void;
|
||||||
|
|
||||||
// ---- device slice ----
|
// ---- device slice ----
|
||||||
|
|||||||
Reference in New Issue
Block a user