扫码登录:cdrop 自签会话原语 + 薄账户层 + 受限访客 + 2FA step-up
身份仍源自 OAuth provider(user_id = OIDC sub),cdrop 在其上自维护一薄层: 自签会话令牌 + accounts 表,并新增扫码快速登录。实施蓝本见 AUTH.md。 后端 · 自签会话原语 - web_sessions 加 kind/scope/granted_by 列(bootstrap 幂等迁移);既有 oidc 会话行为不变,新增 self(滑动续期)/ guest(受限·不续)两类 - cdrop 自签 HS256 会话 access token,密钥派生自 SESSION_SECRET,与落盘 AES、 shortcut HS256 三密钥域隔离;jwtauth.verifySelfToken 无状态校验、靠 typ 区分 - requireFullSession 守卫:guest 会话不得改账号 / 再批准设备 / 签长效 token - /auth/refresh 按 kind 分流:self/guest 纯自签、不触 IdP 后端 · 扫码登录(internal/httpapi/qr.go) - qr/start・status・request・approve・deny 五端点 + login_requests 表 + reaper - 三密钥分离:QR 仅含批准信息,会话只投递给持私有 poll_secret 的原设备 (偷拍 QR 者无 poll_secret 领不到会话、未登录批不了准) - 会话在新设备侧领取(cookie 不经手机)、单次消费、短 TTL 后端 · 薄账户层与 2FA step-up - accounts 表(键 sub,不含任何凭证):exchange/refresh upsert match_key / 显示名 / 头像 / roles,供显示与未来管理员开启迁移标记时跨源关联 - step-up(默认关):开启后 qr/approve 要求新鲜 prompt=login 授权码,后端就地 换 id_token、JWKS 验签 + auth_time 窗口 + sub 匹配,provider 2FA 于此往返强制 前端(web/) - 显码页 /link/new + 批准页 /link + net/qr.ts,对齐 Theme B、复用 AuthShell 与聚珍排版管线、零新全局样式 - step-up 再认证流:批准前跑 prompt=login PKCE,回调分叉(不消费一次性 code、 独立 state key)后带 step_up_code/verifier 调 approve - 三语 i18n qr.*;新增 qrcode 依赖 修复 · clipboard sweeper(早已提交的损坏) - ClearExpiredClipboards 因 clipboard.sql 全角注释触发 sqlc 1.31 多字节偏移 bug,生成 SQL 被截断为「UPDATE clipboard_state SET content =」,sweeper 运行 期报「incomplete input」、过期剪贴板内容从未清除(短 TTL 暴露保护失效) - 注释改纯 ASCII 并加 bug 警告,重生成得完整 SQL;prod 已验证 sweeper 由 ERROR 转为正常清理(cleared count=1) 构建 - .dockerignore:排除本地 node_modules 等,避免宿主原生二进制污染镜像内 vite 构建 - Dockerfile.base:GOPROXY 改为可经 --build-arg 覆盖(默认仍官方代理,受限网络 构建时传区域镜像即可,仓库不固化区域值) 文档 - 新增 AUTH.md(账户与登录实施蓝本);README 特性;.env.example / compose.snippet 增配置项(QR / 自签会话 TTL / step-up / match_claim) 测试 - 自签 token 密钥域隔离、扫码端到端(领取 / 单次 / poll_secret 校验 / deny / step-up 门)、extractIdentity、web_sessions 升级迁移
This commit is contained in:
Generated
+331
@@ -13,7 +13,9 @@
|
||||
"@mantine/hooks": "^7.13.5",
|
||||
"@mantine/notifications": "^7.13.5",
|
||||
"@tanstack/react-router": "^1.82.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lucide-react": "^0.460.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"zustand": "^4.5.5"
|
||||
@@ -1595,6 +1597,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz",
|
||||
"integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -1602,6 +1613,15 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
@@ -1644,6 +1664,30 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansis": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
|
||||
@@ -1805,6 +1849,15 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@@ -1874,6 +1927,17 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -1883,6 +1947,24 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
@@ -1943,6 +2025,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
@@ -1966,6 +2057,12 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
@@ -1990,6 +2087,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
@@ -2102,6 +2205,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -2151,6 +2267,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
@@ -2225,6 +2350,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
@@ -2319,6 +2453,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -2458,6 +2604,51 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
@@ -2512,6 +2703,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.12",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||
@@ -2802,6 +3002,23 @@
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -3014,6 +3231,21 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -3156,6 +3388,12 @@
|
||||
"seroval": "^1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -3166,6 +3404,32 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
@@ -3392,6 +3656,12 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz",
|
||||
@@ -3622,6 +3892,32 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
@@ -3629,6 +3925,41 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"@mantine/hooks": "^7.13.5",
|
||||
"@mantine/notifications": "^7.13.5",
|
||||
"@tanstack/react-router": "^1.82.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lucide-react": "^0.460.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"zustand": "^4.5.5"
|
||||
|
||||
@@ -10,7 +10,8 @@ export interface AuthShellProps
|
||||
{
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
/** 卡内主体。过渡型屏(如「读取中…」)可只给 title + subtitle,省略 children。 */
|
||||
children?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
/** 卡内品牌字标。默认不渲染——顶栏已有 logo,卡内再放会一屏两 logo;
|
||||
* 需要时显式传入一个节点即可。 */
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useAppStore, type User } from "../../store";
|
||||
import { t } from "../../i18n";
|
||||
import { apiFetch } from "../../net/api";
|
||||
import { isDesktop, desktopRefresh, clearDesktopSession } from "../../net/desktop";
|
||||
import { stashStepUpPending } from "../qr/stepUp";
|
||||
|
||||
// ---- dev mode -------------------------------------------------------------
|
||||
|
||||
@@ -70,6 +71,12 @@ export async function fetchAuthConfig(): Promise<AuthConfig>
|
||||
const PKCE_VERIFIER_KEY = "cdrop.pkce_verifier";
|
||||
const OAUTH_STATE_KEY = "cdrop.oauth_state";
|
||||
|
||||
// step-up 再认证用一对独立的 state / verifier 句柄,绝不复用上面那对常规登录键——
|
||||
// 否则 /oauth/callback 在 step-up 往返里会被当成普通登录、误调 /api/auth/exchange。
|
||||
// state 走 sessionStorage(仅本次往返、关标签即清,与 step-up 暂存物同生命周期);
|
||||
// verifier 不单独落键,而是塞进 stepUp.ts 的 PENDING({verifier, returnTo})。
|
||||
const STEPUP_STATE_KEY = "cdrop.qr.stepup_state";
|
||||
|
||||
// loginProd kicks off OIDC PKCE: generate verifier+challenge+state, stash them
|
||||
// in localStorage, navigate the browser to the provider's /authorize endpoint.
|
||||
// The callback page completes the flow.
|
||||
@@ -110,6 +117,66 @@ export async function loginProd(): Promise<void>
|
||||
window.location.href = `${cfg.authorize_url}?${params}`;
|
||||
}
|
||||
|
||||
// startStepUpReauth 发起批准页 step-up 再认证:一次新鲜 PKCE 往返,但追加
|
||||
// prompt=login + max_age=0 强制 provider 现场重证(2FA 即在此处过)。复用既有
|
||||
// /oauth/callback 作 redirect_uri;跳转前用 sessionStorage 记下「待办」标记 +
|
||||
// 这次的 verifier + 要回到的批准页 URL(returnTo 走 stepUp.ts 的同款 open-redirect
|
||||
// 校验,仅允许 /link)。回调检测到「待办」即分叉,不消费 code(见 oauth.callback)。
|
||||
//
|
||||
// 注意:这里不写常规 PKCE_VERIFIER_KEY / OAUTH_STATE_KEY,否则普通登录回调会误判。
|
||||
// verifier 进 PENDING、state 进 STEPUP_STATE_KEY,二者均会在回调与批准页里清掉。
|
||||
export async function startStepUpReauth(returnTo: string): Promise<void>
|
||||
{
|
||||
const cfg = await fetchAuthConfig();
|
||||
if (cfg.auth_mode !== "prod")
|
||||
{
|
||||
throw new Error(`server reports auth_mode=${cfg.auth_mode}, expected prod`);
|
||||
}
|
||||
if (!cfg.authorize_url || !cfg.client_id || !cfg.redirect_uri)
|
||||
{
|
||||
throw new Error("OIDC config incomplete (authorize_url / client_id / redirect_uri)");
|
||||
}
|
||||
|
||||
const verifier = generateRandomBase64url(32);
|
||||
const challenge = await sha256Base64url(verifier);
|
||||
const state = generateRandomBase64url(16);
|
||||
|
||||
// 先把「待办」落定(含 returnTo 的 open-redirect 校验);非法即拒,不发起跳转。
|
||||
if (!stashStepUpPending({ verifier, returnTo }))
|
||||
{
|
||||
throw new Error("invalid step-up return path");
|
||||
}
|
||||
sessionStorage.setItem(STEPUP_STATE_KEY, state);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: cfg.client_id,
|
||||
redirect_uri: cfg.redirect_uri,
|
||||
response_type: "code",
|
||||
scope: cfg.scopes || "openid profile email",
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
prompt: "login",
|
||||
max_age: "0",
|
||||
});
|
||||
window.location.href = `${cfg.authorize_url}?${params}`;
|
||||
}
|
||||
|
||||
// verifyStepUpState 在 /oauth/callback 的 step-up 分叉里校 state(防 CSRF,与常规
|
||||
// 登录回调同口径)。匹配即消费掉 STEPUP_STATE_KEY 并返回 true;不匹配返回 false。
|
||||
export function verifyStepUpState(state: string): boolean
|
||||
{
|
||||
const expected = sessionStorage.getItem(STEPUP_STATE_KEY);
|
||||
sessionStorage.removeItem(STEPUP_STATE_KEY);
|
||||
return !!expected && state === expected;
|
||||
}
|
||||
|
||||
// clearStepUpState 兜底清掉 step-up state(异常路径,避免残留)。
|
||||
export function clearStepUpState(): void
|
||||
{
|
||||
sessionStorage.removeItem(STEPUP_STATE_KEY);
|
||||
}
|
||||
|
||||
interface ExchangeResp
|
||||
{
|
||||
access_token: string;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/* QrCanvas:二维码主体 + 四角定位记号。视觉语言对齐 Theme B——
|
||||
recessed(下沉表面)plate 让二维码读作「一个要对准相机的对象」,而非装饰。 */
|
||||
|
||||
.frame
|
||||
{
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* 二维码取色令牌:module(深)= 前景文字色,底(浅)= 抬升表面。
|
||||
两者在浅 / 深主题里自然反转,对比始终 >7:1 满足扫码。 */
|
||||
--qr-dark: var(--text);
|
||||
--qr-light: var(--surface-raised);
|
||||
}
|
||||
|
||||
.canvas
|
||||
{
|
||||
display: block;
|
||||
border-radius: var(--radius-control);
|
||||
/* 切到新二维码时淡入,避免位图突变;reduced-motion 下 motion.css 兜底归零。 */
|
||||
transition: opacity var(--dur-base) var(--ease-out);
|
||||
}
|
||||
|
||||
/* 四角定位记号:1.5px 描边的 L 形角标,复用 DeviceTypeIcon 的线稿语汇。
|
||||
accent 着色——是整块里唯一允许的「克制的大胆」着色点。 */
|
||||
.tick
|
||||
{
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1.5px solid var(--accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tl { top: calc(var(--space-2) * -1); left: calc(var(--space-2) * -1);
|
||||
border-right: none; border-bottom: none; border-top-left-radius: 3px; }
|
||||
.tr { top: calc(var(--space-2) * -1); right: calc(var(--space-2) * -1);
|
||||
border-left: none; border-bottom: none; border-top-right-radius: 3px; }
|
||||
.bl { bottom: calc(var(--space-2) * -1); left: calc(var(--space-2) * -1);
|
||||
border-right: none; border-top: none; border-bottom-left-radius: 3px; }
|
||||
.br { bottom: calc(var(--space-2) * -1); right: calc(var(--space-2) * -1);
|
||||
border-left: none; border-top: none; border-bottom-right-radius: 3px; }
|
||||
@@ -0,0 +1,68 @@
|
||||
import QRCode from "qrcode";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import s from "./QrCanvas.module.css";
|
||||
|
||||
export interface QrCanvasProps
|
||||
{
|
||||
/** 编码进二维码的完整 URL(qrPayload)。 */
|
||||
value: string;
|
||||
/** 绘制边长(CSS px);实际位图按 devicePixelRatio 放大以求锐利。默认 220。 */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// QrCanvas:显码页的签名元素——把 qrPayload 渲染成一枚平静、可信的二维码。
|
||||
//
|
||||
// 取色不写死黑白,而是读当前主题的语义令牌(--surface-raised 作 light module、
|
||||
// --text 作 dark module),让二维码在浅 / 深主题里都贴合卡面、不像一块外来贴纸;
|
||||
// 又保留足够明暗对比满足扫码识别(--text 对 --surface-raised 始终 >7:1)。
|
||||
// 四角的定位记号(registration marks)由 CSS 叠加,呼应「对准、扫描」这一动作本身。
|
||||
export function QrCanvas(props: QrCanvasProps)
|
||||
{
|
||||
const { value, size = 220 } = props;
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [ error, setError ] = useState(false);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !value) { return; }
|
||||
|
||||
let cancelled = false;
|
||||
setError(false);
|
||||
|
||||
// 从计算样式读语义令牌;module 用最深的前景、底用抬升表面,保证对比与贴合。
|
||||
const styles = getComputedStyle(canvas);
|
||||
const dark = styles.getPropertyValue("--qr-dark").trim() || "#1F1F1F";
|
||||
const light = styles.getPropertyValue("--qr-light").trim() || "#FFFFFF";
|
||||
|
||||
QRCode.toCanvas(canvas, value, {
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 0, // 留白由外层 CSS 容器负责,位图本身贴满
|
||||
width: size,
|
||||
color: { dark, light },
|
||||
}).then(
|
||||
() => { /* 成功:canvas 已就绪 */ },
|
||||
() => { if (!cancelled) { setError(true); } },
|
||||
);
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [ value, size ]);
|
||||
|
||||
return (
|
||||
<div className={s.frame} style={{ width: size, height: size }}>
|
||||
<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" />
|
||||
{/* role=img + aria-label:屏幕阅读器把整块当作一个二维码图像,不读出
|
||||
URL(无意义且冗长);视障用户改走批准页的语义流程。 */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={s.canvas}
|
||||
role="img"
|
||||
aria-label="QR"
|
||||
style={{ width: size, height: size, opacity: error ? 0 : 1 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// 批准页的 redirect-back:/link?r=…&c=… 要求已登录,但既有登录流程(OAuth 整页
|
||||
// 跳转 + 回调落 "/")不带返回地址。这里用 sessionStorage 暂存意图返回的 /link URL:
|
||||
// - /link 在未登录时 stash 当前 URL,再走标准登录;
|
||||
// - 首页 beforeLoad consume 它——登录后落到 "/" 即被弹回 /link。
|
||||
// sessionStorage(非 localStorage):仅本次登录回流需要,关标签即清,且不跨设备。
|
||||
|
||||
const RETURN_TO_KEY = "cdrop.qr.return_to";
|
||||
|
||||
// 仅允许站内 /link 路径回流,杜绝 open-redirect(外部 URL / 协议相对地址)。
|
||||
function isSafeLinkPath(path: string): boolean
|
||||
{
|
||||
return path.startsWith("/link?") || path === "/link";
|
||||
}
|
||||
|
||||
export function stashLinkReturn(path: string): void
|
||||
{
|
||||
if (typeof window === "undefined") { return; }
|
||||
if (!isSafeLinkPath(path)) { return; }
|
||||
window.sessionStorage.setItem(RETURN_TO_KEY, path);
|
||||
}
|
||||
|
||||
// consumeLinkReturn 取出并清除暂存的返回地址(一次性)。无 / 非法返回 null。
|
||||
export function consumeLinkReturn(): string | null
|
||||
{
|
||||
if (typeof window === "undefined") { return null; }
|
||||
const path = window.sessionStorage.getItem(RETURN_TO_KEY);
|
||||
window.sessionStorage.removeItem(RETURN_TO_KEY);
|
||||
if (!path || !isSafeLinkPath(path)) { return null; }
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// 批准页 step-up 再认证的会话级暂存。step_up 开启时,批准方在批准新设备前必须做
|
||||
// 一次新鲜的 prompt=login 再登录(provider 若开了 2FA 即在此处过),把这次拿到的
|
||||
// 授权码交给后端就地核验。这段往返跨一次整页跳转(→ provider → /oauth/callback →
|
||||
// 批准页),故用 sessionStorage 串起三个状态:
|
||||
//
|
||||
// 1. 跳转去 provider 前:记下「待办」标记 + 这次的 code_verifier + 要回到的批准页
|
||||
// URL(PENDING)。
|
||||
// 2. /oauth/callback 落地(检测到 PENDING):不调 /api/auth/exchange(那会在服务端
|
||||
// 消费掉 code 并轮换会话),转而把本次回调的 {code, verifier} 暂存进 STASH,
|
||||
// 清掉 PENDING,整页跳回批准页 URL。
|
||||
// 3. 批准页回到后:取出 STASH 的 {code, verifier} → 调 qrApprove 就地核验 → 用完即清。
|
||||
//
|
||||
// sessionStorage(非 localStorage):仅本次再认证往返需要,关标签即清、不跨设备;
|
||||
// code 是一次性的且与本批准会话强绑定,落 localStorage 反而徒增泄漏面。
|
||||
|
||||
const PENDING_KEY = "cdrop.qr.stepup_pending"; // { verifier, returnTo }
|
||||
const STASH_KEY = "cdrop.qr.stepup_stash"; // { code, verifier }
|
||||
|
||||
// 仅允许站内 /link 路径回流,杜绝 open-redirect(外部 URL / 协议相对地址)。
|
||||
// 与 returnTo.ts 同口径——step-up 往返回来后的整页跳转目标只能是批准页。
|
||||
function isSafeLinkPath(path: string): boolean
|
||||
{
|
||||
return path.startsWith("/link?") || path === "/link";
|
||||
}
|
||||
|
||||
export interface StepUpPending
|
||||
{
|
||||
verifier: string;
|
||||
returnTo: string;
|
||||
}
|
||||
|
||||
export interface StepUpStash
|
||||
{
|
||||
code: string;
|
||||
verifier: string;
|
||||
}
|
||||
|
||||
// stashStepUpPending 在跳转去 provider 前记下「待办」。returnTo 非法(防开放重定向)
|
||||
// 即不写——调用方据返回值判定是否安全发起跳转。
|
||||
export function stashStepUpPending(pending: StepUpPending): boolean
|
||||
{
|
||||
if (typeof window === "undefined") { return false; }
|
||||
if (!pending.verifier || !isSafeLinkPath(pending.returnTo)) { return false; }
|
||||
window.sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
|
||||
return true;
|
||||
}
|
||||
|
||||
// peekStepUpPending 只读地探测是否在 step-up 往返中(/oauth/callback 据此分叉,
|
||||
// 决定不走 /api/auth/exchange)。不消费。
|
||||
export function peekStepUpPending(): StepUpPending | null
|
||||
{
|
||||
if (typeof window === "undefined") { return null; }
|
||||
const raw = window.sessionStorage.getItem(PENDING_KEY);
|
||||
if (!raw) { return null; }
|
||||
const p = safeParse<StepUpPending>(raw);
|
||||
if (!p?.verifier || !isSafeLinkPath(p.returnTo)) { return null; }
|
||||
return p;
|
||||
}
|
||||
|
||||
export function clearStepUpPending(): void
|
||||
{
|
||||
if (typeof window === "undefined") { return; }
|
||||
window.sessionStorage.removeItem(PENDING_KEY);
|
||||
}
|
||||
|
||||
// stashStepUpCode 把回调拿到的一次性 code + 配套 verifier 暂存,留给批准页交给
|
||||
// 后端 approve 核验。前端绝不自己 POST /exchange 消费它。
|
||||
export function stashStepUpCode(stash: StepUpStash): void
|
||||
{
|
||||
if (typeof window === "undefined") { return; }
|
||||
if (!stash.code || !stash.verifier) { return; }
|
||||
window.sessionStorage.setItem(STASH_KEY, JSON.stringify(stash));
|
||||
}
|
||||
|
||||
// consumeStepUpCode 取出并清除暂存的 {code, verifier}(一次性,用完即清,避免泄漏
|
||||
// 与复用)。无 / 非法返回 null。
|
||||
export function consumeStepUpCode(): StepUpStash | null
|
||||
{
|
||||
if (typeof window === "undefined") { return null; }
|
||||
const raw = window.sessionStorage.getItem(STASH_KEY);
|
||||
window.sessionStorage.removeItem(STASH_KEY);
|
||||
if (!raw) { return null; }
|
||||
const s = safeParse<StepUpStash>(raw);
|
||||
if (!s?.code || !s.verifier) { return null; }
|
||||
return s;
|
||||
}
|
||||
|
||||
export function clearStepUpStash(): void
|
||||
{
|
||||
if (typeof window === "undefined") { return; }
|
||||
window.sessionStorage.removeItem(STASH_KEY);
|
||||
}
|
||||
|
||||
function safeParse<T>(raw: string): T | null
|
||||
{
|
||||
try { return JSON.parse(raw) as T; }
|
||||
catch { return null; }
|
||||
}
|
||||
@@ -93,6 +93,50 @@ export const enUS: Partial<TranslationDict> = {
|
||||
"oauth.signingIn": "Signing in…",
|
||||
"oauth.missingParams": "Missing 'code' or 'state' query parameter",
|
||||
|
||||
// ---- QR sign-in · entry --------------------------------------------
|
||||
"qr.entry.fromLogin": "Sign in with a QR code",
|
||||
|
||||
// ---- QR sign-in · show page (new device) ---------------------------
|
||||
"qr.show.title": "Sign in with a QR code",
|
||||
"qr.show.subtitle": "Name this device, then scan the code from a signed-in phone to let it into your account.",
|
||||
"qr.show.nameHint": "Shown to your other devices. ASCII characters only.",
|
||||
"qr.show.generate": "Generate code",
|
||||
"qr.show.scanTitle": "Scan to approve",
|
||||
"qr.show.howto1": "Open the camera or cdrop on another signed-in device.",
|
||||
"qr.show.howto2": "Scan the code above and approve this device.",
|
||||
"qr.show.waiting": "Waiting for approval…",
|
||||
"qr.show.approved": "Approved — signing you in…",
|
||||
"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.regenerate": "Generate a new code",
|
||||
|
||||
// ---- QR sign-in · approve page (phone) -----------------------------
|
||||
"qr.approve.title": "Approve a new device",
|
||||
"qr.approve.subtitle": "Confirm this device may sign in to your account. Check the device and origin first.",
|
||||
"qr.approve.loading": "Reading device details…",
|
||||
"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.trustLabel": "Trust duration",
|
||||
"qr.approve.trust.title": "Trust this device",
|
||||
"qr.approve.trust.hint": "Skip approval for 7 days, renewed on each use.",
|
||||
"qr.approve.once.title": "Just this once",
|
||||
"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.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.button": "Verify and approve",
|
||||
"qr.approve.stepUp.rejected": "Identity verification didn't pass or has expired. Verify again to confirm it's you, then approve.",
|
||||
"qr.approve.deny": "Decline",
|
||||
"qr.approve.doneApprovedTitle": "Approved",
|
||||
"qr.approve.doneApproved": "This device is now linked to your account. You can send and receive files on it.",
|
||||
"qr.approve.doneDeniedTitle": "Declined",
|
||||
"qr.approve.doneDenied": "The request was declined. That device won't sign in to your account.",
|
||||
"qr.approve.expiredTitle": "Code expired",
|
||||
"qr.approve.expired": "This code is no longer valid. Generate a new one on the device and scan again.",
|
||||
"qr.approve.errorTitle": "Something went wrong",
|
||||
"qr.approve.error": "That didn't go through. Try again, or generate a new code on the device.",
|
||||
"qr.approve.backHome": "Back to home",
|
||||
|
||||
// ---- settings -------------------------------------------------------
|
||||
"settings.title": "Settings",
|
||||
"settings.currentDevice.title": "This device",
|
||||
|
||||
@@ -91,6 +91,50 @@ export const zhCN = {
|
||||
"oauth.signingIn": "正在登录…",
|
||||
"oauth.missingParams": "缺少code或state查询参数",
|
||||
|
||||
// ---- 扫码登录 · 入口 -------------------------------------------------
|
||||
"qr.entry.fromLogin": "扫码登录此设备",
|
||||
|
||||
// ---- 扫码登录 · 显码页(新设备)-------------------------------------
|
||||
"qr.show.title": "扫码登录此设备",
|
||||
"qr.show.subtitle": "先给这台设备起个名字,再用已登录的手机扫码批准它接入你的账号。",
|
||||
"qr.show.nameHint": "此名称会展示给你的其他设备,仅可使用 ASCII 字符。",
|
||||
"qr.show.generate": "生成二维码",
|
||||
"qr.show.scanTitle": "用手机扫码批准",
|
||||
"qr.show.howto1": "在另一台已登录的设备上打开相机或 cdrop。",
|
||||
"qr.show.howto2": "扫描上方二维码,按提示批准此设备。",
|
||||
"qr.show.waiting": "等待批准…",
|
||||
"qr.show.approved": "已批准,正在进入…",
|
||||
"qr.show.expired": "二维码已过期。重新生成一张再扫。",
|
||||
"qr.show.denied": "这次接入被拒绝。如确为你本人操作,重新生成二维码再试。",
|
||||
"qr.show.regenerate": "重新生成二维码",
|
||||
|
||||
// ---- 扫码登录 · 批准页(手机)-------------------------------------
|
||||
"qr.approve.title": "批准新设备",
|
||||
"qr.approve.subtitle": "确认让这台设备登入你的账号。请核对设备与来源无误。",
|
||||
"qr.approve.loading": "正在读取设备信息…",
|
||||
"qr.approve.badLink": "这个批准链接不完整。请在新设备上重新生成二维码再扫。",
|
||||
"qr.approve.signInFirst": "先登录你的账号,再批准这台设备接入。",
|
||||
"qr.approve.trustLabel": "信任时长",
|
||||
"qr.approve.trust.title": "信任此设备",
|
||||
"qr.approve.trust.hint": "7 天内免再次批准,每次使用自动续期。",
|
||||
"qr.approve.once.title": "仅此一次",
|
||||
"qr.approve.once.hint": "登录有效 1 小时,到期需重新扫码。",
|
||||
"qr.approve.scopeGuest": "这台设备将以受限访客身份登入:可收发文件与消息,但不能更改账号、不能批准其他设备。",
|
||||
"qr.approve.approve": "批准登入",
|
||||
"qr.approve.stepUp.notice": "为安全起见,批准前需先验证你的身份——点下方按钮会引导你重新登录一次。",
|
||||
"qr.approve.stepUp.button": "验证并批准",
|
||||
"qr.approve.stepUp.rejected": "身份验证未通过或已过期。请再验证一次,确认是你本人后即可批准。",
|
||||
"qr.approve.deny": "拒绝",
|
||||
"qr.approve.doneApprovedTitle": "已批准",
|
||||
"qr.approve.doneApproved": "这台设备已接入你的账号,现在即可在它上面收发文件。",
|
||||
"qr.approve.doneDeniedTitle": "已拒绝",
|
||||
"qr.approve.doneDenied": "这次接入已被拒绝,那台设备不会登入你的账号。",
|
||||
"qr.approve.expiredTitle": "二维码已过期",
|
||||
"qr.approve.expired": "这张二维码已失效。请在新设备上重新生成再扫。",
|
||||
"qr.approve.errorTitle": "出错了",
|
||||
"qr.approve.error": "操作没能完成。请重试,或在新设备上重新生成二维码。",
|
||||
"qr.approve.backHome": "返回主页",
|
||||
|
||||
// ---- 设置页 ----------------------------------------------------------
|
||||
"settings.title": "设置",
|
||||
"settings.currentDevice.title": "当前设备",
|
||||
|
||||
@@ -95,6 +95,50 @@ export const zhTW: Partial<TranslationDict> = {
|
||||
"oauth.signingIn": "正在登入…",
|
||||
"oauth.missingParams": "缺少code或state查詢參數",
|
||||
|
||||
// ---- 掃碼登入 · 入口 -------------------------------------------------
|
||||
"qr.entry.fromLogin": "掃碼登入此裝置",
|
||||
|
||||
// ---- 掃碼登入 · 顯碼頁(新裝置)-------------------------------------
|
||||
"qr.show.title": "掃碼登入此裝置",
|
||||
"qr.show.subtitle": "先為這部裝置命名,再用已登入的手機掃碼批准它接入你的帳號。",
|
||||
"qr.show.nameHint": "此名稱會顯示給你的其他裝置,僅可使用 ASCII 字元。",
|
||||
"qr.show.generate": "產生 QR 碼",
|
||||
"qr.show.scanTitle": "用手機掃碼批准",
|
||||
"qr.show.howto1": "在另一部已登入的裝置上開啟相機或 cdrop。",
|
||||
"qr.show.howto2": "掃描上方 QR 碼,依提示批准此裝置。",
|
||||
"qr.show.waiting": "等待批准…",
|
||||
"qr.show.approved": "已批准,正在進入…",
|
||||
"qr.show.expired": "QR 碼已逾時。重新產生一張再掃。",
|
||||
"qr.show.denied": "這次接入被拒絕。如確為你本人操作,重新產生 QR 碼再試。",
|
||||
"qr.show.regenerate": "重新產生 QR 碼",
|
||||
|
||||
// ---- 掃碼登入 · 批准頁(手機)-------------------------------------
|
||||
"qr.approve.title": "批准新裝置",
|
||||
"qr.approve.subtitle": "確認讓這部裝置登入你的帳號。請核對裝置與來源無誤。",
|
||||
"qr.approve.loading": "正在讀取裝置資訊…",
|
||||
"qr.approve.badLink": "這個批准連結不完整。請在新裝置上重新產生 QR 碼再掃。",
|
||||
"qr.approve.signInFirst": "先登入你的帳號,再批准這部裝置接入。",
|
||||
"qr.approve.trustLabel": "信任時長",
|
||||
"qr.approve.trust.title": "信任此裝置",
|
||||
"qr.approve.trust.hint": "7 天內免再次批准,每次使用自動續期。",
|
||||
"qr.approve.once.title": "僅此一次",
|
||||
"qr.approve.once.hint": "登入有效 1 小時,逾時需重新掃碼。",
|
||||
"qr.approve.scopeGuest": "這部裝置將以受限訪客身分登入:可收發檔案與訊息,但不能變更帳號、不能批准其他裝置。",
|
||||
"qr.approve.approve": "批准登入",
|
||||
"qr.approve.stepUp.notice": "為安全起見,批准前需先驗證你的身分——點下方按鈕會引導你重新登入一次。",
|
||||
"qr.approve.stepUp.button": "驗證並批准",
|
||||
"qr.approve.stepUp.rejected": "身分驗證未通過或已逾時。請再驗證一次,確認是你本人後即可批准。",
|
||||
"qr.approve.deny": "拒絕",
|
||||
"qr.approve.doneApprovedTitle": "已批准",
|
||||
"qr.approve.doneApproved": "這部裝置已接入你的帳號,現在即可在它上面收發檔案。",
|
||||
"qr.approve.doneDeniedTitle": "已拒絕",
|
||||
"qr.approve.doneDenied": "這次接入已被拒絕,那部裝置不會登入你的帳號。",
|
||||
"qr.approve.expiredTitle": "QR 碼已逾時",
|
||||
"qr.approve.expired": "這張 QR 碼已失效。請在新裝置上重新產生再掃。",
|
||||
"qr.approve.errorTitle": "出錯了",
|
||||
"qr.approve.error": "操作沒能完成。請重試,或在新裝置上重新產生 QR 碼。",
|
||||
"qr.approve.backHome": "返回首頁",
|
||||
|
||||
// ---- 設定頁 ----------------------------------------------------------
|
||||
"settings.title": "設定",
|
||||
"settings.currentDevice.title": "目前裝置",
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { apiFetch, apiJSON } from "./api";
|
||||
|
||||
// 扫码登录的网络封装。两条信任边界:
|
||||
// - 显码页(新设备)在 start 时还未登录,故 start / status 是公开端点,
|
||||
// 凭 request_id + 一次性 poll_secret 自证(poll_secret 走 X-Poll-Secret 头,
|
||||
// 绝不进 URL / 日志)。
|
||||
// - 批准页(手机)已完整登录,request / approve / deny 走既有 apiFetch 带 Bearer。
|
||||
// refresh_token 永不进 JS——approved 时服务端在响应里 Set-Cookie 下发会话 cookie,
|
||||
// access_token 由调用方 setAuth 进 sessionStorage,与既有 OAuth 流程同口径。
|
||||
|
||||
// ---- 显码页(公开端点:无 Bearer,凭 poll_secret 自证)----------------------
|
||||
|
||||
export interface QrStartReq
|
||||
{
|
||||
deviceName: string;
|
||||
deviceType: string;
|
||||
}
|
||||
|
||||
export interface QrStartResp
|
||||
{
|
||||
requestId: string;
|
||||
pollSecret: string;
|
||||
qrPayload: string; // 编码进二维码的完整 URL(/link?r=…&c=…)
|
||||
expiresAt: number; // epoch 秒
|
||||
}
|
||||
|
||||
interface QrStartRespRaw
|
||||
{
|
||||
request_id: string;
|
||||
poll_secret: string;
|
||||
qr_payload: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
// qrStart 开一次扫码登录会话。公开端点:手动 fetch(不走 apiFetch,避免注入 Bearer
|
||||
// 与 X-Device-* 头),仅同源 JSON。
|
||||
export async function qrStart(req: QrStartReq): Promise<QrStartResp>
|
||||
{
|
||||
const r = await fetch("/api/auth/qr/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ device_name: req.deviceName, device_type: req.deviceType }),
|
||||
});
|
||||
if (!r.ok)
|
||||
{
|
||||
const text = await r.text().catch(() => r.statusText);
|
||||
throw new Error(`qr start ${r.status}: ${text}`);
|
||||
}
|
||||
const data = await r.json() as QrStartRespRaw;
|
||||
return {
|
||||
requestId: data.request_id,
|
||||
pollSecret: data.poll_secret,
|
||||
qrPayload: data.qr_payload,
|
||||
expiresAt: data.expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
export type QrStatusPhase = "pending" | "approved" | "denied" | "expired";
|
||||
|
||||
export interface QrApprovedUser
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface QrStatus
|
||||
{
|
||||
status: QrStatusPhase;
|
||||
// status === "approved" 时一并带回:
|
||||
accessToken?: string;
|
||||
expiresIn?: number;
|
||||
user?: QrApprovedUser;
|
||||
deviceName?: string;
|
||||
}
|
||||
|
||||
interface QrStatusRaw
|
||||
{
|
||||
status: QrStatusPhase;
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
user?: QrApprovedUser;
|
||||
device_name?: string;
|
||||
}
|
||||
|
||||
// qrStatusOnce 长轮询一轮 status(服务端可能挂起约 25s 再返回)。公开端点:凭
|
||||
// X-Poll-Secret 头自证。signal 可取消(页面卸载 / 重新生成二维码即 abort)。
|
||||
async function qrStatusOnce(
|
||||
requestId: string,
|
||||
pollSecret: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<QrStatus>
|
||||
{
|
||||
const url = `/api/auth/qr/status?request_id=${encodeURIComponent(requestId)}`;
|
||||
const r = await fetch(url, { headers: { "X-Poll-Secret": pollSecret }, signal });
|
||||
if (!r.ok)
|
||||
{
|
||||
const text = await r.text().catch(() => r.statusText);
|
||||
throw new Error(`qr status ${r.status}: ${text}`);
|
||||
}
|
||||
const data = await r.json() as QrStatusRaw;
|
||||
return {
|
||||
status: data.status,
|
||||
accessToken: data.access_token,
|
||||
expiresIn: data.expires_in,
|
||||
user: data.user,
|
||||
deviceName: data.device_name,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PollHandlers
|
||||
{
|
||||
// 每轮返回非 pending 终态即调用一次。pending 由轮询循环内部静默续轮,不上抛。
|
||||
onResolved: (status: QrStatus) => void;
|
||||
// 单轮网络出错时调用(仍会退避后继续轮询,不终止)。可用于安静地展示「重试中」。
|
||||
onError?: (err: unknown) => void;
|
||||
}
|
||||
|
||||
// pollQrStatus 驱动长轮询循环直到拿到终态(approved / denied / expired)或被取消。
|
||||
// 返回一个 cancel 函数;调用即 abort 在途请求并停止循环。单轮异常(含超时)退避
|
||||
// 后重试,不终止——长轮询本就预期被服务端 / 代理周期性切断。
|
||||
export function pollQrStatus(
|
||||
requestId: string,
|
||||
pollSecret: string,
|
||||
handlers: PollHandlers,
|
||||
): () => void
|
||||
{
|
||||
const ctrl = new AbortController();
|
||||
let stopped = false;
|
||||
|
||||
const loop = async () =>
|
||||
{
|
||||
while (!stopped)
|
||||
{
|
||||
try
|
||||
{
|
||||
const status = await qrStatusOnce(requestId, pollSecret, ctrl.signal);
|
||||
if (stopped) { return; }
|
||||
if (status.status !== "pending")
|
||||
{
|
||||
handlers.onResolved(status);
|
||||
return;
|
||||
}
|
||||
// pending:服务端长轮询已自然超时返回,立即续下一轮(无需退避)。
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
if (stopped || ctrl.signal.aborted) { return; }
|
||||
handlers.onError?.(err);
|
||||
// 网络抖动 / 代理切断:短退避后重试,避免忙等打满。
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loop();
|
||||
|
||||
return () =>
|
||||
{
|
||||
stopped = true;
|
||||
ctrl.abort();
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 批准页(需完整登录:走 apiFetch 带 Bearer)------------------------------
|
||||
|
||||
export type QrScope = "full" | "guest";
|
||||
export type QrPersist = "once" | "persist";
|
||||
|
||||
export interface QrRequestInfo
|
||||
{
|
||||
deviceName: string;
|
||||
deviceType: string;
|
||||
requestIp: string;
|
||||
expiresAt: number;
|
||||
status: QrStatusPhase;
|
||||
// step_up 开启时为 true:批准前必须做一次新鲜的 prompt=login 再认证,把这次
|
||||
// 拿到的授权码 + verifier 交给 approve 就地核验(见 features/qr/stepUp.ts)。
|
||||
stepUp: boolean;
|
||||
}
|
||||
|
||||
interface QrRequestInfoRaw
|
||||
{
|
||||
device_name: string;
|
||||
device_type: string;
|
||||
request_ip: string;
|
||||
expires_at: number;
|
||||
status: QrStatusPhase;
|
||||
step_up?: boolean;
|
||||
}
|
||||
|
||||
// qrRequest 取待批准设备的信息,批准页据此渲染安全确认。需完整登录(apiFetch 带
|
||||
// Bearer + 401 自动续期)。
|
||||
export async function qrRequest(requestId: string, code: string): Promise<QrRequestInfo>
|
||||
{
|
||||
const url = `/api/auth/qr/request?request_id=${encodeURIComponent(requestId)}`
|
||||
+ `&code=${encodeURIComponent(code)}`;
|
||||
const data = await apiJSON<QrRequestInfoRaw>(url);
|
||||
return {
|
||||
deviceName: data.device_name,
|
||||
deviceType: data.device_type,
|
||||
requestIp: data.request_ip,
|
||||
expiresAt: data.expires_at,
|
||||
status: data.status,
|
||||
stepUp: data.step_up === true,
|
||||
};
|
||||
}
|
||||
|
||||
// stepUpRequired 标记 approve 因 step-up 再认证缺失 / 过期被后端拒(403
|
||||
// {error:"step_up_required"})。批准页据此把用户带回再认证一遭,而非当成普通错误。
|
||||
export class StepUpRequiredError extends Error
|
||||
{
|
||||
constructor()
|
||||
{
|
||||
super("step_up_required");
|
||||
this.name = "StepUpRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
// qrApprove 批准该设备登入本账号。scope 本期固定 guest(受限访客);persist 决定
|
||||
// 信任时长(persist=7 天滑动 / once=1 小时)。成功 204。
|
||||
//
|
||||
// step_up 开启时须带上一次新鲜 prompt=login 再认证拿到的 stepUpCode + stepUpVerifier,
|
||||
// 后端就地向 IdP 换 id_token、验签 + 校 auth_time 新鲜 + sub 匹配;缺失 / 过期 →
|
||||
// 403 step_up_required(抛 StepUpRequiredError)。step_up 关时这俩字段被后端忽略。
|
||||
export async function qrApprove(
|
||||
requestId: string,
|
||||
code: string,
|
||||
scope: QrScope,
|
||||
persist: QrPersist,
|
||||
stepUpCode?: string,
|
||||
stepUpVerifier?: string,
|
||||
): Promise<void>
|
||||
{
|
||||
const body: Record<string, string> = {
|
||||
request_id: requestId,
|
||||
approval_code: code,
|
||||
scope,
|
||||
persist,
|
||||
};
|
||||
if (stepUpCode && stepUpVerifier)
|
||||
{
|
||||
body.step_up_code = stepUpCode;
|
||||
body.step_up_verifier = stepUpVerifier;
|
||||
}
|
||||
const r = await apiFetch("/api/auth/qr/approve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
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(`qr approve ${r.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
// qrDeny 拒绝该设备。成功 204。
|
||||
export async function qrDeny(requestId: string, code: string): Promise<void>
|
||||
{
|
||||
const r = await apiFetch("/api/auth/qr/deny", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request_id: requestId, approval_code: code }),
|
||||
});
|
||||
if (!r.ok)
|
||||
{
|
||||
const text = await r.text().catch(() => r.statusText);
|
||||
throw new Error(`qr deny ${r.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void>
|
||||
{
|
||||
return new Promise((resolve) => { window.setTimeout(resolve, ms); });
|
||||
}
|
||||
@@ -307,11 +307,15 @@ function ConnectionIcon(props: { connected: boolean })
|
||||
);
|
||||
}
|
||||
|
||||
// 这些路由自己撑满 viewport,root layout 不再套 AppShell。
|
||||
// 这些路由自己撑满 viewport(各自渲染 AuthShell),root layout 不再套 AppShell,
|
||||
// 否则会出现 AppShell 顶栏 + AuthShell 全屏壳的双层 chrome。扫码登录的显码页 /
|
||||
// 批准页同属此列。
|
||||
function isAuthRoute(pathname: string): boolean
|
||||
{
|
||||
return pathname === "/login"
|
||||
|| pathname === "/setup"
|
||||
|| pathname === "/link"
|
||||
|| pathname === "/link/new"
|
||||
|| pathname.startsWith("/oauth/");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo } from "react";
|
||||
import { Container } from "@mantine/core";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useAppStore } from "../store";
|
||||
import { consumeLinkReturn } from "../features/qr/returnTo";
|
||||
import { DeviceStrip } from "../features/devices/DeviceStrip";
|
||||
import { Composer } from "../features/transfer/Composer";
|
||||
import { ActivityFeed } from "../features/transfer/ActivityFeed";
|
||||
@@ -21,6 +22,9 @@ export const Route = createFileRoute("/")({
|
||||
const { user, selfDeviceName } = useAppStore.getState();
|
||||
if (!user) { throw redirect({ to: "/login", search: { dev_user: undefined } }); }
|
||||
if (!selfDeviceName) { throw redirect({ to: "/setup" }); }
|
||||
// 批准页 redirect-back:登录后落到 "/" 时若有暂存的 /link 返回地址,弹回去。
|
||||
const back = consumeLinkReturn();
|
||||
if (back) { throw redirect({ to: back }); }
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/* 批准页:一次安全确认,deliberate 而明确。 */
|
||||
|
||||
/* 待批准设备身份行 */
|
||||
.device
|
||||
{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.deviceIcon
|
||||
{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.deviceMeta
|
||||
{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.deviceName
|
||||
{
|
||||
font-size: var(--fs-15);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deviceSub
|
||||
{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dot { color: var(--divider-strong); }
|
||||
|
||||
.ip
|
||||
{
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 信任时长两选项 */
|
||||
.choices
|
||||
{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.choice
|
||||
{
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--divider);
|
||||
border-left: 3px solid var(--divider);
|
||||
border-radius: var(--radius-control);
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
background-color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.choice:hover { border-color: var(--divider-strong); }
|
||||
|
||||
/* 选中态:accent 左色条 + soft 底(同 Callout / Activity 的状态编码语汇)。 */
|
||||
.choiceOn
|
||||
{
|
||||
background: var(--accent-softer);
|
||||
border-color: var(--accent-ring);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.choice:focus-visible
|
||||
{
|
||||
outline: 2px solid var(--accent-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.choiceMark
|
||||
{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.choiceOn .choiceMark { color: var(--accent); }
|
||||
|
||||
.choiceText
|
||||
{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.choiceTitle
|
||||
{
|
||||
font-size: var(--fs-13);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.choiceHint
|
||||
{
|
||||
font-size: var(--fs-12);
|
||||
line-height: var(--lh-body);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 批准 / 拒绝:批准是主色行动,拒绝是安静的 ghost。 */
|
||||
.actions
|
||||
{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertTriangle, Check, Clock, LogIn, ShieldCheck, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AuthShell } from "../features/auth/AuthShell";
|
||||
import { loginProd, startStepUpReauth } from "../features/auth/auth";
|
||||
import { stashLinkReturn } from "../features/qr/returnTo";
|
||||
import { consumeStepUpCode, type StepUpStash } from "../features/qr/stepUp";
|
||||
import { isDesktop, startDesktopLogin } from "../net/desktop";
|
||||
import {
|
||||
qrApprove, qrDeny, qrRequest, StepUpRequiredError,
|
||||
type QrPersist, type QrRequestInfo,
|
||||
} from "../net/qr";
|
||||
import { useAppStore } from "../store";
|
||||
import { t } from "../i18n";
|
||||
import { DeviceTypeIcon, type DeviceKind } from "../ui/glyphs";
|
||||
import { Button, Callout } from "../ui/primitives";
|
||||
import s from "./link.module.css";
|
||||
|
||||
// 批准页:手机扫码后落地,确认「是哪台设备、从哪来、给什么权限」。要求已登录。
|
||||
export const Route = createFileRoute("/link")({
|
||||
component: LinkApprovePage,
|
||||
validateSearch: (search: Record<string, unknown>) =>
|
||||
({
|
||||
r: typeof search.r === "string" ? search.r : undefined,
|
||||
c: typeof search.c === "string" ? search.c : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
type Outcome = "approved" | "denied" | "expired" | "error" | null;
|
||||
|
||||
function LinkApprovePage()
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const { r: requestId, c: code } = Route.useSearch();
|
||||
const user = useAppStore((s) => s.user);
|
||||
|
||||
const [ info, setInfo ] = useState<QrRequestInfo | null>(null);
|
||||
const [ loadError, setLoadError ] = useState<string | null>(null);
|
||||
const [ persist, setPersist ] = useState<QrPersist>("persist");
|
||||
const [ submitting, setSubmitting ] = useState(false);
|
||||
const [ outcome, setOutcome ] = useState<Outcome>(null);
|
||||
const [ actionError, setActionError ] = useState<string | null>(null);
|
||||
// step-up:true=后端要求批准前先做新鲜 prompt=login 再认证(provider 2FA 即此处过)。
|
||||
const [ stepUpRequired, setStepUpRequired ] = useState(false);
|
||||
// 再认证往返回来后由后端二次拒(403 step_up_required),文案提示并允许重试。
|
||||
const [ stepUpRejected, setStepUpRejected ] = useState(false);
|
||||
|
||||
// 未登录:暂存当前 URL 后走标准登录,登录后由首页 beforeLoad 弹回这里。
|
||||
const handleSignIn = useCallback(async () =>
|
||||
{
|
||||
stashLinkReturn(window.location.pathname + window.location.search);
|
||||
try
|
||||
{
|
||||
if (isDesktop())
|
||||
{
|
||||
await startDesktopLogin();
|
||||
navigate({ to: "/" });
|
||||
}
|
||||
else { await loginProd(); }
|
||||
}
|
||||
catch (e) { setLoadError(e instanceof Error ? e.message : String(e)); }
|
||||
}, [ navigate ]);
|
||||
|
||||
// 已登录且参数齐全:拉取待批准设备信息(含 step_up 标记)。
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!user || !requestId || !code) { return; }
|
||||
let cancelled = false;
|
||||
qrRequest(requestId, code).then(
|
||||
(data) =>
|
||||
{
|
||||
if (cancelled) { return; }
|
||||
setInfo(data);
|
||||
setStepUpRequired(data.stepUp);
|
||||
if (data.status === "expired") { setOutcome("expired"); }
|
||||
else if (data.status === "denied") { setOutcome("denied"); }
|
||||
else if (data.status === "approved") { setOutcome("approved"); }
|
||||
},
|
||||
(e) => { if (!cancelled) { setLoadError(e instanceof Error ? e.message : String(e)); } },
|
||||
);
|
||||
return () => { cancelled = true; };
|
||||
}, [ user, requestId, code ]);
|
||||
|
||||
// doApprove 执行批准;stash 非空时把 step-up 再认证拿到的 code+verifier 一并交给
|
||||
// 后端就地核验。403 step_up_required(StepUpRequiredError)单独走「需重新验证」态,
|
||||
// 不当成普通错误。
|
||||
const doApprove = useCallback(async (stash?: StepUpStash) =>
|
||||
{
|
||||
if (!requestId || !code) { return; }
|
||||
setSubmitting(true);
|
||||
setActionError(null);
|
||||
setStepUpRejected(false);
|
||||
try
|
||||
{
|
||||
await qrApprove(requestId, code, "guest", persist, stash?.code, stash?.verifier);
|
||||
setOutcome("approved");
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
if (e instanceof StepUpRequiredError) { setStepUpRejected(true); }
|
||||
else { setActionError(e instanceof Error ? e.message : String(e)); }
|
||||
}
|
||||
finally { setSubmitting(false); }
|
||||
}, [ requestId, code, persist ]);
|
||||
|
||||
// 再认证往返回来:检测到暂存的 step-up {code, verifier} → 自动调 approve 完成批准
|
||||
// → 用完即清(consumeStepUpCode 取出即清)。仅在已登录、设备信息已就绪后跑一次。
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!user || !info || outcome) { return; }
|
||||
const stash = consumeStepUpCode();
|
||||
if (!stash) { return; }
|
||||
void doApprove(stash);
|
||||
}, [ user, info, outcome, doApprove ]);
|
||||
|
||||
// handleApprove:批准按钮。step-up 要求且尚无再认证凭证时,先把用户带去做一次
|
||||
// 新鲜 prompt=login 再认证(往返回来后由上面的 effect 自动接力 approve);否则
|
||||
// 维持原有直接批准行为不变。
|
||||
const handleApprove = async () =>
|
||||
{
|
||||
if (!requestId || !code) { return; }
|
||||
if (stepUpRequired)
|
||||
{
|
||||
setSubmitting(true);
|
||||
setActionError(null);
|
||||
setStepUpRejected(false);
|
||||
try { await startStepUpReauth(window.location.pathname + window.location.search); }
|
||||
catch (e)
|
||||
{
|
||||
setActionError(e instanceof Error ? e.message : String(e));
|
||||
setSubmitting(false);
|
||||
}
|
||||
// 成功则整页跳转去 provider,本组件随之卸载,不再 setSubmitting。
|
||||
return;
|
||||
}
|
||||
await doApprove();
|
||||
};
|
||||
|
||||
const handleDeny = async () =>
|
||||
{
|
||||
if (!requestId || !code) { return; }
|
||||
setSubmitting(true);
|
||||
setActionError(null);
|
||||
try
|
||||
{
|
||||
await qrDeny(requestId, code);
|
||||
setOutcome("denied");
|
||||
}
|
||||
catch (e) { setActionError(e instanceof Error ? e.message : String(e)); }
|
||||
finally { setSubmitting(false); }
|
||||
};
|
||||
|
||||
// ---- 渲染分支 --------------------------------------------------------
|
||||
|
||||
if (!requestId || !code)
|
||||
{
|
||||
return (
|
||||
<AuthShell title={t("qr.approve.title")} hideBrand>
|
||||
<Callout tone="error" icon={<AlertTriangle size={16} />}>
|
||||
{t("qr.approve.badLink")}
|
||||
</Callout>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user)
|
||||
{
|
||||
return (
|
||||
<AuthShell
|
||||
title={t("qr.approve.title")}
|
||||
subtitle={t("qr.approve.signInFirst")}
|
||||
hideBrand
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
block
|
||||
leftIcon={<LogIn size={16} />}
|
||||
onClick={() => { void handleSignIn(); }}
|
||||
>
|
||||
{t("login.prod.button")}
|
||||
</Button>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (outcome) { return <OutcomeView outcome={outcome} onHome={() => navigate({ to: "/" })} />; }
|
||||
|
||||
if (loadError)
|
||||
{
|
||||
return (
|
||||
<AuthShell title={t("qr.approve.title")} hideBrand>
|
||||
<Callout tone="error" icon={<AlertTriangle size={16} />}>{loadError}</Callout>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!info)
|
||||
{
|
||||
return (
|
||||
<AuthShell title={t("qr.approve.title")} subtitle={t("qr.approve.loading")} hideBrand />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell title={t("qr.approve.title")} subtitle={t("qr.approve.subtitle")} hideBrand>
|
||||
{/* 待批准设备:单行身份块——图标 + 名字 + 来源 IP(monospace 元数据)。 */}
|
||||
<div className={s.device}>
|
||||
<span className={s.deviceIcon}>
|
||||
<DeviceTypeIcon kind={mapDeviceKind(info.deviceType)} size={24} />
|
||||
</span>
|
||||
<div className={s.deviceMeta}>
|
||||
<span className={s.deviceName}>{info.deviceName}</span>
|
||||
<span className={s.deviceSub}>
|
||||
{t(`deviceType.${kindKey(info.deviceType)}` as const)}
|
||||
<span className={s.dot}>·</span>
|
||||
<code className={s.ip}>{info.requestIp}</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信任时长:两选项分段,选中项得 accent 左色条(同 Callout / Activity 语汇)。 */}
|
||||
<div className={s.choices} role="radiogroup" aria-label={t("qr.approve.trustLabel")}>
|
||||
<TrustOption
|
||||
value="persist"
|
||||
selected={persist === "persist"}
|
||||
onSelect={setPersist}
|
||||
title={t("qr.approve.trust.title")}
|
||||
hint={t("qr.approve.trust.hint")}
|
||||
/>
|
||||
<TrustOption
|
||||
value="once"
|
||||
selected={persist === "once"}
|
||||
onSelect={setPersist}
|
||||
title={t("qr.approve.once.title")}
|
||||
hint={t("qr.approve.once.hint")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 权限范围说明:本期固定受限访客,明确告知边界(非装饰)。 */}
|
||||
<Callout tone="info" icon={<ShieldCheck size={16} />}>
|
||||
{t("qr.approve.scopeGuest")}
|
||||
</Callout>
|
||||
|
||||
{/* step-up:批准前的明确安全步骤——需先验证身份(provider 2FA 即此处过)。 */}
|
||||
{stepUpRequired && !stepUpRejected && (
|
||||
<Callout tone="info" icon={<ShieldCheck size={16} />}>
|
||||
{t("qr.approve.stepUp.notice")}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{/* 再认证回来仍被后端拒:给方向(凭证过期 / 验证未通过)并允许重试。 */}
|
||||
{stepUpRejected && (
|
||||
<Callout tone="warn" icon={<AlertTriangle size={16} />}>
|
||||
{t("qr.approve.stepUp.rejected")}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{actionError && (
|
||||
<Callout tone="error" icon={<AlertTriangle size={16} />}>{actionError}</Callout>
|
||||
)}
|
||||
|
||||
<div className={s.actions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
block
|
||||
loading={submitting}
|
||||
leftIcon={
|
||||
stepUpRequired ? <ShieldCheck size={16} /> : <Check size={16} />
|
||||
}
|
||||
onClick={() => { void handleApprove(); }}
|
||||
>
|
||||
{stepUpRequired
|
||||
? t("qr.approve.stepUp.button")
|
||||
: t("qr.approve.approve")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
block
|
||||
disabled={submitting}
|
||||
leftIcon={<X size={16} />}
|
||||
onClick={() => { void handleDeny(); }}
|
||||
>
|
||||
{t("qr.approve.deny")}
|
||||
</Button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
function TrustOption(props: {
|
||||
value: QrPersist;
|
||||
selected: boolean;
|
||||
onSelect: (v: QrPersist) => void;
|
||||
title: string;
|
||||
hint: string;
|
||||
})
|
||||
{
|
||||
const { value, selected, onSelect, title, hint } = props;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
className={`${s.choice} ${selected ? s.choiceOn : ""}`}
|
||||
onClick={() => onSelect(value)}
|
||||
>
|
||||
<span className={s.choiceMark} aria-hidden="true">
|
||||
{selected ? <Check size={14} /> : <Clock size={14} />}
|
||||
</span>
|
||||
<span className={s.choiceText}>
|
||||
<span className={s.choiceTitle}>{title}</span>
|
||||
<span className={s.choiceHint} data-jz-level="paragraph">{hint}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function OutcomeView(props: { outcome: Exclude<Outcome, null>; onHome: () => void })
|
||||
{
|
||||
const { outcome, onHome } = props;
|
||||
const map = {
|
||||
approved: { tone: "ok" as const, icon: <ShieldCheck size={16} />,
|
||||
title: t("qr.approve.doneApprovedTitle"), body: t("qr.approve.doneApproved") },
|
||||
denied: { tone: "warn" as const, icon: <X size={16} />,
|
||||
title: t("qr.approve.doneDeniedTitle"), body: t("qr.approve.doneDenied") },
|
||||
expired: { tone: "warn" as const, icon: <Clock size={16} />,
|
||||
title: t("qr.approve.expiredTitle"), body: t("qr.approve.expired") },
|
||||
error: { tone: "error" as const, icon: <AlertTriangle size={16} />,
|
||||
title: t("qr.approve.errorTitle"), body: t("qr.approve.error") },
|
||||
}[outcome];
|
||||
return (
|
||||
<AuthShell title={map.title} hideBrand>
|
||||
<Callout tone={map.tone} icon={map.icon}>{map.body}</Callout>
|
||||
<Button variant="secondary" size="lg" block onClick={onHome}>
|
||||
{t("qr.approve.backHome")}
|
||||
</Button>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
// mapDeviceKind:契约 device_type → DeviceTypeIcon 的 DeviceKind(与 DeviceStrip
|
||||
// 同口径;批准页独立一份,不改动既有组件)。
|
||||
function mapDeviceKind(type: string): DeviceKind
|
||||
{
|
||||
const v = type.toLowerCase();
|
||||
if (v === "shortcut") { return "shortcut"; }
|
||||
if (v.includes("mac") || v === "darwin") { return "macos"; }
|
||||
if (v.includes("win")) { return "windows"; }
|
||||
if (v.includes("linux")) { return "linux"; }
|
||||
if (v.includes("ios") || v.includes("iphone") || v.includes("ipad")) { return "ios"; }
|
||||
if (v.includes("android")) { return "android"; }
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// kindKey:device_type → deviceType.* i18n key 的已知分支,未知回退 browser。
|
||||
function kindKey(type: string): "browser" | "macos" | "windows" | "linux" | "ios" | "shortcut"
|
||||
{
|
||||
const v = type.toLowerCase();
|
||||
if (v === "shortcut") { return "shortcut"; }
|
||||
if (v.includes("mac") || v === "darwin") { return "macos"; }
|
||||
if (v.includes("win")) { return "windows"; }
|
||||
if (v.includes("linux")) { return "linux"; }
|
||||
if (v.includes("ios") || v.includes("iphone") || v.includes("ipad")) { return "ios"; }
|
||||
return "browser";
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/* 显码页:二维码是主角,其余安静。 */
|
||||
|
||||
.stage
|
||||
{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-2) 0 var(--space-1);
|
||||
}
|
||||
|
||||
/* recessed plate:下沉表面 + 内描边,把二维码托成「一个要对准相机的对象」。 */
|
||||
.plate
|
||||
{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-5);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.statusLine
|
||||
{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--fs-13);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.waitText
|
||||
{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* accent 脉冲点:唯一的动效「时刻」,传达「正在等待批准」的活体感。 */
|
||||
.pulse
|
||||
{
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 0 var(--accent-ring);
|
||||
animation: qr-pulse 1.8s var(--ease-in-out) infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes qr-pulse
|
||||
{
|
||||
0% { box-shadow: 0 0 0 0 var(--accent-ring); }
|
||||
70% { box-shadow: 0 0 0 7px transparent; }
|
||||
100% { box-shadow: 0 0 0 0 transparent; }
|
||||
}
|
||||
|
||||
/* 两步指引:用真实序号(这是一个有顺序的过程),而非装饰性编号。 */
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce)
|
||||
{
|
||||
.pulse { animation: none; }
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertTriangle, ArrowRight, RefreshCw, ScanLine } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AuthShell } from "../features/auth/AuthShell";
|
||||
import { syncWebDeviceName } from "../features/auth/auth";
|
||||
import { QrCanvas } from "../features/qr/QrCanvas";
|
||||
import { persistDesktopDeviceName } from "../net/desktop";
|
||||
import { pollQrStatus, qrStart, type QrStartResp, type QrStatus } from "../net/qr";
|
||||
import { readInjectedDeviceType } from "../store/helpers";
|
||||
import { useAppStore } from "../store";
|
||||
import { t } from "../i18n";
|
||||
import { isAsciiDeviceName } from "../utils/format";
|
||||
import { StateDot } from "../ui/glyphs";
|
||||
import { Button, Callout, TextField } from "../ui/primitives";
|
||||
import s from "./link_.new.module.css";
|
||||
|
||||
// 显码页:有人借了一台陌生设备,扫码把它接入自己的 cdrop。它本身公开(start /
|
||||
// status 无需登录),故不设路由守卫——已登录用户也可经此把另一设备接入。
|
||||
export const Route = createFileRoute("/link_/new")({
|
||||
component: LinkNewPage,
|
||||
});
|
||||
|
||||
type Step = "name" | "show";
|
||||
|
||||
function LinkNewPage()
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const setAuth = useAppStore((s) => s.setAuth);
|
||||
const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName);
|
||||
|
||||
const [ step, setStep ] = useState<Step>("name");
|
||||
const [ name, setName ] = useState(suggestDeviceName);
|
||||
const [ session, setSession ] = useState<QrStartResp | null>(null);
|
||||
const [ phase, setPhase ] = useState<QrStatus["status"]>("pending");
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ starting, setStarting ] = useState(false);
|
||||
|
||||
// 轮询的取消句柄。换二维码 / 卸载时调用即停。
|
||||
const cancelPollRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const onApproved = useCallback((st: QrStatus) =>
|
||||
{
|
||||
if (st.status !== "approved" || !st.accessToken || !st.user) { return; }
|
||||
setAuth({
|
||||
accessToken: st.accessToken,
|
||||
user: { id: st.user.id, name: st.user.name, avatar: st.user.avatar },
|
||||
});
|
||||
// 设备名以批准方下发的为权威(服务端登记的名字),回退到本地输入。
|
||||
const finalName = st.deviceName?.trim() || name.trim();
|
||||
setSelfDeviceName(finalName);
|
||||
persistDesktopDeviceName(finalName);
|
||||
syncWebDeviceName(finalName);
|
||||
navigate({ to: "/" });
|
||||
}, [ name, navigate, setAuth, setSelfDeviceName ]);
|
||||
|
||||
const begin = useCallback(async (deviceName: string) =>
|
||||
{
|
||||
setError(null);
|
||||
setStarting(true);
|
||||
try
|
||||
{
|
||||
const resp = await qrStart({
|
||||
deviceName,
|
||||
deviceType: readInjectedDeviceType(),
|
||||
});
|
||||
setSession(resp);
|
||||
setPhase("pending");
|
||||
setStep("show");
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
finally { setStarting(false); }
|
||||
}, []);
|
||||
|
||||
// 拿到 session 即起长轮询;session 变化(重新生成)时换新轮询。
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!session) { return; }
|
||||
cancelPollRef.current?.();
|
||||
const cancel = pollQrStatus(session.requestId, session.pollSecret, {
|
||||
onResolved: (st) =>
|
||||
{
|
||||
setPhase(st.status);
|
||||
if (st.status === "approved") { onApproved(st); }
|
||||
},
|
||||
onError: () => { /* 单轮抖动:轮询循环自重试,UI 保持等待态 */ },
|
||||
});
|
||||
cancelPollRef.current = cancel;
|
||||
return () => { cancel(); cancelPollRef.current = null; };
|
||||
}, [ session, onApproved ]);
|
||||
|
||||
const handleContinue = () =>
|
||||
{
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) { return; }
|
||||
if (!isAsciiDeviceName(trimmed))
|
||||
{
|
||||
setError(t("settings.deviceName.asciiOnly"));
|
||||
return;
|
||||
}
|
||||
void begin(trimmed);
|
||||
};
|
||||
|
||||
const handleRegenerate = () =>
|
||||
{
|
||||
setError(null);
|
||||
void begin(name.trim());
|
||||
};
|
||||
|
||||
if (step === "name")
|
||||
{
|
||||
return (
|
||||
<AuthShell
|
||||
title={t("qr.show.title")}
|
||||
subtitle={t("qr.show.subtitle")}
|
||||
hideBrand
|
||||
>
|
||||
{error && (
|
||||
<Callout tone="error" icon={<AlertTriangle size={16} />}>{error}</Callout>
|
||||
)}
|
||||
<TextField
|
||||
label={t("setup.field")}
|
||||
hint={t("qr.show.nameHint")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
onKeyDown={(e) =>
|
||||
{
|
||||
if (e.key === "Enter" && name.trim())
|
||||
{
|
||||
e.preventDefault();
|
||||
handleContinue();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
block
|
||||
loading={starting}
|
||||
rightIcon={<ArrowRight size={16} />}
|
||||
onClick={handleContinue}
|
||||
disabled={!name.trim()}
|
||||
>
|
||||
{t("qr.show.generate")}
|
||||
</Button>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell title={t("qr.show.scanTitle")} hideBrand>
|
||||
{error && (
|
||||
<Callout tone="error" icon={<AlertTriangle size={16} />}>{error}</Callout>
|
||||
)}
|
||||
|
||||
<div className={s.stage}>
|
||||
<div className={s.plate}>
|
||||
{session && <QrCanvas value={session.qrPayload} />}
|
||||
</div>
|
||||
|
||||
<StatusLine phase={phase} />
|
||||
</div>
|
||||
|
||||
<ol className={s.steps} data-jz-level="paragraph">
|
||||
<li>{t("qr.show.howto1")}</li>
|
||||
<li>{t("qr.show.howto2")}</li>
|
||||
</ol>
|
||||
|
||||
{(phase === "expired" || phase === "denied") && (
|
||||
<Callout
|
||||
tone={phase === "denied" ? "error" : "warn"}
|
||||
icon={<AlertTriangle size={16} />}
|
||||
>
|
||||
{phase === "denied" ? t("qr.show.denied") : t("qr.show.expired")}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{(phase === "expired" || phase === "denied") && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
block
|
||||
leftIcon={<RefreshCw size={16} />}
|
||||
onClick={handleRegenerate}
|
||||
>
|
||||
{t("qr.show.regenerate")}
|
||||
</Button>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
// 显码页的等待态:一行平静的状态,pending 时 accent 脉冲点 + 「等待批准」。
|
||||
function StatusLine(props: { phase: QrStatus["status"] })
|
||||
{
|
||||
const { phase } = props;
|
||||
if (phase === "approved")
|
||||
{
|
||||
return (
|
||||
<div className={s.statusLine}>
|
||||
<StateDot tone="online" size={8} />
|
||||
<span>{t("qr.show.approved")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (phase === "expired" || phase === "denied") { return null; }
|
||||
return (
|
||||
<div className={s.statusLine}>
|
||||
<span className={s.pulse} aria-hidden="true" />
|
||||
<span className={s.waitText}>
|
||||
<ScanLine size={14} />
|
||||
{t("qr.show.waiting")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// suggestDeviceName:UA 预填一个可读的默认设备名(与 /setup 同口径),可编辑。
|
||||
function suggestDeviceName(): string
|
||||
{
|
||||
const ua = navigator.userAgent;
|
||||
let browser = "Browser";
|
||||
if (ua.includes("Firefox/")) { browser = "Firefox"; }
|
||||
else if (ua.includes("Edg/")) { browser = "Edge"; }
|
||||
else if (ua.includes("Chrome/")) { browser = "Chrome"; }
|
||||
else if (ua.includes("Safari/")) { browser = "Safari"; }
|
||||
|
||||
let os = "Desktop";
|
||||
if (ua.includes("Mac")) { os = "macOS"; }
|
||||
else if (ua.includes("Windows")) { os = "Windows"; }
|
||||
else if (ua.includes("Linux")) { os = "Linux"; }
|
||||
|
||||
return `${browser}-${os}-${Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, "0")}`;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertTriangle, LogIn } from "lucide-react";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertTriangle, LogIn, QrCode } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchAuthConfig, loginDev, loginProd } from "../features/auth/auth";
|
||||
import { isDesktop, startDesktopLogin } from "../net/desktop";
|
||||
@@ -131,6 +131,25 @@ function LoginPage()
|
||||
>
|
||||
{t("login.prod.button")}
|
||||
</Button>
|
||||
{/* 扫码登录入口:从已登录的另一台设备批准本机接入,无需在此重登。
|
||||
克制——一行带图标的次级链接,不与主 CTA 争重。 */}
|
||||
<Link
|
||||
to="/link/new"
|
||||
search={{}}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "var(--space-2)",
|
||||
marginTop: "calc(var(--space-1) * -1)",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: "var(--fs-13)",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
<QrCode size={15} />
|
||||
<span>{t("qr.entry.fromLogin")}</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</AuthShell>
|
||||
|
||||
@@ -2,8 +2,13 @@ import { Loader } from "@mantine/core";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { completeOAuthLogin } from "../features/auth/auth";
|
||||
import {
|
||||
clearStepUpState, completeOAuthLogin, verifyStepUpState,
|
||||
} from "../features/auth/auth";
|
||||
import { AuthShell } from "../features/auth/AuthShell";
|
||||
import {
|
||||
clearStepUpPending, peekStepUpPending, stashStepUpCode,
|
||||
} from "../features/qr/stepUp";
|
||||
import { t } from "../i18n";
|
||||
import { Callout } from "../ui/primitives";
|
||||
|
||||
@@ -68,6 +73,28 @@ function CallbackPage()
|
||||
showErrDeferred(t("oauth.missingParams"));
|
||||
return;
|
||||
}
|
||||
|
||||
// step-up 分叉:批准页发起的 prompt=login 再认证回到这里。此时 code 是要
|
||||
// 交给后端 approve 去 IdP 换的一次性凭证——前端绝不调 /api/auth/exchange
|
||||
// 消费它(那会在服务端轮换会话)。只校 state(防 CSRF,同常规登录口径)、
|
||||
// 把 {code, verifier} 暂存给批准页、清掉「待办」,整页跳回批准页 URL。
|
||||
const pending = peekStepUpPending();
|
||||
if (pending)
|
||||
{
|
||||
if (!verifyStepUpState(search.state))
|
||||
{
|
||||
clearStepUpPending();
|
||||
showErrDeferred("OAuth state mismatch (possible CSRF or stale tab)");
|
||||
return;
|
||||
}
|
||||
stashStepUpCode({ code: search.code, verifier: pending.verifier });
|
||||
clearStepUpPending();
|
||||
// 整页跳转回批准页(returnTo 已在 stash 时通过 open-redirect 校验)。
|
||||
window.location.replace(pending.returnTo);
|
||||
return;
|
||||
}
|
||||
clearStepUpState(); // 非 step-up 路径兜底:清掉可能残留的 step-up state。
|
||||
|
||||
completeOAuthLogin(search.code, search.state).then(
|
||||
// 整页跳转(window.location.replace),不是路由软导航。Safari 下,从
|
||||
// Casdoor 回跳后那条客户端导航会复用 OAuth 流程遗留的 HTTP 连接,而这些
|
||||
|
||||
Reference in New Issue
Block a user