forked from boos/BoosAPI
feat(accounts): extend rebind-session API to handle refresh token and expiration fields
This commit is contained in:
@@ -159,4 +159,135 @@ describe('accounts rebind-session api', () => {
|
|||||||
expect(latest?.username).toBe('linuxdo_1002');
|
expect(latest?.username).toBe('linuxdo_1002');
|
||||||
expect(latest?.status).toBe('active');
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
'/api/accounts/:id/rebind-session',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const accountId = Number.parseInt(request.params.id, 10);
|
const accountId = Number.parseInt(request.params.id, 10);
|
||||||
@@ -672,6 +672,18 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
if (resolvedPlatformUserId) {
|
if (resolvedPlatformUserId) {
|
||||||
extraConfigPatch.platformUserId = 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);
|
updates.extraConfig = mergeAccountExtraConfig(account.extraConfig, extraConfigPatch);
|
||||||
|
|
||||||
db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, accountId)).run();
|
db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, accountId)).run();
|
||||||
|
|||||||
+1
-1
@@ -110,7 +110,7 @@ export const api = {
|
|||||||
addAccount: (data: any) => request('/api/accounts', { method: 'POST', body: JSON.stringify(data) }),
|
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) }),
|
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) }),
|
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) }),
|
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) }),
|
updateAccount: (id: number, data: any) => request(`/api/accounts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
deleteAccount: (id: number) => request(`/api/accounts/${id}`, { method: 'DELETE' }),
|
deleteAccount: (id: number) => request(`/api/accounts/${id}`, { method: 'DELETE' }),
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function Accounts() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
|
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
|
||||||
const [rebindTarget, setRebindTarget] = useState<any | null>(null);
|
const [rebindTarget, setRebindTarget] = useState<any | null>(null);
|
||||||
const [rebindForm, setRebindForm] = useState({ accessToken: '', platformUserId: '' });
|
const [rebindForm, setRebindForm] = useState({ accessToken: '', platformUserId: '', refreshToken: '', tokenExpiresAt: '' });
|
||||||
const [rebindVerifyResult, setRebindVerifyResult] = useState<any>(null);
|
const [rebindVerifyResult, setRebindVerifyResult] = useState<any>(null);
|
||||||
const [rebindVerifying, setRebindVerifying] = useState(false);
|
const [rebindVerifying, setRebindVerifying] = useState(false);
|
||||||
const [rebindSaving, setRebindSaving] = useState(false);
|
const [rebindSaving, setRebindSaving] = useState(false);
|
||||||
@@ -51,6 +51,7 @@ export default function Accounts() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
if (rebindTarget) lastRebindTargetRef.current = rebindTarget;
|
if (rebindTarget) lastRebindTargetRef.current = rebindTarget;
|
||||||
const activeRebindTarget = rebindTarget || lastRebindTargetRef.current;
|
const activeRebindTarget = rebindTarget || lastRebindTargetRef.current;
|
||||||
|
const isRebindSub2Api = ((activeRebindTarget?.site?.platform || '').toLowerCase() === 'sub2api');
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [accountsResult, sitesResult] = await Promise.allSettled([
|
const [accountsResult, sitesResult] = await Promise.allSettled([
|
||||||
@@ -330,6 +331,8 @@ export default function Accounts() {
|
|||||||
setRebindForm({
|
setRebindForm({
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
platformUserId: extractPlatformUserId(account),
|
platformUserId: extractPlatformUserId(account),
|
||||||
|
refreshToken: '',
|
||||||
|
tokenExpiresAt: '',
|
||||||
});
|
});
|
||||||
setRebindVerifyResult(null);
|
setRebindVerifyResult(null);
|
||||||
setRebindFocusTrigger((value) => value + 1);
|
setRebindFocusTrigger((value) => value + 1);
|
||||||
@@ -337,7 +340,7 @@ export default function Accounts() {
|
|||||||
|
|
||||||
const closeRebindPanel = () => {
|
const closeRebindPanel = () => {
|
||||||
setRebindTarget(null);
|
setRebindTarget(null);
|
||||||
setRebindForm({ accessToken: '', platformUserId: '' });
|
setRebindForm({ accessToken: '', platformUserId: '', refreshToken: '', tokenExpiresAt: '' });
|
||||||
setRebindVerifyResult(null);
|
setRebindVerifyResult(null);
|
||||||
setRebindVerifying(false);
|
setRebindVerifying(false);
|
||||||
setRebindSaving(false);
|
setRebindSaving(false);
|
||||||
@@ -391,11 +394,18 @@ export default function Accounts() {
|
|||||||
toast.error('请先验证新的 Session Token 成功');
|
toast.error('请先验证新的 Session Token 成功');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const isSub2ApiRebindTarget = ((rebindTarget?.site?.platform || '').toLowerCase() === 'sub2api');
|
||||||
setRebindSaving(true);
|
setRebindSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.rebindAccountSession(rebindTarget.id, {
|
await api.rebindAccountSession(rebindTarget.id, {
|
||||||
accessToken: rebindForm.accessToken.trim(),
|
accessToken: rebindForm.accessToken.trim(),
|
||||||
platformUserId: rebindForm.platformUserId ? Number.parseInt(rebindForm.platformUserId, 10) : undefined,
|
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('账号重新绑定成功,状态已恢复');
|
toast.success('账号重新绑定成功,状态已恢复');
|
||||||
closeRebindPanel();
|
closeRebindPanel();
|
||||||
@@ -724,6 +734,27 @@ export default function Accounts() {
|
|||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{isRebindSub2Api && (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 220px', gap: 10, marginBottom: 4 }}>
|
||||||
|
<input
|
||||||
|
placeholder="Sub2API refresh_token(可选)"
|
||||||
|
value={rebindForm.refreshToken}
|
||||||
|
onChange={(e) => setRebindForm((prev) => ({ ...prev, refreshToken: e.target.value.trim() }))}
|
||||||
|
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="token_expires_at(可选)"
|
||||||
|
value={rebindForm.tokenExpiresAt}
|
||||||
|
onChange={(e) => setRebindForm((prev) => ({ ...prev, tokenExpiresAt: e.target.value.replace(/\D/g, '') }))}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||||
|
留空将保持原有 refresh_token 不变。配置后可用于托管自动续期。
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{rebindVerifyResult && rebindVerifyResult.success && rebindVerifyResult.tokenType === 'session' && (
|
{rebindVerifyResult && rebindVerifyResult.success && rebindVerifyResult.tokenType === 'session' && (
|
||||||
<div className="alert alert-success animate-scale-in" style={{ marginBottom: 10 }}>
|
<div className="alert alert-success animate-scale-in" style={{ marginBottom: 10 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user