diff --git a/src/server/routes/api/accounts.rebind-session.test.ts b/src/server/routes/api/accounts.rebind-session.test.ts index 8312555..4866357 100644 --- a/src/server/routes/api/accounts.rebind-session.test.ts +++ b/src/server/routes/api/accounts.rebind-session.test.ts @@ -159,4 +159,135 @@ describe('accounts rebind-session api', () => { expect(latest?.username).toBe('linuxdo_1002'); expect(latest?.status).toBe('active'); }); + + it('stores managed sub2api refresh token fields when provided during rebind', async () => { + verifyTokenMock.mockResolvedValueOnce({ + tokenType: 'session', + userInfo: { username: 'sub2_user' }, + }); + + const site = db.insert(schema.sites).values({ + name: 'Sub2 Rebind Site', + url: 'https://sub2.example.com', + platform: 'sub2api', + }).returning().get(); + + const account = db.insert(schema.accounts).values({ + siteId: site.id, + username: 'sub2_user', + accessToken: 'old-access-token', + status: 'expired', + extraConfig: JSON.stringify({ + sub2apiAuth: { + refreshToken: 'old-refresh-token', + tokenExpiresAt: 1750000000000, + }, + }), + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: `/api/accounts/${account.id}/rebind-session`, + payload: { + accessToken: 'new-session-token', + refreshToken: 'new-refresh-token', + tokenExpiresAt: 1760000000000, + }, + }); + + expect(response.statusCode).toBe(200); + + const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get(); + const parsedExtra = JSON.parse(String(latest?.extraConfig || '{}')) as { + sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number }; + }; + expect(parsedExtra.sub2apiAuth?.refreshToken).toBe('new-refresh-token'); + expect(parsedExtra.sub2apiAuth?.tokenExpiresAt).toBe(1760000000000); + }); + + it('keeps existing sub2api refresh token when rebind payload refreshToken is empty', async () => { + verifyTokenMock.mockResolvedValueOnce({ + tokenType: 'session', + userInfo: { username: 'sub2_user' }, + }); + + const site = db.insert(schema.sites).values({ + name: 'Sub2 Rebind Site', + url: 'https://sub2.example.com', + platform: 'sub2api', + }).returning().get(); + + const account = db.insert(schema.accounts).values({ + siteId: site.id, + username: 'sub2_user', + accessToken: 'old-access-token', + status: 'expired', + extraConfig: JSON.stringify({ + sub2apiAuth: { + refreshToken: 'kept-refresh-token', + tokenExpiresAt: 1750000000000, + }, + }), + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: `/api/accounts/${account.id}/rebind-session`, + payload: { + accessToken: 'new-session-token', + refreshToken: '', + tokenExpiresAt: 1760000000000, + }, + }); + + expect(response.statusCode).toBe(200); + + const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get(); + const parsedExtra = JSON.parse(String(latest?.extraConfig || '{}')) as { + sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number }; + }; + expect(parsedExtra.sub2apiAuth?.refreshToken).toBe('kept-refresh-token'); + expect(parsedExtra.sub2apiAuth?.tokenExpiresAt).toBe(1760000000000); + }); + + it('ignores managed sub2api refresh token fields for non-sub2api rebind', async () => { + verifyTokenMock.mockResolvedValueOnce({ + tokenType: 'session', + userInfo: { username: 'linuxdo_1003' }, + }); + + const site = db.insert(schema.sites).values({ + name: 'Rebind Site', + url: 'https://rebind.example.com', + platform: 'new-api', + }).returning().get(); + + const account = db.insert(schema.accounts).values({ + siteId: site.id, + username: 'linuxdo_1001', + accessToken: 'old-access-token', + status: 'expired', + extraConfig: JSON.stringify({ platformUserId: 1001 }), + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: `/api/accounts/${account.id}/rebind-session`, + payload: { + accessToken: 'new-session-token', + refreshToken: 'should-be-ignored', + tokenExpiresAt: 1760000000000, + }, + }); + + expect(response.statusCode).toBe(200); + + const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get(); + const parsedExtra = JSON.parse(String(latest?.extraConfig || '{}')) as { + sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number }; + platformUserId?: number; + }; + expect(parsedExtra.platformUserId).toBe(1001); + expect(parsedExtra.sub2apiAuth).toBeUndefined(); + }); }); diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index 9bc2927..c5f5655 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -594,7 +594,7 @@ export async function accountsRoutes(app: FastifyInstance) { }; }); - app.post<{ Params: { id: string }; Body: { accessToken: string; platformUserId?: number } }>( + app.post<{ Params: { id: string }; Body: { accessToken: string; platformUserId?: number; refreshToken?: string; tokenExpiresAt?: number | string } }>( '/api/accounts/:id/rebind-session', async (request, reply) => { const accountId = Number.parseInt(request.params.id, 10); @@ -672,6 +672,18 @@ export async function accountsRoutes(app: FastifyInstance) { if (resolvedPlatformUserId) { extraConfigPatch.platformUserId = resolvedPlatformUserId; } + if ((site.platform || '').toLowerCase() === 'sub2api') { + const existingManagedAuth = getSub2ApiAuthFromExtraConfig(account.extraConfig); + const requestedRefreshToken = normalizeManagedRefreshToken(request.body?.refreshToken); + const requestedTokenExpiresAt = normalizeManagedTokenExpiresAt(request.body?.tokenExpiresAt); + const nextRefreshToken = requestedRefreshToken || existingManagedAuth?.refreshToken; + const nextTokenExpiresAt = requestedTokenExpiresAt ?? existingManagedAuth?.tokenExpiresAt; + if (nextRefreshToken) { + extraConfigPatch.sub2apiAuth = nextTokenExpiresAt + ? { refreshToken: nextRefreshToken, tokenExpiresAt: nextTokenExpiresAt } + : { refreshToken: nextRefreshToken }; + } + } updates.extraConfig = mergeAccountExtraConfig(account.extraConfig, extraConfigPatch); db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, accountId)).run(); diff --git a/src/web/api.ts b/src/web/api.ts index 7a5350e..bcc4b20 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -110,7 +110,7 @@ export const api = { addAccount: (data: any) => request('/api/accounts', { method: 'POST', body: JSON.stringify(data) }), loginAccount: (data: { siteId: number; username: string; password: string }) => request('/api/accounts/login', { method: 'POST', body: JSON.stringify(data) }), verifyToken: (data: { siteId: number; accessToken: string; platformUserId?: number; credentialMode?: 'auto' | 'session' | 'apikey' }) => request('/api/accounts/verify-token', { method: 'POST', body: JSON.stringify(data) }), - rebindAccountSession: (id: number, data: { accessToken: string; platformUserId?: number }) => + rebindAccountSession: (id: number, data: { accessToken: string; platformUserId?: number; refreshToken?: string; tokenExpiresAt?: number }) => request(`/api/accounts/${id}/rebind-session`, { method: 'POST', body: JSON.stringify(data) }), updateAccount: (id: number, data: any) => request(`/api/accounts/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deleteAccount: (id: number) => request(`/api/accounts/${id}`, { method: 'DELETE' }), diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 06de665..4e5ff8d 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -35,7 +35,7 @@ export default function Accounts() { const [saving, setSaving] = useState(false); const [actionLoading, setActionLoading] = useState>({}); const [rebindTarget, setRebindTarget] = useState(null); - const [rebindForm, setRebindForm] = useState({ accessToken: '', platformUserId: '' }); + const [rebindForm, setRebindForm] = useState({ accessToken: '', platformUserId: '', refreshToken: '', tokenExpiresAt: '' }); const [rebindVerifyResult, setRebindVerifyResult] = useState(null); const [rebindVerifying, setRebindVerifying] = useState(false); const [rebindSaving, setRebindSaving] = useState(false); @@ -51,6 +51,7 @@ export default function Accounts() { const toast = useToast(); if (rebindTarget) lastRebindTargetRef.current = rebindTarget; const activeRebindTarget = rebindTarget || lastRebindTargetRef.current; + const isRebindSub2Api = ((activeRebindTarget?.site?.platform || '').toLowerCase() === 'sub2api'); const load = async () => { const [accountsResult, sitesResult] = await Promise.allSettled([ @@ -330,6 +331,8 @@ export default function Accounts() { setRebindForm({ accessToken: '', platformUserId: extractPlatformUserId(account), + refreshToken: '', + tokenExpiresAt: '', }); setRebindVerifyResult(null); setRebindFocusTrigger((value) => value + 1); @@ -337,7 +340,7 @@ export default function Accounts() { const closeRebindPanel = () => { setRebindTarget(null); - setRebindForm({ accessToken: '', platformUserId: '' }); + setRebindForm({ accessToken: '', platformUserId: '', refreshToken: '', tokenExpiresAt: '' }); setRebindVerifyResult(null); setRebindVerifying(false); setRebindSaving(false); @@ -391,11 +394,18 @@ export default function Accounts() { toast.error('请先验证新的 Session Token 成功'); return; } + const isSub2ApiRebindTarget = ((rebindTarget?.site?.platform || '').toLowerCase() === 'sub2api'); setRebindSaving(true); try { await api.rebindAccountSession(rebindTarget.id, { accessToken: rebindForm.accessToken.trim(), platformUserId: rebindForm.platformUserId ? Number.parseInt(rebindForm.platformUserId, 10) : undefined, + refreshToken: isSub2ApiRebindTarget && rebindForm.refreshToken.trim() + ? rebindForm.refreshToken.trim() + : undefined, + tokenExpiresAt: isSub2ApiRebindTarget && rebindForm.tokenExpiresAt.trim() + ? Number.parseInt(rebindForm.tokenExpiresAt, 10) + : undefined, }); toast.success('账号重新绑定成功,状态已恢复'); closeRebindPanel(); @@ -724,6 +734,27 @@ export default function Accounts() { style={inputStyle} /> + {isRebindSub2Api && ( + <> +
+ setRebindForm((prev) => ({ ...prev, refreshToken: e.target.value.trim() }))} + style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} + /> + setRebindForm((prev) => ({ ...prev, tokenExpiresAt: e.target.value.replace(/\D/g, '') }))} + style={inputStyle} + /> +
+
+ 留空将保持原有 refresh_token 不变。配置后可用于托管自动续期。 +
+ + )} {rebindVerifyResult && rebindVerifyResult.success && rebindVerifyResult.tokenType === 'session' && (