diff --git a/docs/plans/2026-03-13-feishu-auth-config-design.md b/docs/plans/2026-03-13-feishu-auth-config-design.md new file mode 100644 index 0000000..6fc2af5 --- /dev/null +++ b/docs/plans/2026-03-13-feishu-auth-config-design.md @@ -0,0 +1,96 @@ +# Feishu Auth Config Unification Design + +**Date:** 2026-03-13 + +**Issue:** `#6` "BUG-飞书app扫码显示无法以此身份登陆" + +## Problem + +The app stores user-provided Feishu credentials in `localStorage.feishu_config`, and `feishuApi` uses that config when exchanging the OAuth code for tokens. However, the QR login flow in `src/components/AuthPage.tsx` still hardcodes a different Feishu App ID when it builds the OAuth authorize URL. + +That creates a split auth flow: + +- QR scan authorizes app A +- Token exchange uses app B + +When the user scans with their own configured app, Feishu rejects the flow as an identity mismatch. + +## Goals + +- Remove the hardcoded Feishu App ID from the QR login flow. +- Make QR authorization and token exchange read from the same config source. +- Preserve the existing config storage format so current users do not need migration. +- Prevent the auth page from rendering a broken QR flow when config is missing or invalid. + +## Non-Goals + +- No migration away from `localStorage`. +- No broader auth state refactor with React context or global stores. +- No change to requested OAuth scopes in this issue. +- No redesign of the settings UI beyond what is needed for clearer auth behavior. + +## Proposed Approach + +Introduce a shared config access module that becomes the single source of truth for Feishu app configuration. + +### Shared Config Access + +Add a small utility that: + +- Reads `localStorage.feishu_config` +- Safely parses JSON +- Verifies required fields: `appId`, `appSecret`, `endpoint` +- Returns a normalized config value when valid +- Returns `null` or a clear invalid result when config is absent or malformed + +`feishuApi` will reuse this utility instead of keeping its own parsing logic. `AuthPage` will also use it before constructing the QR authorization URL. + +### Auth Page Behavior + +`AuthPage` should: + +- Read the shared config before QR initialization +- Use `config.appId` as the OAuth `client_id` +- Skip QR initialization when config is invalid +- Show a user-facing error message and keep the "go to settings" path available + +This keeps the page behavior safe: the user no longer gets a QR code that is guaranteed to fail. + +### Data Flow + +1. `SettingsPage` saves `feishu_config` to `localStorage` +2. Shared config utility reads and validates that stored value +3. `AuthPage` uses the validated config to build the authorize URL +4. `feishuApi` uses the same validated config for token exchange + +Because both steps use the same source, the authorized app and token exchange app remain aligned. + +## Testing Strategy + +Follow TDD: + +1. Add a failing test for config parsing and validation +2. Add a failing test for auth URL construction using configured `appId` +3. Implement the shared config utility and auth page changes +4. Re-run the targeted tests until they pass +5. Run repository verification commands before claiming completion + +Planned coverage: + +- Valid config returns normalized values +- Missing fields are treated as invalid +- Malformed JSON is treated as invalid +- Auth URL includes configured `appId` +- Auth flow no longer depends on the removed hardcoded App ID + +## Risks + +- The repository currently has no frontend test harness, so adding tests may require introducing a minimal test setup. +- `AuthPage` currently mixes QR initialization, plugin setup, and side effects in one component; tests should target extracted pure helpers where possible to avoid brittle DOM-heavy coverage. + +## Success Criteria + +- A user-configured Feishu app can complete QR login without identity mismatch caused by a hardcoded App ID. +- There is no remaining hardcoded Feishu App ID in the login path. +- Shared config parsing logic is reused by both auth URL generation and token exchange. +- Regression tests cover the config source and auth URL behavior. diff --git a/docs/plans/2026-03-13-feishu-auth-config.md b/docs/plans/2026-03-13-feishu-auth-config.md new file mode 100644 index 0000000..e77a7be --- /dev/null +++ b/docs/plans/2026-03-13-feishu-auth-config.md @@ -0,0 +1,305 @@ +# Feishu Auth Config Unification Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Remove the hardcoded Feishu App ID from the QR login flow and make OAuth authorization plus token exchange read from one shared config source. + +**Architecture:** Add a shared config utility for loading and validating `localStorage.feishu_config`, then extract a small auth helper that builds the authorize URL from validated config. Refactor `feishuApi` and `AuthPage` to consume those helpers so both stages of the OAuth flow use the same `appId`. + +**Tech Stack:** React 19, TypeScript, Vite, Tauri, Vitest + +--- + +### Task 1: Add a test harness and capture config parsing behavior + +**Files:** +- Modify: `package.json` +- Modify: `package-lock.json` +- Create: `src/utils/feishuConfig.test.ts` + +**Step 1: Write the failing test** + +```ts +import { describe, expect, it } from 'vitest'; +import { parseFeishuConfig, isFeishuConfigValid } from './feishuConfig'; + +describe('parseFeishuConfig', () => { + it('returns normalized config when all required fields are present', () => { + const config = parseFeishuConfig( + JSON.stringify({ + appId: 'cli_custom_app', + appSecret: 'secret-value', + endpoint: 'https://open.feishu.cn/open-apis', + }) + ); + + expect(config).toEqual({ + appId: 'cli_custom_app', + appSecret: 'secret-value', + endpoint: 'https://open.feishu.cn/open-apis', + }); + }); + + it('returns null when required fields are missing', () => { + expect(parseFeishuConfig(JSON.stringify({ appId: 'cli_only' }))).toBeNull(); + }); + + it('returns null for malformed json', () => { + expect(parseFeishuConfig('{bad json')).toBeNull(); + }); +}); + +describe('isFeishuConfigValid', () => { + it('returns true only for a complete config object', () => { + expect( + isFeishuConfigValid({ + appId: 'cli_custom_app', + appSecret: 'secret-value', + endpoint: 'https://open.feishu.cn/open-apis', + }) + ).toBe(true); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm run test -- src/utils/feishuConfig.test.ts` + +Expected: FAIL because `vitest` and `src/utils/feishuConfig.ts` do not exist yet. + +**Step 3: Add minimal test tooling** + +```json +{ + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^3.2.4" + } +} +``` + +Do not add browser-heavy test libraries. Keep the first test target pure and cheap. + +**Step 4: Run test to verify it still fails for the right reason** + +Run: `npm run test -- src/utils/feishuConfig.test.ts` + +Expected: FAIL because `parseFeishuConfig` is not implemented yet. + +**Step 5: Commit** + +```bash +git add package.json package-lock.json src/utils/feishuConfig.test.ts +git commit -m "test: add coverage for feishu config parsing" +``` + +### Task 2: Implement shared Feishu config loading + +**Files:** +- Create: `src/utils/feishuConfig.ts` +- Modify: `src/utils/feishuApi.ts` + +**Step 1: Write the minimal implementation** + +```ts +export interface FeishuConfig { + appId: string; + appSecret: string; + endpoint: string; +} + +export function parseFeishuConfig(configStr: string | null): FeishuConfig | null { + if (!configStr) return null; + + try { + const parsed = JSON.parse(configStr) as Partial; + if (!parsed.appId || !parsed.appSecret || !parsed.endpoint) { + return null; + } + + return { + appId: parsed.appId, + appSecret: parsed.appSecret, + endpoint: parsed.endpoint, + }; + } catch { + return null; + } +} + +export function loadFeishuConfig(): FeishuConfig | null { + return parseFeishuConfig(localStorage.getItem('feishu_config')); +} + +export function isFeishuConfigValid(config: Partial | null | undefined): boolean { + return !!(config?.appId && config?.appSecret && config?.endpoint); +} +``` + +Update `src/utils/feishuApi.ts` so: + +- `FeishuConfig` is imported from `src/utils/feishuConfig.ts` +- `loadConfig()` delegates to `loadFeishuConfig()` +- `hasValidConfig()` delegates to `loadFeishuConfig() !== null` + +Keep the existing fallback return shape for `loadConfig()` so current callers do not break. + +**Step 2: Run test to verify it passes** + +Run: `npm run test -- src/utils/feishuConfig.test.ts` + +Expected: PASS + +**Step 3: Run type check on touched files** + +Run: `npm run type-check` + +Expected: PASS + +**Step 4: Commit** + +```bash +git add src/utils/feishuConfig.ts src/utils/feishuApi.ts +git commit -m "refactor: centralize feishu config loading" +``` + +### Task 3: Capture auth URL behavior with a failing test + +**Files:** +- Create: `src/utils/feishuAuth.test.ts` + +**Step 1: Write the failing test** + +```ts +import { describe, expect, it } from 'vitest'; +import { buildFeishuAuthorizeUrl } from './feishuAuth'; + +describe('buildFeishuAuthorizeUrl', () => { + it('uses the configured app id as client_id', () => { + const url = buildFeishuAuthorizeUrl({ + appId: 'cli_custom_app', + redirectUri: 'http://localhost:3000/callback', + scope: 'docs:doc offline_access', + state: 'STATE', + }); + + expect(url).toContain('client_id=cli_custom_app'); + expect(url).not.toContain('cli_a1ad86f33c38500d'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm run test -- src/utils/feishuAuth.test.ts` + +Expected: FAIL because `src/utils/feishuAuth.ts` does not exist yet. + +**Step 3: Commit** + +```bash +git add src/utils/feishuAuth.test.ts +git commit -m "test: cover feishu auth url generation" +``` + +### Task 4: Refactor auth flow to use the shared config source + +**Files:** +- Create: `src/utils/feishuAuth.ts` +- Modify: `src/components/AuthPage.tsx` + +**Step 1: Write the minimal implementation** + +```ts +export function buildFeishuAuthorizeUrl(input: { + appId: string; + redirectUri: string; + scope: string; + state?: string; +}): string { + const params = new URLSearchParams({ + client_id: input.appId, + redirect_uri: input.redirectUri, + response_type: 'code', + scope: input.scope, + state: input.state ?? 'STATE', + }); + + return `https://passport.feishu.cn/suite/passport/oauth/authorize?${params.toString()}`; +} +``` + +Refactor `AuthPage.tsx` so: + +- the hardcoded `FEISHU_APP_ID` constant is removed +- the component reads shared config through `loadFeishuConfig()` +- QR initialization returns early when config is invalid +- the authorize URL is built through `buildFeishuAuthorizeUrl()` +- the existing "go to settings" link remains visible when config is invalid + +If the config is invalid, show a user-facing message once instead of trying to open a broken QR flow. + +**Step 2: Run targeted tests** + +Run: `npm run test -- src/utils/feishuConfig.test.ts src/utils/feishuAuth.test.ts` + +Expected: PASS + +**Step 3: Run lint and type check** + +Run: `npm run lint` +Expected: PASS + +Run: `npm run type-check` +Expected: PASS + +**Step 4: Commit** + +```bash +git add src/utils/feishuAuth.ts src/components/AuthPage.tsx +git commit -m "fix: use configured feishu app id for oauth" +``` + +### Task 5: Final verification for the branch + +**Files:** +- Review only: `src/components/AuthPage.tsx` +- Review only: `src/utils/feishuApi.ts` +- Review only: `src/utils/feishuConfig.ts` +- Review only: `src/utils/feishuAuth.ts` + +**Step 1: Run the full frontend verification** + +Run: `npm run test` +Expected: PASS + +Run: `npm run lint` +Expected: PASS + +Run: `npm run type-check` +Expected: PASS + +Run: `npm run build` +Expected: PASS + +**Step 2: Review the diff** + +Run: `git status --short` +Expected: only intended files changed + +Run: `git diff --stat origin/main...HEAD` +Expected: auth config changes, tests, and plan docs only + +**Step 3: Prepare PR** + +Run: + +```bash +git push origin fix-issue-6-auth-config +gh pr create --repo ytcheng/feishu_docs_export --base main --head fix-issue-6-auth-config --title "fix: use configured Feishu app id for OAuth" --body "" +``` + +Expected: branch pushed and PR URL returned diff --git a/package-lock.json b/package-lock.json index 98081a4..532f2da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,8 @@ "eslint-plugin-react-refresh": "^0.4.0", "globals": "^15.0.0", "typescript": "~5.6.2", - "vite": "^6.0.3" + "vite": "^6.0.3", + "vitest": "^3.2.4" } }, "node_modules/@ampproject/remapping": { @@ -176,6 +177,7 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1186,9 +1188,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, @@ -2016,6 +2018,24 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2043,6 +2063,7 @@ "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2094,6 +2115,7 @@ "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", @@ -2327,12 +2349,128 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2496,6 +2634,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2563,6 +2711,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -2576,6 +2725,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2620,6 +2779,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2637,6 +2813,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -2729,7 +2915,8 @@ "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/debug": { "version": "4.4.1", @@ -2749,6 +2936,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2804,6 +3001,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2902,6 +3106,7 @@ "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -3124,6 +3329,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3134,6 +3349,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3675,6 +3900,13 @@ "dev": true, "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3685,6 +3917,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3891,6 +4133,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3904,6 +4163,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4598,6 +4858,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4607,6 +4868,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -4769,6 +5031,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "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", @@ -4779,6 +5048,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-convert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", @@ -4798,6 +5081,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stylis": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", @@ -4826,6 +5129,20 @@ "node": ">=12.22" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -4843,6 +5160,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4894,6 +5241,7 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4949,6 +5297,7 @@ "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -5018,6 +5367,102 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5034,6 +5479,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 4820abc..9a2ec04 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint:fix": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0 --fix", "type-check": "tsc --noEmit", - "test": "echo \"No tests specified\" && exit 0" + "test": "vitest run" }, "dependencies": { "@ant-design/icons": "^6.0.0", @@ -41,6 +41,7 @@ "eslint-plugin-react-refresh": "^0.4.0", "globals": "^15.0.0", "typescript": "~5.6.2", - "vite": "^6.0.3" + "vite": "^6.0.3", + "vitest": "^3.2.4" } } diff --git a/src/components/AuthPage.tsx b/src/components/AuthPage.tsx index 599d6ec..30bebe7 100644 --- a/src/components/AuthPage.tsx +++ b/src/components/AuthPage.tsx @@ -1,15 +1,17 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Card, Typography, App } from 'antd'; import { feishuApi } from '../utils/feishuApi'; import { openUrl} from '@tauri-apps/plugin-opener' import { start, cancel, onUrl } from '@fabianlars/tauri-plugin-oauth'; import { emit } from '@tauri-apps/api/event'; +import { loadFeishuConfig } from '../utils/feishuConfig'; +import { buildFeishuAuthorizeUrl } from '../utils/feishuAuth'; +import { UserInfo } from '../types'; const { Title, Paragraph } = Typography; -const FEISHU_APP_ID = 'cli_a1ad86f33c38500d'; const FEISHU_SCOPE = 'docs:doc docs:document.media:download docs:document:export docx:document drive:drive drive:file drive:file:download offline_access'; // const FEISHU_REDIRECT_URI = 'http://localhost:{}/callback'; @@ -22,7 +24,26 @@ let globalQRInitialized = false; interface AuthData { access_token: string; refresh_token: string; - user_info: any; + user_info: UserInfo; +} + +interface QRLoginInstance { + matchOrigin(origin: string): boolean; + matchData(data: unknown): boolean; +} + +interface QRLoginOptions { + id: string; + goto: string; + width: string; + height: string; + style: string; +} + +interface AuthWindow extends Window { + QRLogin?: (options: QRLoginOptions) => QRLoginInstance; + attachEvent?: (type: 'onmessage', listener: (event: MessageEvent) => void) => void; + detachEvent?: (type: 'onmessage', listener: (event: MessageEvent) => void) => void; } /** @@ -38,7 +59,15 @@ interface AuthPageProps { */ const AuthPage: React.FC = ({ onGoToSettings }) => { const { message } = App.useApp(); + const authWindow = window as AuthWindow; const [scriptLoaded, setScriptLoaded] = useState(false); + const [authConfig] = useState(() => loadFeishuConfig()); + useEffect(() => { + if (!authConfig) { + setScriptLoaded(true); + message.error('请先在设置页填写完整的飞书应用配置'); + } + }, [authConfig, message]); const qrContainerRef = useRef(null); const messageHandlerRef = useRef<((event: MessageEvent) => void) | null>(null); const qrInitializedRef = useRef(false); @@ -49,7 +78,7 @@ const AuthPage: React.FC = ({ onGoToSettings }) => { /** * 处理授权回调 */ - const handleAuthCallback = async (code: string) => { + const handleAuthCallback = useCallback(async (code: string) => { // 防止重复处理授权 if (isProcessingAuth.current) { console.log('授权正在处理中,忽略重复调用'); @@ -69,7 +98,7 @@ const AuthPage: React.FC = ({ onGoToSettings }) => { const authData: AuthData = { access_token: tokenData.access_token, refresh_token: tokenData.refresh_token, - user_info: user_info || {} + user_info }; // 保存认证数据到本地存储 @@ -86,11 +115,15 @@ const AuthPage: React.FC = ({ onGoToSettings }) => { // 授权失败时重置标记,允许重试 isProcessingAuth.current = false; } - }; + }, [message]); //启动回调服务器 useEffect(() => { + if (!authConfig) { + return; + } + const run = async () => { const oauthConfig = { ports: [3000, 3001], @@ -131,12 +164,16 @@ const AuthPage: React.FC = ({ onGoToSettings }) => { // 重置授权处理标记 isProcessingAuth.current = false; } - }, []); + }, [authConfig]); /** * 监听URL变化,检测授权回调 */ useEffect(() => { + if (!authConfig) { + return; + } + let unlistenRef: (() => void) | null = null; const checkAuthCallback = (url: string) => { @@ -181,13 +218,17 @@ const AuthPage: React.FC = ({ onGoToSettings }) => { unlistenRef = null; } }; - }, []); + }, [authConfig, handleAuthCallback]); /** * 加载飞书QR登录脚本 */ useEffect(() => { // 检查是否已插入 script,避免多次插入 + if (!authConfig) { + return; + } + if (!document.getElementById('feishu-qr-script')) { const script = document.createElement('script'); script.id = 'feishu-qr-script'; @@ -204,13 +245,13 @@ const AuthPage: React.FC = ({ onGoToSettings }) => { } else { setScriptLoaded(true); } - }, []); + }, [authConfig, message]); /** * 初始化飞书QR登录(只执行一次) */ useEffect(() => { - if (!scriptLoaded || !qrContainerRef.current || qrInitializedRef.current || globalQRInitialized) return; + if (!authConfig || !scriptLoaded || !qrContainerRef.current || qrInitializedRef.current || globalQRInitialized) return; // 双重检查,防止React严格模式导致的重复执行 if (qrInitializedRef.current || globalQRInitialized) return; @@ -218,10 +259,11 @@ const AuthPage: React.FC = ({ onGoToSettings }) => { globalQRInitialized = true; const containerId = 'feishu-qr-container'; - qrContainerRef.current.id = containerId; + const qrContainer = qrContainerRef.current; + qrContainer.id = containerId; - // @ts-ignore - if (window.QRLogin) { + const qrLogin = authWindow.QRLogin; + if (qrLogin) { // 延迟初始化,确保DOM已经准备好 @@ -231,8 +273,11 @@ const AuthPage: React.FC = ({ onGoToSettings }) => { redirectUri.current = `http://localhost:${port.current}/callback`; console.log("redirectUri", redirectUri.current); - // @ts-ignore - const goto = `https://passport.feishu.cn/suite/passport/oauth/authorize?client_id=${FEISHU_APP_ID}&redirect_uri=${redirectUri.current}&response_type=code&scope=${encodeURIComponent(FEISHU_SCOPE)}&state=STATE`; + const goto = buildFeishuAuthorizeUrl({ + appId: authConfig.appId, + redirectUri: redirectUri.current, + scope: FEISHU_SCOPE, + }); console.log("goto", goto); try { // 清理页面中所有可能存在的飞书QR码元素 @@ -264,8 +309,7 @@ const AuthPage: React.FC = ({ onGoToSettings }) => { console.log('开始初始化飞书QR码...'); - // @ts-ignore - const QRLoginObj = window.QRLogin({ + const QRLoginObj = qrLogin({ id: containerId, goto, width: '300', @@ -276,12 +320,10 @@ const AuthPage: React.FC = ({ onGoToSettings }) => { // 飞书文档要求的 message 监听 const handleMessage = async function (event: MessageEvent) { console.log("handleMessage", event, QRLoginObj); - // @ts-ignore if (QRLoginObj && QRLoginObj.matchOrigin && QRLoginObj.matchData && QRLoginObj.matchOrigin(event.origin) && QRLoginObj.matchData(event.data)) { console.log("handleMessage matched", QRLoginObj, event); - // @ts-ignore - var loginTmpCode = event.data.tmp_code; + const loginTmpCode = (event.data as { tmp_code?: string }).tmp_code; const authUrl = `${goto}&tmp_code=${loginTmpCode}`; console.log("authUrl", authUrl); @@ -304,8 +346,8 @@ const AuthPage: React.FC = ({ onGoToSettings }) => { if (typeof window.addEventListener != 'undefined') { window.addEventListener('message', handleMessage, false); - } else if (typeof (window as any).attachEvent != 'undefined') { - (window as any).attachEvent('onmessage', handleMessage); + } else if (typeof authWindow.attachEvent != 'undefined') { + authWindow.attachEvent('onmessage', handleMessage); } } catch (error) { console.error('初始化飞书QR登录失败:', error); @@ -322,22 +364,20 @@ const AuthPage: React.FC = ({ onGoToSettings }) => { if (messageHandlerRef.current) { if (typeof window.removeEventListener != 'undefined') { window.removeEventListener('message', messageHandlerRef.current, false); - } else if (typeof (window as any).detachEvent != 'undefined') { - (window as any).detachEvent('onmessage', messageHandlerRef.current); + } else if (typeof authWindow.detachEvent != 'undefined') { + authWindow.detachEvent('onmessage', messageHandlerRef.current); } messageHandlerRef.current = null; } // 清理QR容器内容 - if (qrContainerRef.current) { - qrContainerRef.current.innerHTML = ''; - } + qrContainer.innerHTML = ''; // 重置初始化标记 qrInitializedRef.current = false; globalQRInitialized = false; }; - }, [scriptLoaded]); + }, [authConfig, authWindow, message, scriptLoaded]); return (
@@ -358,6 +398,7 @@ const AuthPage: React.FC = ({ onGoToSettings }) => { borderRadius: '6px' }} > + {!authConfig && 请先完成飞书应用配置} {!scriptLoaded && 正在加载二维码...}
@@ -380,4 +421,4 @@ const AuthPage: React.FC = ({ onGoToSettings }) => { ); }; -export default AuthPage; \ No newline at end of file +export default AuthPage; diff --git a/src/utils/feishuApi.ts b/src/utils/feishuApi.ts index 191cf56..707789c 100644 --- a/src/utils/feishuApi.ts +++ b/src/utils/feishuApi.ts @@ -22,16 +22,13 @@ import { import { FeishuFile, FeishuWikiNode, FeishuWikiSpace } from '../types'; import { createTauriAdapter } from './http'; import { TokenExpiredEvent } from '../types/event'; +import { FeishuConfig, loadFeishuConfig } from './feishuConfig'; const FEISHU_SCOPE = 'docs:doc docs:document.media:download docs:document:export docx:document drive:drive drive:file drive:file:download offline_access'; /** * 飞书配置接口 */ -export interface FeishuConfig { - appId: string; - appSecret: string; - endpoint: string; -} +export type { FeishuConfig } from './feishuConfig'; /** * 飞书API客户端类 @@ -124,6 +121,11 @@ export class FeishuApi { * 从 localStorage 加载飞书配置 */ static loadConfig(): FeishuConfig { + const sharedConfig = loadFeishuConfig(); + if (sharedConfig) { + return sharedConfig; + } + try { const configStr = localStorage.getItem('feishu_config'); if (configStr) { @@ -148,6 +150,10 @@ export class FeishuApi { * 检查是否存在有效的飞书配置 */ static hasValidConfig(): boolean { + if (loadFeishuConfig()) { + return true; + } + try { const configStr = localStorage.getItem('feishu_config'); if (configStr) { @@ -383,7 +389,7 @@ export class FeishuApi { ): Promise { const { pageSize = 200, pageToken, orderBy, direction } = options; - const params: any = { + const params: Record = { folder_token: folderId, page_size: pageSize, user_id_type: 'user_id', @@ -435,7 +441,7 @@ export class FeishuApi { ): Promise { const { pageSize = 20, pageToken } = options; - const params: any = { + const params: Record = { page_size: pageSize, user_id_type: 'user_id', }; @@ -479,7 +485,7 @@ export class FeishuApi { ): Promise { const { pageSize = 50, pageToken, parentNodeToken } = options; - const params: any = { + const params: Record = { space_id: spaceId, page_size: pageSize, user_id_type: 'user_id', diff --git a/src/utils/feishuAuth.test.ts b/src/utils/feishuAuth.test.ts new file mode 100644 index 0000000..06fe015 --- /dev/null +++ b/src/utils/feishuAuth.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { buildFeishuAuthorizeUrl } from './feishuAuth'; + +describe('buildFeishuAuthorizeUrl', () => { + it('uses the configured app id as client_id', () => { + const url = buildFeishuAuthorizeUrl({ + appId: 'cli_custom_app', + redirectUri: 'http://localhost:3000/callback', + scope: 'docs:doc offline_access', + state: 'STATE', + }); + + expect(url).toContain('client_id=cli_custom_app'); + expect(url).not.toContain('cli_a1ad86f33c38500d'); + }); +}); diff --git a/src/utils/feishuAuth.ts b/src/utils/feishuAuth.ts new file mode 100644 index 0000000..f7b62cf --- /dev/null +++ b/src/utils/feishuAuth.ts @@ -0,0 +1,23 @@ +export interface FeishuAuthorizeUrlInput { + appId: string; + redirectUri: string; + scope: string; + state?: string; +} + +export function buildFeishuAuthorizeUrl({ + appId, + redirectUri, + scope, + state = 'STATE', +}: FeishuAuthorizeUrlInput): string { + const params = new URLSearchParams({ + client_id: appId, + redirect_uri: redirectUri, + response_type: 'code', + scope, + state, + }); + + return `https://passport.feishu.cn/suite/passport/oauth/authorize?${params.toString()}`; +} diff --git a/src/utils/feishuConfig.test.ts b/src/utils/feishuConfig.test.ts new file mode 100644 index 0000000..e0fe691 --- /dev/null +++ b/src/utils/feishuConfig.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { isFeishuConfigValid, parseFeishuConfig } from './feishuConfig'; + +describe('parseFeishuConfig', () => { + it('returns normalized config when all required fields are present', () => { + const config = parseFeishuConfig( + JSON.stringify({ + appId: 'cli_custom_app', + appSecret: 'secret-value', + endpoint: 'https://open.feishu.cn/open-apis', + }) + ); + + expect(config).toEqual({ + appId: 'cli_custom_app', + appSecret: 'secret-value', + endpoint: 'https://open.feishu.cn/open-apis', + }); + }); + + it('returns null when required fields are missing', () => { + expect(parseFeishuConfig(JSON.stringify({ appId: 'cli_only' }))).toBeNull(); + }); + + it('returns null for malformed json', () => { + expect(parseFeishuConfig('{bad json')).toBeNull(); + }); +}); + +describe('isFeishuConfigValid', () => { + it('returns true only for a complete config object', () => { + expect( + isFeishuConfigValid({ + appId: 'cli_custom_app', + appSecret: 'secret-value', + endpoint: 'https://open.feishu.cn/open-apis', + }) + ).toBe(true); + }); +}); diff --git a/src/utils/feishuConfig.ts b/src/utils/feishuConfig.ts new file mode 100644 index 0000000..08eafbf --- /dev/null +++ b/src/utils/feishuConfig.ts @@ -0,0 +1,36 @@ +export interface FeishuConfig { + appId: string; + appSecret: string; + endpoint: string; +} + +export function parseFeishuConfig(configStr: string | null): FeishuConfig | null { + if (!configStr) { + return null; + } + + try { + const parsed = JSON.parse(configStr) as Partial; + if (!parsed.appId || !parsed.appSecret || !parsed.endpoint) { + return null; + } + + return { + appId: parsed.appId, + appSecret: parsed.appSecret, + endpoint: parsed.endpoint, + }; + } catch { + return null; + } +} + +export function loadFeishuConfig(): FeishuConfig | null { + return parseFeishuConfig(localStorage.getItem('feishu_config')); +} + +export function isFeishuConfigValid( + config: Partial | null | undefined +): boolean { + return !!(config?.appId && config.appSecret && config.endpoint); +}