diff --git a/.eslintcache b/.eslintcache new file mode 100644 index 0000000..28d825a --- /dev/null +++ b/.eslintcache @@ -0,0 +1 @@ +[{"E:\\Document\\Coding\\SecScore\\eslint.config.mjs":"1","E:\\Document\\Coding\\SecScore\\src\\App.tsx":"2","E:\\Document\\Coding\\SecScore\\src\\ClientContext.ts":"3","E:\\Document\\Coding\\SecScore\\src\\components\\BoardManager.tsx":"4","E:\\Document\\Coding\\SecScore\\src\\components\\ContentArea.tsx":"5","E:\\Document\\Coding\\SecScore\\src\\components\\EventHistory.tsx":"6","E:\\Document\\Coding\\SecScore\\src\\components\\Home.tsx":"7","E:\\Document\\Coding\\SecScore\\src\\components\\Leaderboard.tsx":"8","E:\\Document\\Coding\\SecScore\\src\\components\\OAuthCallback.tsx":"9","E:\\Document\\Coding\\SecScore\\src\\components\\OAuthLogin.tsx":"10","E:\\Document\\Coding\\SecScore\\src\\components\\OOBE\\OOBE.tsx":"11","E:\\Document\\Coding\\SecScore\\src\\components\\OOBE\\OOBEBackground.tsx":"12","E:\\Document\\Coding\\SecScore\\src\\components\\ReasonManager.tsx":"13","E:\\Document\\Coding\\SecScore\\src\\components\\RewardExchange.tsx":"14","E:\\Document\\Coding\\SecScore\\src\\components\\RewardSettings.tsx":"15","E:\\Document\\Coding\\SecScore\\src\\components\\ScoreManager.tsx":"16","E:\\Document\\Coding\\SecScore\\src\\components\\Settings.tsx":"17","E:\\Document\\Coding\\SecScore\\src\\components\\SettlementHistory.tsx":"18","E:\\Document\\Coding\\SecScore\\src\\components\\Sidebar.tsx":"19","E:\\Document\\Coding\\SecScore\\src\\components\\StudentManager.tsx":"20","E:\\Document\\Coding\\SecScore\\src\\components\\TagEditorDialog.tsx":"21","E:\\Document\\Coding\\SecScore\\src\\components\\ThemeEditor.tsx":"22","E:\\Document\\Coding\\SecScore\\src\\components\\ThemeQuickSettings.tsx":"23","E:\\Document\\Coding\\SecScore\\src\\components\\TitleBar.tsx":"24","E:\\Document\\Coding\\SecScore\\src\\components\\Versions.tsx":"25","E:\\Document\\Coding\\SecScore\\src\\components\\WindowControls.tsx":"26","E:\\Document\\Coding\\SecScore\\src\\components\\Wizard.tsx":"27","E:\\Document\\Coding\\SecScore\\src\\contexts\\ServiceContext.tsx":"28","E:\\Document\\Coding\\SecScore\\src\\contexts\\ThemeContext.tsx":"29","E:\\Document\\Coding\\SecScore\\src\\contexts\\ThemeEditorContext.tsx":"30","E:\\Document\\Coding\\SecScore\\src\\env.d.ts":"31","E:\\Document\\Coding\\SecScore\\src\\hooks\\useResponsive.ts":"32","E:\\Document\\Coding\\SecScore\\src\\i18n\\index.ts":"33","E:\\Document\\Coding\\SecScore\\src\\main.tsx":"34","E:\\Document\\Coding\\SecScore\\src\\preload\\types.ts":"35","E:\\Document\\Coding\\SecScore\\src\\react-19-patch.ts":"36","E:\\Document\\Coding\\SecScore\\src\\services\\StudentService.ts":"37","E:\\Document\\Coding\\SecScore\\src\\shared\\kernel.ts":"38","E:\\Document\\Coding\\SecScore\\src\\shared\\mobileNavigation.ts":"39","E:\\Document\\Coding\\SecScore\\src\\utils\\color.ts":"40","E:\\Document\\Coding\\SecScore\\src\\utils\\studentAvatar.ts":"41","E:\\Document\\Coding\\SecScore\\src\\workers\\xlsxWorker.ts":"42","E:\\Document\\Coding\\SecScore\\vite.config.ts":"43"},{"size":1413,"mtime":1774084765386,"results":"44","hashOfConfig":"45"},{"size":38217,"mtime":1774773410827,"results":"46","hashOfConfig":"47"},{"size":124,"mtime":1774084765537,"results":"48","hashOfConfig":"47"},{"size":47714,"mtime":1774704827018,"results":"49","hashOfConfig":"47"},{"size":12490,"mtime":1774773413504,"results":"50","hashOfConfig":"47"},{"size":2044,"mtime":1774084765557,"results":"51","hashOfConfig":"47"},{"size":90881,"mtime":1774704827750,"results":"52","hashOfConfig":"47"},{"size":8101,"mtime":1774084765561,"results":"53","hashOfConfig":"47"},{"size":1047,"mtime":1774772479779,"results":"54","hashOfConfig":"47"},{"size":4948,"mtime":1774780673579,"results":"55","hashOfConfig":"47"},{"size":36271,"mtime":1774176288912,"results":"56","hashOfConfig":"47"},{"size":4203,"mtime":1774084765566,"results":"57","hashOfConfig":"47"},{"size":5983,"mtime":1774084765569,"results":"58","hashOfConfig":"47"},{"size":9360,"mtime":1774084765571,"results":"59","hashOfConfig":"47"},{"size":6151,"mtime":1774084765572,"results":"60","hashOfConfig":"47"},{"size":12301,"mtime":1774084765573,"results":"61","hashOfConfig":"47"},{"size":55529,"mtime":1774779133310,"results":"62","hashOfConfig":"47"},{"size":5950,"mtime":1774084765578,"results":"63","hashOfConfig":"47"},{"size":10335,"mtime":1774675242424,"results":"64","hashOfConfig":"47"},{"size":79058,"mtime":1774704830361,"results":"65","hashOfConfig":"47"},{"size":6331,"mtime":1774084765584,"results":"66","hashOfConfig":"47"},{"size":8079,"mtime":1774084765586,"results":"67","hashOfConfig":"47"},{"size":10954,"mtime":1774084765587,"results":"68","hashOfConfig":"47"},{"size":3858,"mtime":1774084765589,"results":"69","hashOfConfig":"47"},{"size":431,"mtime":1774084765590,"results":"70","hashOfConfig":"47"},{"size":2367,"mtime":1774084765592,"results":"71","hashOfConfig":"47"},{"size":1560,"mtime":1774084765594,"results":"72","hashOfConfig":"47"},{"size":362,"mtime":1774084765604,"results":"73","hashOfConfig":"47"},{"size":5985,"mtime":1774084765606,"results":"74","hashOfConfig":"47"},{"size":2824,"mtime":1774084765608,"results":"75","hashOfConfig":"47"},{"size":38,"mtime":1774084765611,"results":"76","hashOfConfig":"47"},{"size":2097,"mtime":1774084765613,"results":"77","hashOfConfig":"47"},{"size":1533,"mtime":1774084765616,"results":"78","hashOfConfig":"47"},{"size":6919,"mtime":1774704831300,"results":"79","hashOfConfig":"47"},{"size":17900,"mtime":1774779134852,"results":"80","hashOfConfig":"47"},{"size":1150,"mtime":1774084765630,"results":"81","hashOfConfig":"47"},{"size":667,"mtime":1774084765638,"results":"82","hashOfConfig":"47"},{"size":3061,"mtime":1774176077013,"results":"83","hashOfConfig":"47"},{"size":1716,"mtime":1774772796302,"results":"84","hashOfConfig":"47"},{"size":3256,"mtime":1774084765655,"results":"85","hashOfConfig":"47"},{"size":1115,"mtime":1774084765658,"results":"86","hashOfConfig":"47"},{"size":947,"mtime":1774084765660,"results":"87","hashOfConfig":"47"},{"size":709,"mtime":1774665241507,"results":"88","hashOfConfig":"47"},{"filePath":"89","messages":"90","suppressedMessages":"91","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"oyxevq",{"filePath":"92","messages":"93","suppressedMessages":"94","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"f6a32r",{"filePath":"95","messages":"96","suppressedMessages":"97","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"98","messages":"99","suppressedMessages":"100","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"101","messages":"102","suppressedMessages":"103","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"104","messages":"105","suppressedMessages":"106","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"107","messages":"108","suppressedMessages":"109","errorCount":3,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"110","messages":"111","suppressedMessages":"112","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"113","messages":"114","suppressedMessages":"115","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"116","messages":"117","suppressedMessages":"118","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"119","messages":"120","suppressedMessages":"121","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"122","messages":"123","suppressedMessages":"124","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"125","messages":"126","suppressedMessages":"127","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"128","messages":"129","suppressedMessages":"130","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"131","messages":"132","suppressedMessages":"133","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"134","messages":"135","suppressedMessages":"136","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"137","messages":"138","suppressedMessages":"139","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"140","messages":"141","suppressedMessages":"142","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"143","messages":"144","suppressedMessages":"145","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"146","messages":"147","suppressedMessages":"148","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"149","messages":"150","suppressedMessages":"151","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"152","messages":"153","suppressedMessages":"154","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"155","messages":"156","suppressedMessages":"157","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"158","messages":"159","suppressedMessages":"160","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"161","messages":"162","suppressedMessages":"163","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"164","messages":"165","suppressedMessages":"166","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"167","messages":"168","suppressedMessages":"169","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"170","messages":"171","suppressedMessages":"172","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"173","messages":"174","suppressedMessages":"175","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"176","messages":"177","suppressedMessages":"178","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"179","messages":"180","suppressedMessages":"181","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"182","messages":"183","suppressedMessages":"184","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"185","messages":"186","suppressedMessages":"187","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"188","messages":"189","suppressedMessages":"190","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"191","messages":"192","suppressedMessages":"193","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"194","messages":"195","suppressedMessages":"196","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"197","messages":"198","suppressedMessages":"199","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"200","messages":"201","suppressedMessages":"202","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"203","messages":"204","suppressedMessages":"205","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"206","messages":"207","suppressedMessages":"208","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"209","messages":"210","suppressedMessages":"211","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"212","messages":"213","suppressedMessages":"214","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"215","messages":"216","suppressedMessages":"217","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"E:\\Document\\Coding\\SecScore\\eslint.config.mjs",[],[],"E:\\Document\\Coding\\SecScore\\src\\App.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\ClientContext.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\BoardManager.tsx",["218","219"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ContentArea.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\EventHistory.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Home.tsx",["220","221","222","223","224","225"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Leaderboard.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\OAuthCallback.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\OAuthLogin.tsx",["226"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\OOBE\\OOBE.tsx",["227","228"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\OOBE\\OOBEBackground.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ReasonManager.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\RewardExchange.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\RewardSettings.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ScoreManager.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Settings.tsx",["229"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\SettlementHistory.tsx",["230","231"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Sidebar.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\StudentManager.tsx",["232"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\TagEditorDialog.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ThemeEditor.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ThemeQuickSettings.tsx",["233"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\TitleBar.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Versions.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\WindowControls.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Wizard.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\contexts\\ServiceContext.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\contexts\\ThemeContext.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\contexts\\ThemeEditorContext.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\env.d.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\hooks\\useResponsive.ts",["234"],[],"E:\\Document\\Coding\\SecScore\\src\\i18n\\index.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\main.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\preload\\types.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\react-19-patch.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\services\\StudentService.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\shared\\kernel.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\shared\\mobileNavigation.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\utils\\color.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\utils\\studentAvatar.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\workers\\xlsxWorker.ts",[],[],"E:\\Document\\Coding\\SecScore\\vite.config.ts",[],[],{"ruleId":"235","severity":1,"message":"236","line":626,"column":6,"nodeType":"237","endLine":626,"endColumn":44,"suggestions":"238"},{"ruleId":"235","severity":1,"message":"236","line":634,"column":6,"nodeType":"237","endLine":634,"endColumn":59,"suggestions":"239"},{"ruleId":"235","severity":1,"message":"240","line":532,"column":6,"nodeType":"237","endLine":532,"endColumn":76,"suggestions":"241"},{"ruleId":"242","severity":2,"message":"243","line":544,"column":34,"nodeType":null,"endLine":556,"endColumn":4},{"ruleId":"242","severity":2,"message":"243","line":544,"column":34,"nodeType":null,"endLine":556,"endColumn":4},{"ruleId":"235","severity":1,"message":"244","line":556,"column":6,"nodeType":"237","endLine":556,"endColumn":15,"suggestions":"245"},{"ruleId":"242","severity":2,"message":"246","line":1627,"column":5,"nodeType":null,"endLine":1650,"endColumn":6},{"ruleId":"235","severity":1,"message":"247","line":1710,"column":6,"nodeType":"237","endLine":1710,"endColumn":8,"suggestions":"248"},{"ruleId":"235","severity":1,"message":"249","line":119,"column":6,"nodeType":"237","endLine":119,"endColumn":8,"suggestions":"250"},{"ruleId":"235","severity":1,"message":"251","line":160,"column":6,"nodeType":"237","endLine":160,"endColumn":20,"suggestions":"252"},{"ruleId":"235","severity":1,"message":"253","line":334,"column":5,"nodeType":"237","endLine":334,"endColumn":27,"suggestions":"254"},{"ruleId":"235","severity":1,"message":"255","line":239,"column":6,"nodeType":"237","endLine":239,"endColumn":8,"suggestions":"256"},{"ruleId":"235","severity":1,"message":"257","line":54,"column":6,"nodeType":"237","endLine":54,"endColumn":18,"suggestions":"258"},{"ruleId":"235","severity":1,"message":"244","line":106,"column":5,"nodeType":"237","endLine":106,"endColumn":7,"suggestions":"259"},{"ruleId":"235","severity":1,"message":"260","line":1351,"column":6,"nodeType":"237","endLine":1351,"endColumn":50,"suggestions":"261"},{"ruleId":"235","severity":1,"message":"251","line":100,"column":6,"nodeType":"237","endLine":100,"endColumn":20,"suggestions":"262"},{"ruleId":"235","severity":1,"message":"263","line":25,"column":9,"nodeType":"264","endLine":25,"endColumn":70},"react-hooks/exhaustive-deps","React Hook useEffect has a missing dependency: 'activeBoard'. Either include it or remove the dependency array.","ArrayExpression",["265"],["266"],"React Hook useMemo has an unnecessary dependency: 'layoutType'. Either exclude it or remove the dependency array.",["267"],"react-hooks/preserve-manual-memoization","Compilation Skipped: Existing memoization could not be preserved\n\nReact Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `t`, but the source dependencies were [reasons]. Inferred different dependency than source.\n\nE:\\Document\\Coding\\SecScore\\src\\components\\Home.tsx:544:34\n 542 | }, [groupedStudents])\n 543 |\n> 544 | const groupedReasons = useMemo(() => {\n | ^^^^^^^\n> 545 | const groups: Record = {}\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 546 | reasons.forEach((r) => {\n …\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 555 | })\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 556 | }, [reasons])\n | ^^^^ Could not preserve existing manual memoization\n 557 |\n 558 | const selectedStudents = useMemo(() => {\n 559 | if (selectedStudentIds.length === 0) return []","React Hook useMemo has a missing dependency: 't'. Either include it or remove the dependency array.",["268"],"Compilation Skipped: Existing memoization could not be preserved\n\nReact Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This value was memoized in source but not in compilation output.\n\nE:\\Document\\Coding\\SecScore\\src\\components\\Home.tsx:1627:5\n 1625 |\n 1626 | const handleNavAction = useCallback(\n> 1627 | (clientY: number) => {\n | ^^^^^^^^^^^^^^^^^^^^^^\n> 1628 | if (!navContainerRef.current) return\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 1629 | const rect = navContainerRef.current.getBoundingClientRect()\n …\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 1649 | }\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 1650 | },\n | ^^^^^^ Could not preserve existing memoization\n 1651 | [groupedStudents, quickNavLayout.itemSize, quickNavLayout.paddingY, triggerNavHaptic]\n 1652 | )\n 1653 |","React Hook useEffect has missing dependencies: 'onGlobalMouseMove' and 'onGlobalMouseUp'. Either include them or remove the dependency array.",["269"],"React Hook useEffect has a missing dependency: 'handleDeepLink'. Either include it or remove the dependency array.",["270"],"React Hook useEffect has a missing dependency: 't'. Either include it or remove the dependency array.",["271"],"React Hook useCallback has missing dependencies: 'showOobeMessage' and 't'. Either include them or remove the dependency array.",["272"],"React Hook useEffect has a missing dependency: 'loadAll'. Either include it or remove the dependency array.",["273"],"React Hook useCallback has a missing dependency: 't'. Either include it or remove the dependency array.",["274"],["275"],"React Hook useMemo has missing dependencies: 'handleDelete', 'handleOpenAvatarEditor', 'handleOpenGroupEditor', and 'handleOpenTagEditor'. Either include them or remove the dependency array.",["276"],["277"],"The 'breakpoints' object makes the dependencies of useEffect Hook (at line 49) change on every render. Move it inside the useEffect callback. Alternatively, wrap the initialization of 'breakpoints' in its own useMemo() Hook.","VariableDeclarator",{"desc":"278","fix":"279"},{"desc":"280","fix":"281"},{"desc":"282","fix":"283"},{"desc":"284","fix":"285"},{"desc":"286","fix":"287"},{"desc":"288","fix":"289"},{"desc":"290","fix":"291"},{"desc":"292","fix":"293"},{"desc":"294","fix":"295"},{"desc":"296","fix":"297"},{"desc":"298","fix":"299"},{"desc":"300","fix":"301"},{"desc":"290","fix":"302"},"Update the dependencies array to be: [activeBoard, activeBoard.id, activeBoard.layout]",{"range":"303","text":"304"},"Update the dependencies array to be: [activeBoard, activeBoard.id, listConfigSignature, runAllInBoard]",{"range":"305","text":"306"},"Update the dependencies array to be: [sortedStudents, sortType, searchKeyword, getGroupName, t]",{"range":"307","text":"308"},"Update the dependencies array to be: [reasons, t]",{"range":"309","text":"310"},"Update the dependencies array to be: [onGlobalMouseMove, onGlobalMouseUp]",{"range":"311","text":"312"},"Update the dependencies array to be: [handleDeepLink]",{"range":"313","text":"314"},"Update the dependencies array to be: [currentTheme, t]",{"range":"315","text":"316"},"Update the dependencies array to be: [students, showOobeMessage, t]",{"range":"317","text":"318"},"Update the dependencies array to be: [loadAll]",{"range":"319","text":"320"},"Update the dependencies array to be: [messageApi, t]",{"range":"321","text":"322"},"Update the dependencies array to be: [t]",{"range":"323","text":"324"},"Update the dependencies array to be: [t, isMobile, canEdit, handleOpenTagEditor, handleOpenGroupEditor, handleOpenAvatarEditor, handleDelete]",{"range":"325","text":"326"},{"range":"327","text":"316"},[18048,18086],"[activeBoard, activeBoard.id, activeBoard.layout]",[18296,18349],"[activeBoard, activeBoard.id, listConfigSignature, runAllInBoard]",[16069,16139],"[sortedStudents, sortType, searchKeyword, getGroupName, t]",[16914,16923],"[reasons, t]",[53173,53175],"[onGlobalMouseMove, onGlobalMouseUp]",[3088,3090],"[handleDeepLink]",[4674,4688],"[currentTheme, t]",[10389,10411],"[students, showOobeMessage, t]",[7646,7648],"[loadAll]",[1697,1709],"[messageApi, t]",[3343,3345],"[t]",[45580,45624],"[t, isMobile, canEdit, handleOpenTagEditor, handleOpenGroupEditor, handleOpenAvatarEditor, handleDelete]",[3185,3199]] \ No newline at end of file diff --git a/AUTH/API.MD b/AUTH/API.MD new file mode 100644 index 0000000..fc62a7f --- /dev/null +++ b/AUTH/API.MD @@ -0,0 +1,922 @@ +# SECTL Auth API 参考 + +本文档详细说明 SECTL Auth 提供的所有 API 接口。 + +## 基础信息 + +- **基础 URL**: `https://sectl.top` +- **认证方式**: OAuth 2.0 + Bearer Token +- **数据格式**: JSON + +## 接口概览 + +| 接口 | 方法 | 说明 | 对应页面 | +| ----------------------- | ---- | ------------------- | ------------------------ | +| `/oauth/authorize` | GET | 获取授权码 | OAuthAuthorizeView.vue | +| `/api/oauth/token` | POST | 换取/刷新令牌 | AuthCallbackView.vue | +| `/api/oauth/userinfo` | GET | 获取用户信息 | PlatformUsersView.vue | +| `/api/oauth/config` | POST | 配置 Token 过期时间 | PlatformSettingsView.vue | +| `/api/oauth/revoke` | POST | 撤销令牌 | SecurityView.vue | +| `/api/oauth/introspect` | POST | 验证令牌 | - | + +--- + +## OAuth 接口 + +### 1. 获取授权码 + +引导用户跳转到授权页面。 + +```http +GET /oauth/authorize +``` + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +| ------------- | ------ | ---- | ---------------- | +| client_id | string | ✅ | 平台 ID | +| redirect_uri | string | ✅ | 回调 URL | +| response_type | string | ✅ | 固定值 `code` | +| state | string | ❌ | 防 CSRF 状态参数 | + +**示例请求:** + +``` +https://sectl.top/oauth/authorize?client_id=pf_AHmIhAiptyLFxk8x&redirect_uri=http://localhost:5000/callback&response_type=code +``` + +**成功响应:** + +- 重定向到 `redirect_uri?code={authorization_code}&state={state}` + +**错误响应:** + +- 重定向到 `redirect_uri?error={error_code}&error_description={description}` + +**错误码:** + +| 错误码 | 说明 | +| ------------------------- | ------------------ | +| invalid_request | 请求参数缺失或无效 | +| unauthorized_client | 客户端未授权 | +| access_denied | 用户拒绝授权 | +| unsupported_response_type | 不支持的响应类型 | +| invalid_scope | 无效的权限范围 | +| server_error | 服务器内部错误 | + +**相关页面:** + +- [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue) - 授权页面 +- [LoginView.vue](../../src/views/LoginView.vue) - 登录页面 + +--- + +### 2. 获取访问令牌 + +使用授权码换取访问令牌。 + +```http +POST /api/oauth/token +``` + +**请求头:** + +``` +Content-Type: application/json +``` + +**请求体:** + +| 参数名 | 类型 | 必填 | 说明 | +| ------------- | ------ | ---- | ------------------------------------------------ | +| grant_type | string | ✅ | `authorization_code` 或 `refresh_token` | +| code | string | 条件 | 授权码(grant_type=authorization_code 时必填) | +| refresh_token | string | 条件 | 刷新令牌(grant_type=refresh_token 时必填) | +| client_id | string | ✅ | 平台 ID | +| client_secret | string | ✅ | 平台密钥 | +| redirect_uri | string | 条件 | 回调地址(grant_type=authorization_code 时必填) | + +**授权码模式示例:** + +```json +{ + "grant_type": "authorization_code", + "code": "NjliNDIyOTYwMDE4znLUNeWM6J8ye0MC", + "client_id": "pf_AHmIhAiptyLFxk8x", + "client_secret": "sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1", + "redirect_uri": "http://localhost:5000/callback" +} +``` + +**刷新令牌模式示例:** + +```json +{ + "grant_type": "refresh_token", + "refresh_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "client_id": "pf_AHmIhAiptyLFxk8x", + "client_secret": "sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1" +} +``` + +**成功响应:** + +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +**响应字段:** + +| 字段 | 类型 | 说明 | +| ------------- | ------ | ----------------------- | +| access_token | string | 访问令牌(JWT) | +| refresh_token | string | 刷新令牌 | +| token_type | string | 令牌类型,固定为 Bearer | +| expires_in | number | 过期时间(秒) | + +**错误响应:** + +```json +{ + "error": "invalid_grant", + "error_description": "Authorization code has expired" +} +``` + +**错误码:** + +| 错误码 | HTTP 状态 | 说明 | +| ---------------------- | --------- | ------------------------ | +| invalid_request | 400 | 请求格式错误 | +| invalid_client | 401 | 客户端认证失败 | +| invalid_grant | 400 | 授权码无效或过期 | +| unauthorized_client | 400 | 客户端无权使用此授权类型 | +| unsupported_grant_type | 400 | 不支持的授权类型 | +| invalid_scope | 400 | 无效的权限范围 | + +**相关页面:** + +- [AuthCallbackView.vue](../../src/views/AuthCallbackView.vue) - 回调处理 + +--- + +### 3. 获取用户信息 + +使用访问令牌获取用户信息。 + +```http +GET /api/oauth/userinfo +``` + +**请求头:** + +``` +Authorization: Bearer {access_token} +``` + +**成功响应:** + +```json +{ + "user_id": "69bd422960018cf4d0e5", + "email": "user@example.com", + "name": "张三", + "github_username": "zhangsan", + "permission": 1 +} +``` + +**响应字段:** + +| 字段 | 类型 | 说明 | +| --------------- | ------ | --------------------- | +| user_id | string | 用户唯一标识 | +| email | string | 用户邮箱 | +| name | string | 用户名称 | +| github_username | string | GitHub 用户名(如有) | +| permission | number | 用户权限等级 | + +**错误响应:** + +```json +{ + "error": "invalid_token", + "error_description": "The access token is invalid or has expired" +} +``` + +**相关页面:** + +- [PlatformUsersView.vue](../../src/views/dashboard/manage/PlatformUsersView.vue) - 平台用户列表 + +--- + +### 4. 配置 Token 过期时间 + +配置平台的 access_token 过期时间(需要管理员权限)。 + +```http +POST /api/oauth/config +``` + +**请求头:** + +``` +Content-Type: application/json +``` + +**请求体:** + +| 参数名 | 类型 | 必填 | 说明 | +| ------------ | ------ | ---- | ------------------------------------------- | +| platform_id | string | ✅ | 平台 ID | +| expires_in | number | ✅ | 过期时间(秒),-1 表示永不过期,最小 60 秒 | +| admin_secret | string | ✅ | 管理员密钥 | + +**请求示例:** + +```json +{ + "platform_id": "pf_AHmIhAiptyLFxk8x", + "expires_in": 86400, + "admin_secret": "your-admin-secret" +} +``` + +**成功响应:** + +```json +{ + "success": true, + "message": "Token expiration time updated", + "expires_in": 86400 +} +``` + +**错误响应:** + +```json +{ + "error": "unauthorized", + "error_description": "Invalid admin secret" +} +``` + +**相关页面:** + +- [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) - 平台设置 + +--- + +### 5. 撤销令牌 + +撤销指定的访问令牌或刷新令牌。 + +```http +POST /api/oauth/revoke +``` + +**请求头:** + +``` +Content-Type: application/json +``` + +**请求体:** + +| 参数名 | 类型 | 必填 | 说明 | +| --------------- | ------ | ---- | --------------------------------- | +| token | string | ✅ | 要撤销的令牌 | +| token_type_hint | string | ❌ | `access_token` 或 `refresh_token` | +| client_id | string | ✅ | 平台 ID | +| client_secret | string | ✅ | 平台密钥 | + +**请求示例:** + +```json +{ + "token": "eyJhbGciOiJIUzI1NiIs...", + "token_type_hint": "access_token", + "client_id": "pf_AHmIhAiptyLFxk8x", + "client_secret": "sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1" +} +``` + +**成功响应:** + +```json +{ + "success": true, + "message": "Token revoked successfully" +} +``` + +**相关页面:** + +- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue) - 安全设置 +- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) - 登录平台列表 + +--- + +### 6. 验证令牌 + +验证访问令牌的有效性和获取令牌信息。 + +```http +POST /api/oauth/introspect +``` + +**请求头:** + +``` +Content-Type: application/json +``` + +**请求体:** + +| 参数名 | 类型 | 必填 | 说明 | +| ------------- | ------ | ---- | ------------ | +| token | string | ✅ | 要验证的令牌 | +| client_id | string | ✅ | 平台 ID | +| client_secret | string | ✅ | 平台密钥 | + +**请求示例:** + +```json +{ + "token": "eyJhbGciOiJIUzI1NiIs...", + "client_id": "pf_AHmIhAiptyLFxk8x", + "client_secret": "sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1" +} +``` + +**活跃令牌响应:** + +```json +{ + "active": true, + "user_id": "69bd422960018cf4d0e5", + "client_id": "pf_AHmIhAiptyLFxk8x", + "exp": 1704783600, + "iat": 1704780000 +} +``` + +**非活跃令牌响应:** + +```json +{ + "active": false +} +``` + +**响应字段:** + +| 字段 | 类型 | 说明 | +| --------- | ------- | ------------------------ | +| active | boolean | 令牌是否有效 | +| user_id | string | 用户 ID(仅活跃令牌) | +| client_id | string | 平台 ID(仅活跃令牌) | +| exp | number | 过期时间戳(仅活跃令牌) | +| iat | number | 签发时间戳(仅活跃令牌) | + +--- + +## 平台管理 API + +以下 API 用于管理平台信息,需要通过 Appwrite SDK 调用。 + +### 获取平台列表 + +```javascript +// 使用 Appwrite SDK +const result = await databases.listDocuments({ + databaseId: "69bd89d8000304c37368", + collectionId: "platforms", + queries: [Query.orderDesc("$createdAt"), Query.limit(100)], +}) +``` + +**相关页面:** + +- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) + +### 获取平台详情 + +```javascript +const platform = await databases.getDocument({ + databaseId: "69bd89d8000304c37368", + collectionId: "platforms", + documentId: "platform-document-id", +}) +``` + +**相关页面:** + +- [PlatformDetailView.vue](../../src/views/dashboard/detail/PlatformDetailView.vue) + +### 更新平台信息 + +```javascript +await databases.updateDocument({ + databaseId: "69bd89d8000304c37368", + collectionId: "platforms", + documentId: "platform-document-id", + data: { + callback_url: "http://new-callback.com", + token_expires_in: 7200, + }, +}) +``` + +**相关页面:** + +- [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) + +### 申请创建平台 + +```javascript +await databases.createDocument({ + databaseId: "69bd89d8000304c37368", + collectionId: "platform_applications", + documentId: ID.unique(), + data: { + name: "我的应用", + description: "应用描述", + callback_url: "http://localhost:5000/callback", + owner_id: currentUser.$id, + status: "pending", + created_at: new Date().toISOString(), + }, +}) +``` + +**相关页面:** + +- [PlatformApplyView.vue](../../src/views/dashboard/form/PlatformApplyView.vue) + +--- + +## 用户管理 API + +### 获取用户授权的平台列表 + +```javascript +const result = await databases.listDocuments({ + databaseId: "69bd89d8000304c37368", + collectionId: "oauth_tokens", + queries: [Query.equal("user_id", currentUser.$id), Query.equal("revoked", false)], +}) +``` + +**相关页面:** + +- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) + +### 获取平台授权用户列表 + +```javascript +const result = await databases.listDocuments({ + databaseId: "69bd89d8000304c37368", + collectionId: "oauth_tokens", + queries: [Query.equal("platform_id", "pf_xxx"), Query.equal("revoked", false)], +}) +``` + +**相关页面:** + +- [PlatformUsersView.vue](../../src/views/dashboard/manage/PlatformUsersView.vue) + +--- + +## 云存储 API + +云存储 API 提供文件上传、下载、管理等功能。需要通过 OAuth access_token 进行认证。 + +### 接口概览 + +| 接口 | 方法 | 说明 | SDK 方法 | +| ------------------------------- | ------ | ------------------------ | ------------------------------- | +| `/api/cloud/upload` | POST | 上传文件 | `uploadFile()` | +| `/api/cloud/files/{file_id}` | GET | 获取文件信息 | `getFile()` | +| `/api/cloud/download/{file_id}` | GET | 获取下载链接 | `downloadFile()` | +| `/api/cloud/view/{file_id}` | GET | 查看文件内容 | `viewFile()` | +| `/api/cloud/preview/{file_id}` | GET | 预览文件(支持图片处理) | `previewFile()` | +| `/api/cloud/info` | GET | 获取存储信息 | `getStorageInfo()` | +| `/api/cloud/files` | GET | 列出/搜索文件 | `listFiles()` / `searchFiles()` | +| `/api/cloud/files/{file_id}` | PUT | 更新文件(重命名) | `updateFile()` | +| `/api/cloud/files/{file_id}` | DELETE | 删除文件 | `deleteFile()` | + +--- + +### 1. 上传文件 + +上传文件到云存储,系统会自动验证文件格式和检查存储容量。 + +```http +POST /api/cloud/upload +``` + +**请求头:** + +``` +Authorization: Bearer {access_token} +Content-Type: multipart/form-data +``` + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +| ----------- | ------ | ---- | ------------ | +| file | File | ✅ | 要上传的文件 | +| platform_id | string | ✅ | 平台 ID | + +**请求示例(JavaScript):** + +```javascript +const formData = new FormData() +formData.append("file", fileInput.files[0]) +formData.append("platform_id", "pf_AHmIhAiptyLFxk8x") + +const response = await fetch("https://sectl.top/api/cloud/upload", { + method: "POST", + headers: { + Authorization: "Bearer eyJhbGciOiJIUzI1NiIs...", + }, + body: formData, +}) +``` + +**成功响应:** + +```json +{ + "success": true, + "file_id": "cf_xxxxxxxx", + "filename": "example.jpg", + "size": 1024567, + "url": "https://cloud.appwrite.io/v1/storage/buckets/69c869010007b752a9b1/files/xxx/view", + "storage": { + "used": "50MB", + "limit": "100MB", + "available": "50MB", + "percentage": 50 + } +} +``` + +**错误响应 - 格式不允许:** + +```json +{ + "error": "invalid_format", + "error_description": "File format 'exe' is not allowed", + "allowed_formats": ["jpg", "png", "pdf"] +} +``` + +**错误响应 - 超出容量:** + +```json +{ + "error": "storage_exceeded", + "error_description": "Upload would exceed storage limit", + "storage": { + "used": "95MB", + "limit": "100MB", + "available": "5MB", + "requested": "10MB" + } +} +``` + +**相关页面:** + +- [CloudServiceView.vue](../../src/views/dashboard/CloudServiceView.vue) - 云服务总览 + +--- + +### 2. 下载文件 + +获取文件的下载链接。 + +```http +GET /api/cloud/download/{file_id} +``` + +**请求头:** + +``` +Authorization: Bearer {access_token} +``` + +**路径参数:** + +| 参数名 | 类型 | 必填 | 说明 | +| ------- | ------ | ---- | ------- | +| file_id | string | ✅ | 文件 ID | + +**成功响应:** + +```json +{ + "success": true, + "file_id": "cf_xxxxxxxx", + "filename": "example.jpg", + "download_url": "https://cloud.appwrite.io/v1/storage/buckets/69c869010007b752a9b1/files/xxx/download", + "expires_at": "2025-01-01T01:00:00Z" +} +``` + +--- + +### 3. 获取存储信息 + +获取用户在该平台的存储使用情况和配置信息。 + +```http +GET /api/cloud/info?platform_id={platform_id} +``` + +**请求头:** + +``` +Authorization: Bearer {access_token} +``` + +**查询参数:** + +| 参数名 | 类型 | 必填 | 说明 | +| ----------- | ------ | ---- | ------- | +| platform_id | string | ✅ | 平台 ID | + +**成功响应:** + +```json +{ + "success": true, + "platform_id": "pf_xxx", + "tier": "free", + "storage": { + "used": 52428800, + "used_formatted": "50MB", + "limit": 104857600, + "limit_formatted": "100MB", + "available": 52428800, + "available_formatted": "50MB", + "percentage": 50 + }, + "allowed_formats": ["jpg", "png", "pdf"], + "max_file_size": 10485760 +} +``` + +--- + +### 4. 列出文件 + +获取用户在该平台的文件列表。 + +```http +GET /api/cloud/files?platform_id={platform_id}&limit=20&offset=0 +``` + +**请求头:** + +``` +Authorization: Bearer {access_token} +``` + +**查询参数:** + +| 参数名 | 类型 | 必填 | 说明 | +| ----------- | ------ | ---- | ----------------- | +| platform_id | string | ✅ | 平台 ID | +| limit | number | ❌ | 每页数量,默认 20 | +| offset | number | ❌ | 偏移量,默认 0 | + +**成功响应:** + +```json +{ + "success": true, + "files": [ + { + "file_id": "cf_xxx", + "filename": "example.jpg", + "size": 1024567, + "size_formatted": "1MB", + "created_at": "2025-01-01T00:00:00Z" + } + ], + "total": 10, + "limit": 20, + "offset": 0 +} +``` + +--- + +### 5. 删除文件 + +删除指定的文件。 + +```http +DELETE /api/cloud/files/{file_id} +``` + +**请求头:** + +``` +Authorization: Bearer {access_token} +``` + +**路径参数:** + +| 参数名 | 类型 | 必填 | 说明 | +| ------- | ------ | ---- | ------- | +| file_id | string | ✅ | 文件 ID | + +**成功响应:** + +```json +{ + "success": true, + "message": "File deleted successfully", + "storage": { + "used": "45MB", + "limit": "100MB", + "available": "55MB" + } +} +``` + +--- + +## 云存储 SDK + +我们提供了 JavaScript SDK 简化云存储 API 的调用。 + +### 安装 + +```html + +``` + +或使用 ES6 模块: + +```javascript +import CloudStorage from "./cloud-storage.js" +``` + +### 初始化 + +```javascript +const cloud = new CloudStorage({ + baseUrl: "https://sectl.top", + platformId: "your_platform_id", + accessToken: "your_access_token", +}) +``` + +### 使用示例 + +```javascript +// 上传文件 +try { + const result = await cloud.uploadFile(fileInput.files[0]) + console.log("文件上传成功:", result.file_id) + console.log("存储使用情况:", result.storage.percentage + "%") +} catch (error) { + if (error.code === "invalid_format") { + console.error("不支持的文件格式:", error.data.allowed_formats) + } else if (error.code === "storage_exceeded") { + console.error("存储空间不足") + } +} + +// 获取存储信息 +const info = await cloud.getStorageInfo() +console.log("已用空间:", info.storage.used_formatted) +console.log("允许格式:", info.allowed_formats) + +// 列出文件 +const files = await cloud.listFiles({ limit: 10 }) +files.files.forEach((file) => { + console.log(file.filename, file.size_formatted) +}) + +// 下载文件 +const download = await cloud.downloadFile("cf_xxx") +window.location.href = download.download_url + +// 删除文件 +await cloud.deleteFile("cf_xxx") +``` + +**相关文件:** + +- [cloud-storage.js](../../platform-sdk/cloud-storage.js) - SDK 源码 + +--- + +## 远程退登 API + +### 创建退登事件 + +当用户退出登录时,系统会自动创建退登事件。 + +```javascript +await databases.createDocument({ + databaseId: "69bd89d8000304c37368", + collectionId: "logout_events", + documentId: ID.unique(), + data: { + user_id: currentUser.$id, + platform_id: null, // null 表示全局退出 + global: true, + created_at: new Date().toISOString(), + }, +}) +``` + +### 订阅退登事件 + +使用 Appwrite Realtime 订阅退登事件: + +```javascript +import { Client } from "appwrite" + +const client = new Client() + .setEndpoint("https://cloud.appwrite.io/v1") + .setProject("your-project-id") + +const unsubscribe = client.subscribe( + "databases.69bd89d8000304c37368.collections.logout_events.documents", + (response) => { + const event = response.payload + if (event.user_id === currentUserId) { + // 执行本地退出 + localLogout() + } + } +) +``` + +**相关页面:** + +- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue) + +--- + +## 错误处理 + +### HTTP 状态码 + +| 状态码 | 说明 | +| ------ | ------------------ | +| 200 | 请求成功 | +| 400 | 请求参数错误 | +| 401 | 未授权,认证失败 | +| 403 | 禁止访问,权限不足 | +| 404 | 资源不存在 | +| 500 | 服务器内部错误 | + +### 错误响应格式 + +```json +{ + "error": "error_code", + "error_description": "详细的错误描述", + "error_uri": "https://docs.example.com/error/error_code" +} +``` + +--- + +## 安全建议 + +1. **使用 HTTPS** - 所有 API 通信必须使用 HTTPS +2. **保护密钥** - 不要在客户端代码中暴露 client_secret +3. **验证回调地址** - 确保 redirect_uri 与注册时一致 +4. **使用状态参数** - 添加 state 参数防止 CSRF 攻击 +5. **安全存储令牌** - Token 应安全存储,避免泄露 +6. **及时撤销** - 用户退出时及时撤销令牌 + +--- + +## 相关页面 + +- [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue) - OAuth 授权页面 +- [AuthCallbackView.vue](../../src/views/AuthCallbackView.vue) - 认证回调 +- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) - 平台管理 +- [PlatformDetailView.vue](../../src/views/dashboard/detail/PlatformDetailView.vue) - 平台详情 +- [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) - 平台设置 +- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue) - 安全设置 +- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) - 登录平台列表 diff --git a/AUTH/AUTH.MD b/AUTH/AUTH.MD new file mode 100644 index 0000000..d77c64b --- /dev/null +++ b/AUTH/AUTH.MD @@ -0,0 +1,482 @@ +# 平台接入指南 + +本文档介绍如何将第三方平台与 SECTL Auth 集成,实现统一认证和远程退登功能。 + +## 目录 + +- [概述](#概述) +- [快速开始](#快速开始) +- [管理后台功能](#管理后台功能) +- [OAuth 集成](#oauth-集成) +- [远程退登](#远程退登) +- [代码示例](#代码示例) +- [常见问题](#常见问题) + +## 概述 + +SECTL Auth 提供以下核心功能: + +1. **统一认证** - 用户使用 SECTL Auth 账号登录第三方平台 +2. **远程退登** - 用户在 SECTL Auth 中心退出后,平台自动退出 +3. **用户管理** - 查看已授权用户、统计信息等 + +### 工作流程 + +``` +┌─────────────┐ 登录请求 ┌─────────────┐ +│ 用户 │ ─────────────> │ SECTL Auth │ +│ │ │ │ +│ 平台A │ <───────────── │ 认证中心 │ +│ │ Access Token │ │ +└──────┬──────┘ └──────┬──────┘ + │ │ + │ 远程退登通知 │ + │ <─────────────────────────── │ + │ (Realtime) │ + ▼ ▼ + 自动退出登录 用户点击退出 +``` + +## 快速开始 + +### 1. 注册平台 + +在 SECTL Auth 平台管理页面 ([PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue)) 申请接入,获取: + +| 参数 | 说明 | 示例 | +| -------------- | ------------ | ------------------------------------- | +| platform_id | 平台唯一标识 | `pf_AHmIhAiptyLFxk8x` | +| platform_token | 平台认证密钥 | `sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1` | +| callback_url | 回调地址 | `http://localhost:5000/callback` | + +### 2. 平台状态说明 + +平台在管理后台有以下状态: + +| 状态 | 说明 | 可操作 | +| -------- | ------ | -------- | +| active | 运行中 | 正常授权 | +| pending | 审核中 | 等待审核 | +| disabled | 已停用 | 无法授权 | + +### 3. 查看统计信息 + +在平台管理页面可以查看: + +- **总平台数** - 已创建的平台数量 +- **运行中** - 活跃平台数量 +- **审核中** - 待审核申请数量 +- **总用户数** - 授权用户总数 + +## 管理后台功能 + +### 平台列表 ([PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue)) + +平台管理页面提供以下功能: + +#### 1. 平台卡片展示 + +每个平台显示: + +- 平台名称和描述 +- 状态标签(运行中/审核中/已停用) +- 创建时间 +- 授权用户数 +- 管理按钮 + +#### 2. 搜索和过滤 + +- 按名称搜索平台 +- 按状态过滤 +- 分页展示 + +#### 3. 快捷操作 + +- **新建平台** - 跳转到申请页面 +- **我的申请** - 查看自己的平台申请 +- **审核申请** - 审核其他用户的申请(需 >=18 权限) + +### 平台详情 ([PlatformDetailView.vue](../../src/views/dashboard/detail/PlatformDetailView.vue)) + +查看平台详细信息: + +#### 基本信息 + +- 平台 ID (Client ID) +- 平台密钥 (Client Secret) +- 回调地址 +- 创建时间 + +#### 安全设置 + +- Token 过期时间配置 +- 密钥重置 +- 回调地址修改 + +#### 用户统计 + +- 授权用户列表 +- 登录时间 +- 最后活跃时间 + +### 平台申请 ([PlatformApplyView.vue](../../src/views/dashboard/form/PlatformApplyView.vue)) + +申请创建新平台: + +```javascript +// 申请参数 +{ + name: "我的应用", // 平台名称 + description: "应用描述", // 平台描述 + callback_url: "http://..." // 回调地址 +} +``` + +申请后状态为 `pending`,等待管理员审核。 + +### 申请审核 ([PlatformApplicationsView.vue](../../src/views/dashboard/manage/PlatformApplicationsView.vue)) + +管理员审核平台申请: + +- 查看申请详情 +- 批准申请(状态变为 `active`) +- 拒绝申请(状态变为 `disabled`) + +## OAuth 集成 + +### 授权流程 + +#### 1. 引导用户到授权页面 + +``` +GET https://sectl.top/oauth/authorize +``` + +**参数:** + +| 参数名 | 必填 | 说明 | +| ------------- | ---- | ------------- | +| client_id | ✅ | 平台 ID | +| redirect_uri | ✅ | 回调地址 | +| response_type | ✅ | 固定值 `code` | + +**示例:** + +``` +https://sectl.top/oauth/authorize?client_id=pf_xxx&redirect_uri=http://localhost:5000/callback&response_type=code +``` + +#### 2. 处理回调 + +用户授权后,会重定向到: + +``` +{callback_url}?code={authorization_code} +``` + +#### 3. 换取访问令牌 + +```http +POST https://sectl.top/api/oauth/token +Content-Type: application/json + +{ + "grant_type": "authorization_code", + "code": "授权码", + "client_id": "pf_xxx", + "client_secret": "sk_xxx", + "redirect_uri": "http://localhost:5000/callback" +} +``` + +**响应:** + +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "xxx", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +#### 4. 获取用户信息 + +```http +GET https://sectl.top/api/oauth/userinfo +Authorization: Bearer {access_token} +``` + +**响应:** + +```json +{ + "user_id": "69bd422960018cf4d0e5", + "email": "user@example.com", + "name": "张三", + "github_username": "zhangsan" +} +``` + +## 远程退登 + +### 实现原理 + +1. 用户在 SECTL Auth 点击退出登录 +2. 系统创建 `logout_events` 文档 +3. 第三方应用通过 Appwrite Realtime 订阅该集合 +4. 收到匹配的事件后执行本地退出 + +### 集成步骤 + +#### 1. 安装 Appwrite SDK + +**C#** + +```bash +dotnet add package Appwrite +``` + +**Python** + +```bash +pip install appwrite +``` + +#### 2. 配置 Realtime 订阅 + +**C# 示例:** + +```csharp +using Appwrite; +using Appwrite.Services; + +var client = new Client() + .SetEndpoint("https://cloud.appwrite.io/v1") + .SetProject("your-project-id"); + +var realtime = new Realtime(client); + +// 订阅 logout_events 集合 +realtime.Subscribe("databases.{databaseId}.collections.logout_events.documents", + callback: (message) => { + var logoutEvent = message.Payload; + if (logoutEvent.UserId == currentUserId) { + // 执行本地退出 + LocalLogout(); + } + }); +``` + +**Python 示例:** + +```python +from appwrite.client import Client +from appwrite.services.realtime import Realtime + +client = Client() +client.set_endpoint('https://cloud.appwrite.io/v1') +client.set_project('your-project-id') + +realtime = Realtime(client) + +def on_logout_event(message): + event = message['payload'] + if event['user_id'] == current_user_id: + # 执行本地退出 + local_logout() + +# 订阅 logout_events 集合 +realtime.subscribe( + 'databases.{database_id}.collections.logout_events.documents', + callback=on_logout_event +) +``` + +### 数据结构 + +**logout_events 集合字段:** + +| 字段 | 类型 | 说明 | +| ----------- | ------- | ------------------ | +| user_id | String | 退出登录的用户ID | +| platform_id | String | 特定平台ID(可选) | +| global | Boolean | 是否全局退出 | +| created_at | String | 创建时间 | + +## 代码示例 + +### C# 完整示例 + +详见 [csharp-example](./csharp-example/) 目录。 + +```csharp +// SectlAuthClient.cs +public class SectlAuthClient +{ + private readonly string _platformId; + private readonly string _platformSecret; + private readonly string _callbackUrl; + private readonly HttpClient _httpClient; + + public SectlAuthClient(string platformId, string platformSecret, string callbackUrl) + { + _platformId = platformId; + _platformSecret = platformSecret; + _callbackUrl = callbackUrl; + _httpClient = new HttpClient(); + } + + // 获取授权 URL + public string GetAuthorizationUrl() + { + return $"https://sectl.top/oauth/authorize?" + + $"client_id={_platformId}&" + + $"redirect_uri={Uri.EscapeDataString(_callbackUrl)}&" + + $"response_type=code"; + } + + // 换取访问令牌 + public async Task ExchangeCodeAsync(string code) + { + var request = new + { + grant_type = "authorization_code", + code = code, + client_id = _platformId, + client_secret = _platformSecret, + redirect_uri = _callbackUrl + }; + + var response = await _httpClient.PostAsJsonAsync( + "https://sectl.top/api/oauth/token", request); + + return await response.Content.ReadFromJsonAsync(); + } + + // 获取用户信息 + public async Task GetUserInfoAsync(string accessToken) + { + _httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", accessToken); + + var response = await _httpClient.GetAsync( + "https://sectl.top/api/oauth/userinfo"); + + return await response.Content.ReadFromJsonAsync(); + } +} +``` + +### Python 完整示例 + +详见 [python-example](./python-example/) 目录。 + +```python +# sectl_auth/client.py +import requests + +class SectlAuthClient: + BASE_URL = "https://sectl.top" + AUTH_URL = "https://sectl.top" + + def __init__(self, platform_id: str, platform_secret: str, callback_url: str): + self.platform_id = platform_id + self.platform_secret = platform_secret + self.callback_url = callback_url + + def get_authorization_url(self) -> str: + """获取授权 URL""" + return ( + f"{self.AUTH_URL}/oauth/authorize?" + f"client_id={self.platform_id}&" + f"redirect_uri={self.callback_url}&" + f"response_type=code" + ) + + def exchange_code(self, code: str) -> dict: + """用授权码换取访问令牌""" + response = requests.post( + f"{self.BASE_URL}/api/oauth/token", + json={ + "grant_type": "authorization_code", + "code": code, + "client_id": self.platform_id, + "client_secret": self.platform_secret, + "redirect_uri": self.callback_url + } + ) + response.raise_for_status() + return response.json() + + def get_user_info(self, access_token: str) -> dict: + """获取用户信息""" + response = requests.get( + f"{self.BASE_URL}/api/oauth/userinfo", + headers={"Authorization": f"Bearer {access_token}"} + ) + response.raise_for_status() + return response.json() +``` + +## 常见问题 + +### Q: 如何获取 platform_id 和 platform_token? + +A: 在 SECTL Auth 平台管理页面申请创建平台,审核通过后会获得: + +- platform_id (Client ID) +- platform_token (Client Secret) + +### Q: 授权码有效期多久? + +A: 授权码有效期为 10 分钟,且只能使用一次。 + +### Q: Access Token 过期后怎么办? + +A: 使用 refresh_token 换取新的 access_token: + +```http +POST /api/oauth/token +{ + "grant_type": "refresh_token", + "refresh_token": "your-refresh-token", + "client_id": "your-platform-id", + "client_secret": "your-platform-secret" +} +``` + +### Q: 如何配置 Token 过期时间? + +A: 在平台设置页面 ([PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue)) 可以配置: + +- access_token 过期时间(默认 3600 秒) +- 设置为 -1 表示永不过期 + +### Q: 远程退登不生效怎么办? + +A: 检查以下几点: + +1. 是否正确订阅了 `logout_events` 集合 +2. 检查 user_id 是否匹配 +3. 确认 Appwrite Realtime 连接正常 +4. 查看浏览器控制台是否有错误 + +### Q: 平台状态显示"审核中"怎么办? + +A: 新创建的平台需要管理员审核。审核通过后状态会变为 `active`。 + +## 相关页面 + +- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) - 平台管理 +- [PlatformDetailView.vue](../../src/views/dashboard/detail/PlatformDetailView.vue) - 平台详情 +- [PlatformApplyView.vue](../../src/views/dashboard/form/PlatformApplyView.vue) - 申请平台 +- [PlatformApplicationsView.vue](../../src/views/dashboard/manage/PlatformApplicationsView.vue) - 审核申请 +- [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) - 平台设置 +- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) - 登录平台列表 + +## 参考文档 + +- [OAuth 2.0 流程](./OAUTH_FLOW.MD) +- [API 参考](./API_REFERENCE.md) +- [故障排查](../oauth-flow/TROUBLESHOOTING.md) diff --git a/AUTH/OAUTH_FLOW.MD b/AUTH/OAUTH_FLOW.MD new file mode 100644 index 0000000..b3279b6 --- /dev/null +++ b/AUTH/OAUTH_FLOW.MD @@ -0,0 +1,311 @@ +# OAuth 2.0 授权流程文档 + +本文档详细说明 SECTL Auth 的 OAuth 2.0 授权流程,帮助第三方应用开发者接入统一认证服务。 + +## 概述 + +SECTL Auth 实现了标准的 OAuth 2.0 授权码流程(Authorization Code Flow),允许第三方应用使用 SECTL Auth 账户进行用户认证。 + +### 流程特点 + +- **安全性高** - 使用授权码模式,避免 Token 暴露在浏览器端 +- **标准化** - 遵循 OAuth 2.0 RFC 6749 规范 +- **远程退登** - 支持通过 Realtime 实现跨平台同步退出 +- **可配置** - 支持自定义 Token 过期时间 + +## 授权流程 + +### 流程图 + +``` +┌─────────┐ ┌─────────────┐ +│ 用户 │ │ 第三方应用 │ +└────┬────┘ └──────┬──────┘ + │ │ + │ 1. 访问应用 │ + │───────────────────────────────────────────────>│ + │ │ + │ 2. 重定向到授权页面 │ + │<───────────────────────────────────────────────│ + │ │ + │ 3. 登录并授权 (LoginView.vue / OAuthAuthorizeView.vue) + │───────────────────────────────────────────────>│ + │ │ + │ 4. 返回授权码 │ + │<───────────────────────────────────────────────│ + │ │ + │ 5. 用授权码换取 Token │ + │ │──────┐ + │ │ │ + │ 6. 返回 Access Token │<─────┘ + │ │ + │ 7. 使用 Token 访问用户资源 │ + │<───────────────────────────────────────────────│ +``` + +## 详细步骤 + +### 1. 引导用户到授权页面 + +当用户需要登录时,第三方应用应将用户重定向到 SECTL Auth 的授权页面。 + +**请求地址:** + +``` +GET https://sectl.top/oauth/authorize +``` + +**请求参数:** + +| 参数名 | 必填 | 类型 | 说明 | +| ------------- | ---- | ------ | --------------------------------- | +| client_id | ✅ | string | 平台 ID,如 `pf_AHmIhAiptyLFxk8x` | +| redirect_uri | ✅ | string | 回调地址,必须与平台设置匹配 | +| response_type | ✅ | string | 固定值 `code` | + +**示例 URL:** + +``` +https://sectl.top/oauth/authorize?client_id=pf_AHmIhAiptyLFxk8x&redirect_uri=http://localhost:5000/callback&response_type=code +``` + +**相关代码:** [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue) + +### 2. 用户登录与授权 + +用户将被引导至 SECTL Auth 登录页面(如果未登录): + +1. **登录页面** ([LoginView.vue](../../src/views/LoginView.vue)) + - 用户输入邮箱密码或使用 GitHub 登录 + - 支持记住我功能 + - 检测 OAuth 参数,登录后自动跳转授权页 + +2. **授权确认页** ([OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue)) + - 显示应用名称和图标 + - 列出请求的权限范围 + - 用户可选择"授权"或"拒绝" + +**权限范围:** + +- 访问用户基本信息(用户名、邮箱、头像) +- 验证用户身份 + +### 3. 获取授权码 + +用户授权后,页面将重定向到指定的回调地址: + +``` +{redirect_uri}?code={authorization_code} +``` + +**示例:** + +``` +http://localhost:5000/callback?code=NjliNDIyOTYwMDE4znLUNeWM6J8ye0MC +``` + +**授权码特性:** + +- 有效期:10 分钟 +- 一次性使用 +- 与 `redirect_uri` 绑定 + +### 4. 换取访问令牌 + +第三方应用后端使用授权码向 SECTL Auth 换取访问令牌。 + +**请求地址:** + +``` +POST https://sectl.top/api/oauth/token +``` + +**请求头:** + +``` +Content-Type: application/json +``` + +**请求体:** + +```json +{ + "grant_type": "authorization_code", + "code": "NjliNDIyOTYwMDE4znLUNeWM6J8ye0MC", + "client_id": "pf_AHmIhAiptyLFxk8x", + "client_secret": "sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1", + "redirect_uri": "http://localhost:5000/callback" +} +``` + +**参数说明:** + +| 参数名 | 必填 | 说明 | +| ------------- | ---- | --------------------------- | +| grant_type | ✅ | 固定值 `authorization_code` | +| code | ✅ | 上一步获取的授权码 | +| client_id | ✅ | 平台 ID | +| client_secret | ✅ | 平台密钥 | +| redirect_uri | ✅ | 必须与授权时一致 | + +**成功响应:** + +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +**Token 信息:** + +| 字段 | 说明 | +| ------------- | ----------------------------------- | +| access_token | 访问令牌,用于调用 API | +| refresh_token | 刷新令牌,用于获取新的 access_token | +| token_type | 令牌类型,固定为 Bearer | +| expires_in | 过期时间(秒) | + +### 5. 使用访问令牌 + +获取 access_token 后,可用于调用 SECTL Auth API 获取用户信息。 + +**获取用户信息:** + +```http +GET https://sectl.top/api/oauth/userinfo +Authorization: Bearer {access_token} +``` + +**响应示例:** + +```json +{ + "user_id": "69bd422960018cf4d0e5", + "email": "user@example.com", + "name": "张三", + "github_username": "zhangsan" +} +``` + +## Token 管理 + +### 过期时间配置 + +平台可配置 Token 过期时间: + +| 令牌类型 | 默认有效期 | 说明 | +| ------------- | --------------- | ---------------------- | +| access_token | 3600 秒 (1小时) | 短期有效,用于访问资源 | +| refresh_token | 长期有效 | 用于刷新 access_token | +| 授权码 | 600 秒 (10分钟) | 一次性使用 | + +**修改过期时间:** + +```http +POST https://sectl.top/api/oauth/config +Content-Type: application/json + +{ + "expires_in": 86400, + "admin_secret": "your-admin-secret" +} +``` + +### 刷新 Token + +当 access_token 过期时,使用 refresh_token 获取新的令牌: + +```http +POST https://sectl.top/api/oauth/token +Content-Type: application/json + +{ + "grant_type": "refresh_token", + "refresh_token": "your-refresh-token", + "client_id": "your-platform-id", + "client_secret": "your-platform-secret" +} +``` + +## 远程退登 + +SECTL Auth 支持远程退登功能,当用户在 SECTL Auth 中心退出登录时,已授权的第三方应用会收到通知。 + +### 实现原理 + +1. 用户在 SECTL Auth 点击退出登录 +2. 系统创建 `logout_events` 文档 +3. 第三方应用通过 Appwrite Realtime 订阅该集合 +4. 收到退登事件后,应用执行本地退出操作 + +### 集成方式 + +详见 [平台接入指南](../platform-integration/README.md)。 + +## 错误处理 + +### 授权错误 + +当授权失败时,将重定向到回调地址并附带错误信息: + +``` +{redirect_uri}?error=access_denied&error_description=user+denied+authorization +``` + +**常见错误码:** + +| 错误码 | 说明 | +| ------------------------- | ------------------ | +| invalid_request | 请求参数缺失或无效 | +| unauthorized_client | 客户端未授权 | +| access_denied | 用户拒绝授权 | +| unsupported_response_type | 不支持的响应类型 | +| invalid_scope | 无效的权限范围 | +| server_error | 服务器内部错误 | + +### Token 错误 + +换取 Token 时的错误响应: + +```json +{ + "error": "invalid_grant", + "error_description": "Authorization code has expired" +} +``` + +**常见错误码:** + +| 错误码 | 说明 | +| ---------------------- | ------------------------ | +| invalid_request | 请求格式错误 | +| invalid_client | 客户端认证失败 | +| invalid_grant | 授权码无效或过期 | +| unauthorized_client | 客户端无权使用此授权类型 | +| unsupported_grant_type | 不支持的授权类型 | +| invalid_scope | 无效的权限范围 | + +## 安全建议 + +1. **使用 HTTPS** - 所有 OAuth 通信必须使用 HTTPS +2. **保护 client_secret** - 不要在客户端代码中暴露 client_secret +3. **验证 redirect_uri** - 确保回调地址与注册时一致 +4. **使用状态参数** - 建议添加 `state` 参数防止 CSRF 攻击 +5. **及时清理** - 使用后的授权码应立即作废 +6. **安全存储** - Token 应安全存储,避免泄露 + +## 相关页面 + +- [LoginView.vue](../../src/views/LoginView.vue) - 登录页面 +- [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue) - OAuth 授权页面 +- [AuthCallbackView.vue](../../src/views/AuthCallbackView.vue) - 认证回调处理 +- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) - 登录平台列表 + +## 参考文档 + +- [平台接入指南](../platform-integration/README.md) +- [API 参考](../platform-integration/API_REFERENCE.md) +- [故障排查](./TROUBLESHOOTING.md) diff --git a/AUTH/TROUBLESHOOTING.md b/AUTH/TROUBLESHOOTING.md new file mode 100644 index 0000000..c24753a --- /dev/null +++ b/AUTH/TROUBLESHOOTING.md @@ -0,0 +1,476 @@ +# OAuth 流程故障排查指南 + +本文档汇总了 SECTL Auth OAuth 流程中的常见问题及解决方案。 + +## 目录 + +- [授权阶段问题](#授权阶段问题) +- [Token 换取问题](#token-换取问题) +- [用户信息获取问题](#用户信息获取问题) +- [远程退登问题](#远程退登问题) +- [平台管理问题](#平台管理问题) +- [网络与 CORS 问题](#网络与-cors-问题) + +--- + +## 授权阶段问题 + +### 1. 授权页面无法打开 + +**现象:** 访问授权 URL 返回 404 或空白页面 + +**排查步骤:** + +1. 检查 URL 格式是否正确 + ``` + https://auth.sectl.top/oauth/authorize?client_id=xxx&redirect_uri=xxx&response_type=code + ``` +2. 确认必要参数是否齐全 + - `client_id` - 平台 ID + - `redirect_uri` - 回调地址 + - `response_type` - 固定值 `code` +3. 检查平台是否存在且状态为 `active` + - 登录 SECTL Auth 管理后台 + - 查看 [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) + - 确认平台状态不是 `pending` 或 `disabled` + +**相关页面:** + +- [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue) +- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) + +--- + +### 2. 授权后没有记录到登录平台 + +**现象:** 用户授权后,在"登录平台"页面 ([LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue)) 看不到记录 + +**排查步骤:** + +1. 检查浏览器控制台是否有错误 +2. 检查 `user_data` 集合是否有该用户的记录 +3. 检查 `user_data.login_platforms` 字段是否正确更新 +4. 检查 `oauth_tokens` 集合是否有对应的令牌记录 + +**可能原因:** + +- 用户未登录 SECTL Auth +- `user_data` 集合权限问题 +- 网络请求失败 +- 平台状态为 `disabled` + +**相关页面:** + +- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) +- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue) + +--- + +### 3. 用户已登录但授权页面仍要求登录 + +**现象:** 用户已登录 SECTL Auth,但授权页面仍跳转到登录页 + +**排查步骤:** + +1. 检查登录状态是否正确保持 +2. 检查浏览器 Cookie 是否被阻止 +3. 检查是否跨域问题 + +**解决方案:** + +- 确保浏览器允许第三方 Cookie +- 检查 `rememberMe` 选项是否勾选 +- 查看 [LoginView.vue](../../src/views/LoginView.vue) 中的登录逻辑 + +--- + +## Token 换取问题 + +### 1. 换取 token 时提示 "Invalid authorization code" + +**现象:** 用 code 换取 token 返回 400 错误 + +```json +{ + "error": "invalid_grant", + "error_description": "Invalid authorization code" +} +``` + +**排查步骤:** + +1. 检查 code 是否已过期(10分钟有效期) +2. 检查 code 是否已被使用过 +3. 检查 `redirect_uri` 是否与授权时一致 +4. 检查 `client_id` 和 `client_secret` 是否正确 + +**可能原因:** + +- Code 已过期 +- Code 已被使用(一次性) +- 回调地址不匹配 +- 平台密钥错误 + +**相关代码:** [AuthCallbackView.vue](../../src/views/AuthCallbackView.vue) + +--- + +### 2. 换取 token 时提示 "Invalid client" + +**现象:** 返回 401 错误 + +```json +{ + "error": "invalid_client", + "error_description": "Client authentication failed" +} +``` + +**排查步骤:** + +1. 确认 `client_id` 正确 +2. 确认 `client_secret` 正确且未泄露 +3. 检查平台状态是否为 `active` + +**解决方案:** + +- 在 [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) 查看平台信息 +- 如密钥泄露,在 [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) 重置密钥 + +--- + +### 3. Access Token 过期后无法刷新 + +**现象:** 使用 refresh_token 换取新 token 失败 + +**排查步骤:** + +1. 检查 refresh_token 是否有效 +2. 检查 refresh_token 是否被撤销 +3. 检查平台是否被禁用 + +**请求示例:** + +```http +POST /api/oauth/token +{ + "grant_type": "refresh_token", + "refresh_token": "your-refresh-token", + "client_id": "your-platform-id", + "client_secret": "your-platform-secret" +} +``` + +--- + +## 用户信息获取问题 + +### 1. 获取用户信息返回 401 + +**现象:** 调用 `/api/oauth/userinfo` 返回未授权错误 + +**排查步骤:** + +1. 检查 access_token 是否过期 +2. 检查 Authorization 头格式是否正确 + ``` + Authorization: Bearer {access_token} + ``` +3. 检查 token 是否被撤销 + +**解决方案:** + +- 使用 refresh_token 获取新的 access_token +- 重新走授权流程 + +**相关页面:** [PlatformUsersView.vue](../../src/views/dashboard/manage/PlatformUsersView.vue) + +--- + +### 2. 用户信息字段缺失 + +**现象:** 返回的用户信息缺少某些字段 + +**排查步骤:** + +1. 检查用户是否设置了该字段 +2. 检查数据库中 `user_data` 集合是否有该字段 + +**响应示例:** + +```json +{ + "user_id": "69bd422960018cf4d0e5", + "email": "user@example.com", + "name": "张三", + "github_username": "zhangsan", + "permission": 1 +} +``` + +--- + +## 远程退登问题 + +### 1. 远程退登不生效 + +**现象:** 用户在 SECTL Auth 退出后,第三方平台没有自动退出 + +**排查步骤:** + +1. 检查是否正确订阅了 `logout_events` 集合 +2. 检查 user_id 是否匹配 +3. 确认 Appwrite Realtime 连接正常 +4. 查看浏览器控制台是否有错误 + +**订阅代码示例:** + +```javascript +client.subscribe( + "databases.69bd89d8000304c37368.collections.logout_events.documents", + (response) => { + const event = response.payload + if (event.user_id === currentUserId) { + localLogout() + } + } +) +``` + +**相关页面:** + +- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue) +- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) + +--- + +### 2. Realtime 连接断开 + +**现象:** 退登事件偶尔收不到 + +**排查步骤:** + +1. 检查网络连接稳定性 +2. 检查是否有心跳机制保持连接 +3. 检查浏览器是否进入休眠状态 + +**解决方案:** + +- 实现重连机制 +- 定期检查连接状态 +- 使用 Service Worker 保持后台连接 + +--- + +## 平台管理问题 + +### 1. 平台管理的"登录用户"为空 + +**现象:** 在平台管理页面查看登录用户,显示为空 + +**排查步骤:** + +1. 检查 `oauth_tokens` 集合是否有记录 +2. 检查记录是否有 `user_id` 字段 +3. 检查 `oauth_tokens` 集合权限 + +**解决方案:** + +如果 `oauth_tokens` 没有 `user_id` 字段,需要在 Appwrite Console 中添加: + +1. 进入 Database → 选择数据库 +2. 找到 `oauth_tokens` 集合 +3. 添加字段:`user_id`(String 类型,非必填) + +**相关页面:** + +- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) +- [PlatformUsersView.vue](../../src/views/dashboard/manage/PlatformUsersView.vue) + +--- + +### 2. 平台状态显示"审核中" + +**现象:** 新创建的平台无法使用,状态为 `pending` + +**排查步骤:** + +1. 检查是否已提交平台申请 +2. 联系管理员审核 +3. 在 [MyPlatformApplicationsView.vue](../../src/views/dashboard/manage/MyPlatformApplicationsView.vue) 查看申请状态 + +**解决方案:** + +- 等待管理员审核 +- 管理员在 [PlatformApplicationsView.vue](../../src/views/dashboard/manage/PlatformApplicationsView.vue) 中批准申请 + +--- + +### 3. 无法修改平台回调地址 + +**现象:** 在平台设置中修改回调地址失败 + +**排查步骤:** + +1. 检查是否有权限修改(所有者或管理员) +2. 检查回调地址格式是否正确 +3. 检查数据库权限配置 + +**相关页面:** [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) + +--- + +## 网络与 CORS 问题 + +### 1. CORS 错误 + +**现象:** 浏览器控制台显示 CORS 错误 + +**错误信息:** + +``` +Access to fetch at 'https://auth.sectl.top/...' from origin 'https://auth.sectl.top' has been blocked by CORS policy +``` + +**解决方案:** + +已在 OAuth API 中添加 CORS 头: + +```javascript +res.setHeader("Access-Control-Allow-Origin", "*") +res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS") +res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization") +``` + +**排查步骤:** + +1. 确认请求头是否正确 +2. 确认是否为预检请求(OPTIONS)问题 +3. 检查服务器 CORS 配置 + +--- + +### 2. 授权码中的用户ID解码失败 + +**现象:** 换取 token 后 `user_id` 为 "anonymous" + +**排查步骤:** + +1. 检查授权码格式(应为 32 位:前16位 base64 用户ID + 后16位随机) +2. 检查 `oauth_codes` 集合是否有 `user_id` 字段 + +**代码逻辑:** + +```javascript +// 生成授权码时编码用户ID +const userIdHash = Buffer.from(userId) + .toString("base64") + .replace(/[^a-zA-Z0-9]/g, "") + .substring(0, 16) +const code = userIdHash + randomPart + +// 换取 token 时解码 +try { + const userIdHash = code.substring(0, 16) + const base64Str = userIdHash.replace(/_/g, "/").replace(/-/g, "+") + "==" + const decoded = Buffer.from(base64Str, "base64").toString("utf8") + tokenUserId = decoded +} catch (e) { + tokenUserId = "anonymous" +} +``` + +--- + +### 3. 网络超时 + +**现象:** 请求 OAuth API 超时 + +**排查步骤:** + +1. 检查网络连接 +2. 检查服务器状态 +3. 增加请求超时时间 + +**解决方案:** + +```javascript +// 增加超时时间 +const response = await fetch(url, { + ...options, + signal: AbortSignal.timeout(30000), // 30秒超时 +}) +``` + +--- + +## 调试技巧 + +### 1. 浏览器开发者工具 + +使用浏览器开发者工具排查问题: + +1. **Network 面板** - 查看请求和响应 +2. **Console 面板** - 查看错误日志 +3. **Application 面板** - 查看 Cookie 和 LocalStorage + +### 2. Appwrite Console + +在 Appwrite Console 中检查: + +1. **Database** - 检查集合数据和权限 +2. **Auth** - 检查用户和会话 +3. **Functions** - 查看函数日志 +4. **Realtime** - 检查实时连接 + +### 3. 日志记录 + +在关键位置添加日志: + +```javascript +// 授权流程日志 +console.log("OAuth params:", { client_id, redirect_uri, response_type }) +console.log("Authorization code:", code) +console.log("Token response:", tokenData) + +// 退登事件日志 +console.log("Logout event received:", event) +console.log("Current user ID:", currentUserId) +``` + +--- + +## 常见问题速查表 + +| 问题 | 可能原因 | 解决方案 | +| :------------- | :------------------- | :---------------------- | +| 授权页面 404 | 平台不存在或状态异常 | 检查平台状态和 ID | +| Invalid code | Code 过期或已使用 | 重新授权获取新 code | +| Invalid client | 密钥错误 | 检查 client_secret | +| Token 过期 | 超过 expires_in | 使用 refresh_token 刷新 | +| 用户信息 401 | Token 无效 | 检查 Token 格式和有效期 | +| 退登不生效 | Realtime 未连接 | 检查订阅代码 | +| CORS 错误 | 跨域配置问题 | 检查服务器 CORS 头 | + +--- + +## 获取帮助 + +如果以上方案无法解决问题: + +1. 查看 [API 参考](../platform-integration/API_REFERENCE.md) +2. 查看 [平台接入指南](../platform-integration/README.md) +3. 提交 Issue 到项目仓库 +4. 联系管理员 + +--- + +## 相关页面 + +- [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue) - OAuth 授权页面 +- [AuthCallbackView.vue](../../src/views/AuthCallbackView.vue) - 认证回调 +- [LoginView.vue](../../src/views/LoginView.vue) - 登录页面 +- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) - 平台管理 +- [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) - 平台设置 +- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue) - 安全设置 +- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) - 登录平台列表 diff --git a/src/components/OAuthCallback.tsx b/src/components/OAuthCallback.tsx new file mode 100644 index 0000000..8e05b8c --- /dev/null +++ b/src/components/OAuthCallback.tsx @@ -0,0 +1,49 @@ +import { useEffect } from "react" +import { useSearchParams, useNavigate } from "react-router-dom" + +export function OAuthCallback() { + const [searchParams] = useSearchParams() + const navigate = useNavigate() + + useEffect(() => { + const code = searchParams.get("code") + const error = searchParams.get("error") + const errorDescription = searchParams.get("error_description") + + if (error) { + window.postMessage( + { + error: errorDescription || error, + }, + window.location.origin + ) + navigate("/") + return + } + + if (code) { + window.postMessage( + { + code, + }, + window.location.origin + ) + navigate("/") + } + }, [searchParams, navigate]) + + return ( +
+
正在处理登录...
+
+ ) +} diff --git a/src/components/OAuthLogin.tsx b/src/components/OAuthLogin.tsx new file mode 100644 index 0000000..85b9593 --- /dev/null +++ b/src/components/OAuthLogin.tsx @@ -0,0 +1,183 @@ +import { Button, message, Modal, Space, Spin } from "antd" +import { useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { open } from "@tauri-apps/plugin-shell" +import { listen, UnlistenFn } from "@tauri-apps/api/event" + +interface OAuthLoginProps { + visible: boolean + onClose: () => void + onSuccess: (userInfo: { + user_id: string + email: string + name: string + github_username?: string + permission: number + }) => void +} + +interface OAuthConfig { + platform_id: string + platform_secret: string + callback_url: string +} + +export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) { + const { t } = useTranslation() + const [loading, setLoading] = useState(false) + const deepLinkUnlistenRef = useRef(null) + + const getOAuthConfig = (): OAuthConfig | null => { + const api = (window as any).api + if (!api) return null + + const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID + const platformSecret = import.meta.env.VITE_OAUTH_PLATFORM_SECRET + const callbackUrl = import.meta.env.VITE_OAUTH_CALLBACK_URL || "secscore://oauth/callback" + + if (!platformId || !platformSecret) { + return null + } + + return { + platform_id: platformId, + platform_secret: platformSecret, + callback_url: callbackUrl, + } + } + + const handleDeepLink = async (url: string) => { + const config = getOAuthConfig() + if (!config) return + + try { + const urlObj = new URL(url) + const code = urlObj.searchParams.get("code") + const error = urlObj.searchParams.get("error") + + if (error) { + message.error(decodeURIComponent(error)) + setLoading(false) + return + } + + if (code) { + const api = (window as any).api + const tokenRes = await api.oauthExchangeCode( + code, + config.platform_id, + config.platform_secret, + config.callback_url + ) + + if (!tokenRes.success) { + message.error(tokenRes.message || "获取访问令牌失败") + setLoading(false) + return + } + + const userRes = await api.oauthGetUserInfo(tokenRes.data.access_token) + + if (!userRes.success) { + message.error(userRes.message || "获取用户信息失败") + setLoading(false) + return + } + + onSuccess(userRes.data) + onClose() + } + } catch (error: any) { + message.error(error.message || "登录失败") + } finally { + setLoading(false) + } + } + + useEffect(() => { + const setupDeepLink = async () => { + try { + const unlisten = await listen("deep-link://new-url", (event) => { + if (event.payload) { + handleDeepLink(event.payload) + } + }) + deepLinkUnlistenRef.current = unlisten + } catch (error) { + console.error("Failed to setup deep link listener:", error) + } + } + + setupDeepLink() + + return () => { + if (deepLinkUnlistenRef.current) { + deepLinkUnlistenRef.current() + deepLinkUnlistenRef.current = null + } + } + }, []) + + const handleOAuthLogin = async () => { + const config = getOAuthConfig() + if (!config) { + message.error("OAuth 配置未设置") + return + } + + setLoading(true) + + try { + const api = (window as any).api + const urlRes = await api.oauthGetAuthorizationUrl(config.platform_id, config.callback_url) + + if (!urlRes.success) { + message.error(urlRes.message || "获取授权链接失败") + setLoading(false) + return + } + + await open(urlRes.data) + } catch (error: any) { + message.error(error.message || "登录失败") + setLoading(false) + } + } + + return ( + +
+ {loading ? ( + + +
{t("auth.oauthLoggingIn", "正在登录...")}
+
+ 请在浏览器中完成授权 +
+
+ ) : ( + +
+ {t("auth.oauthHint", "使用 SECTL Auth 账号登录,享受统一认证和远程退登功能")} +
+ +
+ )} +
+
+ ) +}