Spaces:
Paused
feat: account management page with batch operations (#146)
Browse files* fix: treat unfetched plan types as possibly compatible in acquire()
When model-fetcher fails to fetch models for a plan type (e.g. the
selected free account had an expired token), _planModelMap only contains
successfully fetched plans. acquire() then excludes all accounts whose
planType is absent from the map — even though their compatibility is
unknown, not negative.
Add isPlanFetched() to model-store and use it in acquire() to
distinguish "plan was fetched and model is absent" (exclude) from
"plan was never fetched" (include as possibly compatible). This prevents
false 503s when a plan's model fetch failed at startup.
* feat: account management page with batch operations
Add #/account-management page for bulk account management:
- POST /auth/accounts/batch-delete and batch-status endpoints
- Batch delete, set active, set disabled with confirmation
- Status summary chips (clickable to filter)
- Reuse AccountTable with optional proxy column
- Import/export accessible from new page
- Fix markStatus() acquire lock leak (was missing acquireLocks.delete)
- Fix misleading test name in plan-routing-acquire
* fix: harden batch operations with error handling and loading state
- Hook batchDelete/batchSetStatus: return error string on failure (consistent with deleteAccount pattern)
- AccountManagement: try-catch + error/success toast, busy state prevents double-submit
- AccountBulkActions: accept loading prop, disable buttons during async
- Remove unnecessary AccountManagementPage wrapper
- Fix hardcoded "Dashboard" string → i18n t("backToDashboard")
- Remove redundant proxies! non-null assertion
---------
Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>
- CHANGELOG.md +3 -0
- shared/hooks/use-accounts.ts +38 -0
- shared/i18n/translations.ts +18 -0
- src/auth/__tests__/account-pool-quota.test.ts +1 -0
- src/auth/__tests__/account-pool-sticky.test.ts +1 -0
- src/auth/__tests__/account-pool-team-dedup.test.ts +1 -0
- src/auth/__tests__/plan-routing-acquire.test.ts +63 -1
- src/auth/account-pool.ts +13 -4
- src/models/model-store.ts +8 -0
- src/routes/__tests__/accounts-batch.test.ts +220 -0
- src/routes/__tests__/accounts-delete-warnings.test.ts +1 -0
- src/routes/__tests__/accounts-import-export.test.ts +1 -0
- src/routes/accounts.ts +75 -0
- web/src/App.tsx +3 -1
- web/src/components/AccountBulkActions.tsx +91 -0
- web/src/components/AccountList.tsx +6 -0
- web/src/components/AccountTable.tsx +25 -22
- web/src/pages/AccountManagement.tsx +186 -0
|
@@ -15,6 +15,9 @@
|
|
| 15 |
- 后台额度刷新周期性重试 banned 账号,成功即自动解封
|
| 16 |
- 上游 401 token 吊销("token has been invalidated")自动标记过期并切换下一个账号
|
| 17 |
- 之前 401 直接透传给客户端,不标记也不重试
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
### Fixed
|
| 20 |
|
|
|
|
| 15 |
- 后台额度刷新周期性重试 banned 账号,成功即自动解封
|
| 16 |
- 上游 401 token 吊销("token has been invalidated")自动标记过期并切换下一个账号
|
| 17 |
- 之前 401 直接透传给客户端,不标记也不重试
|
| 18 |
+
- Account Management 页面(`#/account-management`):批量删除、批量改状态(active/disabled)、导入导出
|
| 19 |
+
- `POST /auth/accounts/batch-delete` 和 `POST /auth/accounts/batch-status` 批量端点
|
| 20 |
+
- 状态摘要条可点击筛选,复用 AccountTable 选择/分页/Shift 多选
|
| 21 |
|
| 22 |
### Fixed
|
| 23 |
|
|
@@ -217,6 +217,42 @@ export function useAccounts() {
|
|
| 217 |
return result;
|
| 218 |
}, [loadAccounts]);
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
return {
|
| 221 |
list,
|
| 222 |
loading,
|
|
@@ -232,5 +268,7 @@ export function useAccounts() {
|
|
| 232 |
deleteAccount,
|
| 233 |
exportAccounts,
|
| 234 |
importAccounts,
|
|
|
|
|
|
|
| 235 |
};
|
| 236 |
}
|
|
|
|
| 217 |
return result;
|
| 218 |
}, [loadAccounts]);
|
| 219 |
|
| 220 |
+
const batchDelete = useCallback(async (ids: string[]): Promise<string | null> => {
|
| 221 |
+
try {
|
| 222 |
+
const resp = await fetch("/auth/accounts/batch-delete", {
|
| 223 |
+
method: "POST",
|
| 224 |
+
headers: { "Content-Type": "application/json" },
|
| 225 |
+
body: JSON.stringify({ ids }),
|
| 226 |
+
});
|
| 227 |
+
if (!resp.ok) {
|
| 228 |
+
const data = await resp.json();
|
| 229 |
+
return data.error || "Batch delete failed";
|
| 230 |
+
}
|
| 231 |
+
await loadAccounts();
|
| 232 |
+
return null;
|
| 233 |
+
} catch (err) {
|
| 234 |
+
return "networkError" + (err instanceof Error ? err.message : "");
|
| 235 |
+
}
|
| 236 |
+
}, [loadAccounts]);
|
| 237 |
+
|
| 238 |
+
const batchSetStatus = useCallback(async (ids: string[], status: "active" | "disabled"): Promise<string | null> => {
|
| 239 |
+
try {
|
| 240 |
+
const resp = await fetch("/auth/accounts/batch-status", {
|
| 241 |
+
method: "POST",
|
| 242 |
+
headers: { "Content-Type": "application/json" },
|
| 243 |
+
body: JSON.stringify({ ids, status }),
|
| 244 |
+
});
|
| 245 |
+
if (!resp.ok) {
|
| 246 |
+
const data = await resp.json();
|
| 247 |
+
return data.error || "Batch status change failed";
|
| 248 |
+
}
|
| 249 |
+
await loadAccounts();
|
| 250 |
+
return null;
|
| 251 |
+
} catch (err) {
|
| 252 |
+
return "networkError" + (err instanceof Error ? err.message : "");
|
| 253 |
+
}
|
| 254 |
+
}, [loadAccounts]);
|
| 255 |
+
|
| 256 |
return {
|
| 257 |
list,
|
| 258 |
loading,
|
|
|
|
| 268 |
deleteAccount,
|
| 269 |
exportAccounts,
|
| 270 |
importAccounts,
|
| 271 |
+
batchDelete,
|
| 272 |
+
batchSetStatus,
|
| 273 |
};
|
| 274 |
}
|
|
@@ -201,6 +201,15 @@ export const translations = {
|
|
| 201 |
rotationLeastUsedDesc: "Prefer the account with fewest requests.",
|
| 202 |
rotationRoundRobinDesc: "Cycle through accounts in order.",
|
| 203 |
rotationSaved: "Saved",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
},
|
| 205 |
zh: {
|
| 206 |
serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
|
|
@@ -407,6 +416,15 @@ export const translations = {
|
|
| 407 |
rotationLeastUsedDesc: "\u4f18\u5148\u4f7f\u7528\u8bf7\u6c42\u6b21\u6570\u6700\u5c11\u7684\u8d26\u53f7\u3002",
|
| 408 |
rotationRoundRobinDesc: "\u6309\u987a\u5e8f\u8f6e\u6d41\u4f7f\u7528\u5404\u8d26\u53f7\u3002",
|
| 409 |
rotationSaved: "\u5df2\u4fdd\u5b58",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
},
|
| 411 |
} as const;
|
| 412 |
|
|
|
|
| 201 |
rotationLeastUsedDesc: "Prefer the account with fewest requests.",
|
| 202 |
rotationRoundRobinDesc: "Cycle through accounts in order.",
|
| 203 |
rotationSaved: "Saved",
|
| 204 |
+
accountManagement: "Account Management",
|
| 205 |
+
manageAccounts: "Manage Accounts",
|
| 206 |
+
batchDelete: "Delete Selected",
|
| 207 |
+
batchDeleteConfirm: "Confirm Delete?",
|
| 208 |
+
setActive: "Set Active",
|
| 209 |
+
setDisabled: "Set Disabled",
|
| 210 |
+
deleteSuccess: "Deleted",
|
| 211 |
+
statusChangeSuccess: "Updated",
|
| 212 |
+
cancel: "Cancel",
|
| 213 |
},
|
| 214 |
zh: {
|
| 215 |
serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
|
|
|
|
| 416 |
rotationLeastUsedDesc: "\u4f18\u5148\u4f7f\u7528\u8bf7\u6c42\u6b21\u6570\u6700\u5c11\u7684\u8d26\u53f7\u3002",
|
| 417 |
rotationRoundRobinDesc: "\u6309\u987a\u5e8f\u8f6e\u6d41\u4f7f\u7528\u5404\u8d26\u53f7\u3002",
|
| 418 |
rotationSaved: "\u5df2\u4fdd\u5b58",
|
| 419 |
+
accountManagement: "账号管理",
|
| 420 |
+
manageAccounts: "管理账号",
|
| 421 |
+
batchDelete: "批量删除",
|
| 422 |
+
batchDeleteConfirm: "确认删除?",
|
| 423 |
+
setActive: "设为活跃",
|
| 424 |
+
setDisabled: "设为禁用",
|
| 425 |
+
deleteSuccess: "已删除",
|
| 426 |
+
statusChangeSuccess: "已更新",
|
| 427 |
+
cancel: "取消",
|
| 428 |
},
|
| 429 |
} as const;
|
| 430 |
|
|
@@ -55,6 +55,7 @@ vi.mock("../../utils/jitter.js", () => ({
|
|
| 55 |
|
| 56 |
vi.mock("../../models/model-store.js", () => ({
|
| 57 |
getModelPlanTypes: vi.fn(() => []),
|
|
|
|
| 58 |
}));
|
| 59 |
|
| 60 |
import { AccountPool } from "../account-pool.js";
|
|
|
|
| 55 |
|
| 56 |
vi.mock("../../models/model-store.js", () => ({
|
| 57 |
getModelPlanTypes: vi.fn(() => []),
|
| 58 |
+
isPlanFetched: vi.fn(() => true),
|
| 59 |
}));
|
| 60 |
|
| 61 |
import { AccountPool } from "../account-pool.js";
|
|
@@ -13,6 +13,7 @@ const mockGetModelPlanTypes = vi.fn<(id: string) => string[]>(() => []);
|
|
| 13 |
|
| 14 |
vi.mock("../../models/model-store.js", () => ({
|
| 15 |
getModelPlanTypes: (...args: unknown[]) => mockGetModelPlanTypes(args[0] as string),
|
|
|
|
| 16 |
}));
|
| 17 |
|
| 18 |
vi.mock("../../config.js", () => ({
|
|
|
|
| 13 |
|
| 14 |
vi.mock("../../models/model-store.js", () => ({
|
| 15 |
getModelPlanTypes: (...args: unknown[]) => mockGetModelPlanTypes(args[0] as string),
|
| 16 |
+
isPlanFetched: () => true,
|
| 17 |
}));
|
| 18 |
|
| 19 |
vi.mock("../../config.js", () => ({
|
|
@@ -10,6 +10,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
| 10 |
|
| 11 |
vi.mock("../../models/model-store.js", () => ({
|
| 12 |
getModelPlanTypes: vi.fn(() => []),
|
|
|
|
| 13 |
}));
|
| 14 |
|
| 15 |
vi.mock("../../config.js", () => ({
|
|
|
|
| 10 |
|
| 11 |
vi.mock("../../models/model-store.js", () => ({
|
| 12 |
getModelPlanTypes: vi.fn(() => []),
|
| 13 |
+
isPlanFetched: vi.fn(() => true),
|
| 14 |
}));
|
| 15 |
|
| 16 |
vi.mock("../../config.js", () => ({
|
|
@@ -8,11 +8,13 @@
|
|
| 8 |
|
| 9 |
import { describe, it, expect, vi, beforeEach } from "vitest";
|
| 10 |
|
| 11 |
-
// Mock getModelPlanTypes to control plan routing
|
| 12 |
const mockGetModelPlanTypes = vi.fn<(id: string) => string[]>(() => []);
|
|
|
|
| 13 |
|
| 14 |
vi.mock("../../models/model-store.js", () => ({
|
| 15 |
getModelPlanTypes: (...args: unknown[]) => mockGetModelPlanTypes(args[0] as string),
|
|
|
|
| 16 |
}));
|
| 17 |
|
| 18 |
vi.mock("../../config.js", () => ({
|
|
@@ -139,4 +141,64 @@ describe("account-pool plan-based routing", () => {
|
|
| 139 |
// Both are valid candidates, should get one of them
|
| 140 |
expect(["tok-free", "tok-team"]).toContain(acquired!.token);
|
| 141 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
});
|
|
|
|
| 8 |
|
| 9 |
import { describe, it, expect, vi, beforeEach } from "vitest";
|
| 10 |
|
| 11 |
+
// Mock getModelPlanTypes and isPlanFetched to control plan routing
|
| 12 |
const mockGetModelPlanTypes = vi.fn<(id: string) => string[]>(() => []);
|
| 13 |
+
const mockIsPlanFetched = vi.fn<(planType: string) => boolean>(() => true);
|
| 14 |
|
| 15 |
vi.mock("../../models/model-store.js", () => ({
|
| 16 |
getModelPlanTypes: (...args: unknown[]) => mockGetModelPlanTypes(args[0] as string),
|
| 17 |
+
isPlanFetched: (...args: unknown[]) => mockIsPlanFetched(args[0] as string),
|
| 18 |
}));
|
| 19 |
|
| 20 |
vi.mock("../../config.js", () => ({
|
|
|
|
| 141 |
// Both are valid candidates, should get one of them
|
| 142 |
expect(["tok-free", "tok-team"]).toContain(acquired!.token);
|
| 143 |
});
|
| 144 |
+
|
| 145 |
+
it("includes accounts whose plan was never fetched (unfetched = possibly compatible)", () => {
|
| 146 |
+
// Model known to work on team, but free plan was never fetched
|
| 147 |
+
mockGetModelPlanTypes.mockReturnValue(["team"]);
|
| 148 |
+
mockIsPlanFetched.mockImplementation((plan: string) => plan === "team");
|
| 149 |
+
|
| 150 |
+
const pool = createPool(
|
| 151 |
+
{ token: "tok-free", planType: "free", email: "free@test.com" },
|
| 152 |
+
{ token: "tok-team", planType: "team", email: "team@test.com" },
|
| 153 |
+
);
|
| 154 |
+
|
| 155 |
+
// Both accounts are candidates (team is known, free is unfetched → possibly compatible)
|
| 156 |
+
const first = pool.acquire({ model: "gpt-5.4" });
|
| 157 |
+
expect(first).not.toBeNull();
|
| 158 |
+
|
| 159 |
+
// Second concurrent request should also succeed (two candidates available)
|
| 160 |
+
const second = pool.acquire({ model: "gpt-5.4" });
|
| 161 |
+
expect(second).not.toBeNull();
|
| 162 |
+
expect(second!.token).not.toBe(first!.token);
|
| 163 |
+
|
| 164 |
+
// Both tokens should be from our pool
|
| 165 |
+
const tokens = new Set([first!.token, second!.token]);
|
| 166 |
+
expect(tokens.has("tok-free")).toBe(true);
|
| 167 |
+
expect(tokens.has("tok-team")).toBe(true);
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
it("excludes accounts whose plan was fetched but lacks the model", () => {
|
| 171 |
+
// Model known to work on team; free plan was fetched and model is NOT in it
|
| 172 |
+
mockGetModelPlanTypes.mockReturnValue(["team"]);
|
| 173 |
+
mockIsPlanFetched.mockReturnValue(true); // both plans fetched
|
| 174 |
+
|
| 175 |
+
const pool = createPool(
|
| 176 |
+
{ token: "tok-free", planType: "free", email: "free@test.com" },
|
| 177 |
+
{ token: "tok-team", planType: "team", email: "team@test.com" },
|
| 178 |
+
);
|
| 179 |
+
|
| 180 |
+
// Lock the team account
|
| 181 |
+
const first = pool.acquire({ model: "gpt-5.4" });
|
| 182 |
+
expect(first).not.toBeNull();
|
| 183 |
+
expect(first!.token).toBe("tok-team");
|
| 184 |
+
|
| 185 |
+
// Second request — free plan was fetched and model is absent → null
|
| 186 |
+
const second = pool.acquire({ model: "gpt-5.4" });
|
| 187 |
+
expect(second).toBeNull();
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
it("unfetched plans are included even when model appears plan-locked", () => {
|
| 191 |
+
// Model supports team only, no plans have been fetched, but only free accounts exist
|
| 192 |
+
mockGetModelPlanTypes.mockReturnValue(["team"]);
|
| 193 |
+
mockIsPlanFetched.mockReturnValue(false); // no plans fetched yet
|
| 194 |
+
|
| 195 |
+
const pool = createPool(
|
| 196 |
+
{ token: "tok-free", planType: "free", email: "free@test.com" },
|
| 197 |
+
);
|
| 198 |
+
|
| 199 |
+
// Free plan is unfetched → included as possibly compatible
|
| 200 |
+
const acquired = pool.acquire({ model: "gpt-5.4" });
|
| 201 |
+
expect(acquired).not.toBeNull();
|
| 202 |
+
expect(acquired!.token).toBe("tok-free");
|
| 203 |
+
});
|
| 204 |
});
|
|
@@ -12,7 +12,7 @@ import {
|
|
| 12 |
extractUserProfile,
|
| 13 |
isTokenExpired,
|
| 14 |
} from "./jwt-utils.js";
|
| 15 |
-
import { getModelPlanTypes } from "../models/model-store.js";
|
| 16 |
import { getRotationStrategy } from "./rotation-strategy.js";
|
| 17 |
import { createFsPersistence } from "./account-persistence.js";
|
| 18 |
import type { AccountPersistence } from "./account-persistence.js";
|
|
@@ -93,17 +93,25 @@ export class AccountPool {
|
|
| 93 |
|
| 94 |
if (available.length === 0) return null;
|
| 95 |
|
| 96 |
-
// Model-aware selection: prefer accounts whose planType matches the model's known plans
|
|
|
|
|
|
|
| 97 |
let candidates = available;
|
| 98 |
if (options?.model) {
|
| 99 |
const preferredPlans = getModelPlanTypes(options.model);
|
| 100 |
if (preferredPlans.length > 0) {
|
| 101 |
const planSet = new Set(preferredPlans);
|
| 102 |
-
const matched = available.filter((a) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
if (matched.length > 0) {
|
| 104 |
candidates = matched;
|
| 105 |
} else {
|
| 106 |
-
//
|
| 107 |
return null;
|
| 108 |
}
|
| 109 |
}
|
|
@@ -373,6 +381,7 @@ export class AccountPool {
|
|
| 373 |
}
|
| 374 |
|
| 375 |
markStatus(entryId: string, status: AccountEntry["status"]): void {
|
|
|
|
| 376 |
const entry = this.accounts.get(entryId);
|
| 377 |
if (!entry) return;
|
| 378 |
entry.status = status;
|
|
|
|
| 12 |
extractUserProfile,
|
| 13 |
isTokenExpired,
|
| 14 |
} from "./jwt-utils.js";
|
| 15 |
+
import { getModelPlanTypes, isPlanFetched } from "../models/model-store.js";
|
| 16 |
import { getRotationStrategy } from "./rotation-strategy.js";
|
| 17 |
import { createFsPersistence } from "./account-persistence.js";
|
| 18 |
import type { AccountPersistence } from "./account-persistence.js";
|
|
|
|
| 93 |
|
| 94 |
if (available.length === 0) return null;
|
| 95 |
|
| 96 |
+
// Model-aware selection: prefer accounts whose planType matches the model's known plans.
|
| 97 |
+
// Accounts whose planType has never been fetched are treated as "possibly compatible"
|
| 98 |
+
// to avoid false 503s when a plan's model fetch failed at startup.
|
| 99 |
let candidates = available;
|
| 100 |
if (options?.model) {
|
| 101 |
const preferredPlans = getModelPlanTypes(options.model);
|
| 102 |
if (preferredPlans.length > 0) {
|
| 103 |
const planSet = new Set(preferredPlans);
|
| 104 |
+
const matched = available.filter((a) => {
|
| 105 |
+
if (!a.planType) return false;
|
| 106 |
+
// Plan is known to support this model
|
| 107 |
+
if (planSet.has(a.planType)) return true;
|
| 108 |
+
// Plan has never been fetched — include as possibly compatible
|
| 109 |
+
return !isPlanFetched(a.planType);
|
| 110 |
+
});
|
| 111 |
if (matched.length > 0) {
|
| 112 |
candidates = matched;
|
| 113 |
} else {
|
| 114 |
+
// All accounts belong to plans that were fetched but don't include this model
|
| 115 |
return null;
|
| 116 |
}
|
| 117 |
}
|
|
|
|
| 381 |
}
|
| 382 |
|
| 383 |
markStatus(entryId: string, status: AccountEntry["status"]): void {
|
| 384 |
+
this.acquireLocks.delete(entryId);
|
| 385 |
const entry = this.accounts.get(entryId);
|
| 386 |
if (!entry) return;
|
| 387 |
entry.status = status;
|
|
@@ -308,6 +308,14 @@ export function getModelPlanTypes(modelId: string): string[] {
|
|
| 308 |
return [...(_modelPlanIndex.get(modelId) ?? [])];
|
| 309 |
}
|
| 310 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
// ── Model name suffix parsing ───────────────────────────────────────
|
| 312 |
|
| 313 |
export interface ParsedModelName {
|
|
|
|
| 308 |
return [...(_modelPlanIndex.get(modelId) ?? [])];
|
| 309 |
}
|
| 310 |
|
| 311 |
+
/**
|
| 312 |
+
* Check if models have ever been successfully fetched for a given plan type.
|
| 313 |
+
* Returns false when the plan's model list is unknown (fetch failed or never attempted).
|
| 314 |
+
*/
|
| 315 |
+
export function isPlanFetched(planType: string): boolean {
|
| 316 |
+
return _planModelMap.has(planType);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
// ── Model name suffix parsing ───────────────────────────────────────
|
| 320 |
|
| 321 |
export interface ParsedModelName {
|
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Tests for batch account operations.
|
| 3 |
+
* POST /auth/accounts/batch-delete — delete multiple accounts
|
| 4 |
+
* POST /auth/accounts/batch-status — change status for multiple accounts
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
| 8 |
+
|
| 9 |
+
// Mock fs before importing anything
|
| 10 |
+
vi.mock("fs", () => ({
|
| 11 |
+
readFileSync: vi.fn(() => { throw new Error("ENOENT"); }),
|
| 12 |
+
writeFileSync: vi.fn(),
|
| 13 |
+
renameSync: vi.fn(),
|
| 14 |
+
existsSync: vi.fn(() => false),
|
| 15 |
+
mkdirSync: vi.fn(),
|
| 16 |
+
}));
|
| 17 |
+
|
| 18 |
+
vi.mock("../../paths.js", () => ({
|
| 19 |
+
getDataDir: vi.fn(() => "/tmp/test-data"),
|
| 20 |
+
getConfigDir: vi.fn(() => "/tmp/test-config"),
|
| 21 |
+
}));
|
| 22 |
+
|
| 23 |
+
vi.mock("../../config.js", () => ({
|
| 24 |
+
getConfig: vi.fn(() => ({
|
| 25 |
+
auth: {
|
| 26 |
+
jwt_token: null,
|
| 27 |
+
rotation_strategy: "least_used",
|
| 28 |
+
rate_limit_backoff_seconds: 60,
|
| 29 |
+
},
|
| 30 |
+
server: { proxy_api_key: null },
|
| 31 |
+
})),
|
| 32 |
+
}));
|
| 33 |
+
|
| 34 |
+
vi.mock("../../auth/jwt-utils.js", () => ({
|
| 35 |
+
decodeJwtPayload: vi.fn(() => ({ exp: Math.floor(Date.now() / 1000) + 3600 })),
|
| 36 |
+
extractChatGptAccountId: vi.fn((token: string) => `acct-${token.slice(0, 8)}`),
|
| 37 |
+
extractUserProfile: vi.fn((token: string) => ({
|
| 38 |
+
email: `${token.slice(0, 4)}@test.com`,
|
| 39 |
+
chatgpt_plan_type: "free",
|
| 40 |
+
})),
|
| 41 |
+
isTokenExpired: vi.fn(() => false),
|
| 42 |
+
}));
|
| 43 |
+
|
| 44 |
+
vi.mock("../../utils/jitter.js", () => ({
|
| 45 |
+
jitter: vi.fn((val: number) => val),
|
| 46 |
+
}));
|
| 47 |
+
|
| 48 |
+
vi.mock("../../models/model-store.js", () => ({
|
| 49 |
+
getModelPlanTypes: vi.fn(() => []),
|
| 50 |
+
isPlanFetched: vi.fn(() => true),
|
| 51 |
+
}));
|
| 52 |
+
|
| 53 |
+
import { Hono } from "hono";
|
| 54 |
+
import { AccountPool } from "../../auth/account-pool.js";
|
| 55 |
+
import { createAccountRoutes } from "../../routes/accounts.js";
|
| 56 |
+
|
| 57 |
+
const mockScheduler = {
|
| 58 |
+
scheduleOne: vi.fn(),
|
| 59 |
+
clearOne: vi.fn(),
|
| 60 |
+
start: vi.fn(),
|
| 61 |
+
stop: vi.fn(),
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
function addAccounts(pool: AccountPool, count: number): string[] {
|
| 65 |
+
const ids: string[] = [];
|
| 66 |
+
for (let i = 0; i < count; i++) {
|
| 67 |
+
// Each token needs a unique first 8 chars to get a unique accountId from the mock
|
| 68 |
+
const unique = `${String.fromCharCode(65 + i)}${String(i).padStart(7, "0")}`;
|
| 69 |
+
ids.push(pool.addAccount(`${unique}-padding-for-length`));
|
| 70 |
+
}
|
| 71 |
+
return ids;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
describe("batch account operations", () => {
|
| 75 |
+
let pool: AccountPool;
|
| 76 |
+
let app: Hono;
|
| 77 |
+
|
| 78 |
+
beforeEach(() => {
|
| 79 |
+
pool = new AccountPool();
|
| 80 |
+
const routes = createAccountRoutes(pool, mockScheduler as never);
|
| 81 |
+
app = new Hono();
|
| 82 |
+
app.route("/", routes);
|
| 83 |
+
mockScheduler.clearOne.mockClear();
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
// ── batch-delete ──────────────────────────────────────────
|
| 87 |
+
|
| 88 |
+
describe("POST /auth/accounts/batch-delete", () => {
|
| 89 |
+
it("deletes multiple accounts", async () => {
|
| 90 |
+
const ids = addAccounts(pool, 3);
|
| 91 |
+
expect(pool.getAccounts()).toHaveLength(3);
|
| 92 |
+
|
| 93 |
+
const res = await app.request("/auth/accounts/batch-delete", {
|
| 94 |
+
method: "POST",
|
| 95 |
+
headers: { "Content-Type": "application/json" },
|
| 96 |
+
body: JSON.stringify({ ids }),
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
expect(res.status).toBe(200);
|
| 100 |
+
const body = await res.json();
|
| 101 |
+
expect(body.deleted).toBe(3);
|
| 102 |
+
expect(body.notFound).toHaveLength(0);
|
| 103 |
+
expect(pool.getAccounts()).toHaveLength(0);
|
| 104 |
+
// Scheduler cleared for each
|
| 105 |
+
expect(mockScheduler.clearOne).toHaveBeenCalledTimes(3);
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
it("reports not-found ids without failing", async () => {
|
| 109 |
+
const ids = addAccounts(pool, 2);
|
| 110 |
+
|
| 111 |
+
const res = await app.request("/auth/accounts/batch-delete", {
|
| 112 |
+
method: "POST",
|
| 113 |
+
headers: { "Content-Type": "application/json" },
|
| 114 |
+
body: JSON.stringify({ ids: [...ids, "nonexistent-id"] }),
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
expect(res.status).toBe(200);
|
| 118 |
+
const body = await res.json();
|
| 119 |
+
expect(body.deleted).toBe(2);
|
| 120 |
+
expect(body.notFound).toEqual(["nonexistent-id"]);
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
it("rejects empty ids array", async () => {
|
| 124 |
+
const res = await app.request("/auth/accounts/batch-delete", {
|
| 125 |
+
method: "POST",
|
| 126 |
+
headers: { "Content-Type": "application/json" },
|
| 127 |
+
body: JSON.stringify({ ids: [] }),
|
| 128 |
+
});
|
| 129 |
+
expect(res.status).toBe(400);
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
it("rejects malformed body", async () => {
|
| 133 |
+
const res = await app.request("/auth/accounts/batch-delete", {
|
| 134 |
+
method: "POST",
|
| 135 |
+
headers: { "Content-Type": "application/json" },
|
| 136 |
+
body: JSON.stringify({ wrong: "field" }),
|
| 137 |
+
});
|
| 138 |
+
expect(res.status).toBe(400);
|
| 139 |
+
});
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
// ── batch-status ──────────────────────────────────────────
|
| 143 |
+
|
| 144 |
+
describe("POST /auth/accounts/batch-status", () => {
|
| 145 |
+
it("sets multiple accounts to disabled", async () => {
|
| 146 |
+
const ids = addAccounts(pool, 3);
|
| 147 |
+
|
| 148 |
+
const res = await app.request("/auth/accounts/batch-status", {
|
| 149 |
+
method: "POST",
|
| 150 |
+
headers: { "Content-Type": "application/json" },
|
| 151 |
+
body: JSON.stringify({ ids, status: "disabled" }),
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
expect(res.status).toBe(200);
|
| 155 |
+
const body = await res.json();
|
| 156 |
+
expect(body.updated).toBe(3);
|
| 157 |
+
expect(body.notFound).toHaveLength(0);
|
| 158 |
+
|
| 159 |
+
for (const acct of pool.getAccounts()) {
|
| 160 |
+
expect(acct.status).toBe("disabled");
|
| 161 |
+
}
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
it("sets accounts back to active", async () => {
|
| 165 |
+
const ids = addAccounts(pool, 2);
|
| 166 |
+
// First disable them
|
| 167 |
+
for (const id of ids) pool.markStatus(id, "disabled");
|
| 168 |
+
|
| 169 |
+
const res = await app.request("/auth/accounts/batch-status", {
|
| 170 |
+
method: "POST",
|
| 171 |
+
headers: { "Content-Type": "application/json" },
|
| 172 |
+
body: JSON.stringify({ ids, status: "active" }),
|
| 173 |
+
});
|
| 174 |
+
|
| 175 |
+
expect(res.status).toBe(200);
|
| 176 |
+
const body = await res.json();
|
| 177 |
+
expect(body.updated).toBe(2);
|
| 178 |
+
for (const acct of pool.getAccounts()) {
|
| 179 |
+
expect(acct.status).toBe("active");
|
| 180 |
+
}
|
| 181 |
+
});
|
| 182 |
+
|
| 183 |
+
it("rejects disallowed status values", async () => {
|
| 184 |
+
const ids = addAccounts(pool, 1);
|
| 185 |
+
|
| 186 |
+
for (const badStatus of ["expired", "banned", "rate_limited", "refreshing"]) {
|
| 187 |
+
const res = await app.request("/auth/accounts/batch-status", {
|
| 188 |
+
method: "POST",
|
| 189 |
+
headers: { "Content-Type": "application/json" },
|
| 190 |
+
body: JSON.stringify({ ids, status: badStatus }),
|
| 191 |
+
});
|
| 192 |
+
expect(res.status).toBe(400);
|
| 193 |
+
}
|
| 194 |
+
});
|
| 195 |
+
|
| 196 |
+
it("reports not-found ids", async () => {
|
| 197 |
+
const ids = addAccounts(pool, 1);
|
| 198 |
+
|
| 199 |
+
const res = await app.request("/auth/accounts/batch-status", {
|
| 200 |
+
method: "POST",
|
| 201 |
+
headers: { "Content-Type": "application/json" },
|
| 202 |
+
body: JSON.stringify({ ids: [...ids, "ghost"], status: "disabled" }),
|
| 203 |
+
});
|
| 204 |
+
|
| 205 |
+
expect(res.status).toBe(200);
|
| 206 |
+
const body = await res.json();
|
| 207 |
+
expect(body.updated).toBe(1);
|
| 208 |
+
expect(body.notFound).toEqual(["ghost"]);
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
it("rejects empty ids array", async () => {
|
| 212 |
+
const res = await app.request("/auth/accounts/batch-status", {
|
| 213 |
+
method: "POST",
|
| 214 |
+
headers: { "Content-Type": "application/json" },
|
| 215 |
+
body: JSON.stringify({ ids: [], status: "active" }),
|
| 216 |
+
});
|
| 217 |
+
expect(res.status).toBe(400);
|
| 218 |
+
});
|
| 219 |
+
});
|
| 220 |
+
});
|
|
@@ -47,6 +47,7 @@ vi.mock("../../utils/jitter.js", () => ({
|
|
| 47 |
|
| 48 |
vi.mock("../../models/model-store.js", () => ({
|
| 49 |
getModelPlanTypes: vi.fn(() => []),
|
|
|
|
| 50 |
}));
|
| 51 |
|
| 52 |
import { Hono } from "hono";
|
|
|
|
| 47 |
|
| 48 |
vi.mock("../../models/model-store.js", () => ({
|
| 49 |
getModelPlanTypes: vi.fn(() => []),
|
| 50 |
+
isPlanFetched: vi.fn(() => true),
|
| 51 |
}));
|
| 52 |
|
| 53 |
import { Hono } from "hono";
|
|
@@ -49,6 +49,7 @@ vi.mock("../../utils/jitter.js", () => ({
|
|
| 49 |
|
| 50 |
vi.mock("../../models/model-store.js", () => ({
|
| 51 |
getModelPlanTypes: vi.fn(() => []),
|
|
|
|
| 52 |
}));
|
| 53 |
|
| 54 |
import { Hono } from "hono";
|
|
|
|
| 49 |
|
| 50 |
vi.mock("../../models/model-store.js", () => ({
|
| 51 |
getModelPlanTypes: vi.fn(() => []),
|
| 52 |
+
isPlanFetched: vi.fn(() => true),
|
| 53 |
}));
|
| 54 |
|
| 55 |
import { Hono } from "hono";
|
|
@@ -28,6 +28,15 @@ import type { ProxyPool } from "../proxy/proxy-pool.js";
|
|
| 28 |
import { toQuota } from "../auth/quota-utils.js";
|
| 29 |
import { clearWarnings, getActiveWarnings, getWarningsLastUpdated } from "../auth/quota-warnings.js";
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
const BulkImportSchema = z.object({
|
| 32 |
accounts: z.array(z.object({
|
| 33 |
token: z.string().min(1),
|
|
@@ -113,6 +122,72 @@ export function createAccountRoutes(
|
|
| 113 |
return c.json({ success: true, added, updated, failed, errors });
|
| 114 |
});
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
// List all accounts
|
| 117 |
// ?quota=true → return cached quota (fast, from background refresh)
|
| 118 |
// ?quota=fresh → force live fetch from upstream (manual refresh button)
|
|
|
|
| 28 |
import { toQuota } from "../auth/quota-utils.js";
|
| 29 |
import { clearWarnings, getActiveWarnings, getWarningsLastUpdated } from "../auth/quota-warnings.js";
|
| 30 |
|
| 31 |
+
const BatchIdsSchema = z.object({
|
| 32 |
+
ids: z.array(z.string()).min(1),
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
const BatchStatusSchema = z.object({
|
| 36 |
+
ids: z.array(z.string()).min(1),
|
| 37 |
+
status: z.enum(["active", "disabled"]),
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
const BulkImportSchema = z.object({
|
| 41 |
accounts: z.array(z.object({
|
| 42 |
token: z.string().min(1),
|
|
|
|
| 122 |
return c.json({ success: true, added, updated, failed, errors });
|
| 123 |
});
|
| 124 |
|
| 125 |
+
// Batch delete accounts
|
| 126 |
+
app.post("/auth/accounts/batch-delete", async (c) => {
|
| 127 |
+
let body: unknown;
|
| 128 |
+
try {
|
| 129 |
+
body = await c.req.json();
|
| 130 |
+
} catch {
|
| 131 |
+
c.status(400);
|
| 132 |
+
return c.json({ error: "Malformed JSON request body" });
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
const parsed = BatchIdsSchema.safeParse(body);
|
| 136 |
+
if (!parsed.success) {
|
| 137 |
+
c.status(400);
|
| 138 |
+
return c.json({ error: "Invalid request", details: parsed.error.issues });
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
let deleted = 0;
|
| 142 |
+
const notFound: string[] = [];
|
| 143 |
+
|
| 144 |
+
for (const id of parsed.data.ids) {
|
| 145 |
+
scheduler.clearOne(id);
|
| 146 |
+
const removed = pool.removeAccount(id);
|
| 147 |
+
if (removed) {
|
| 148 |
+
cookieJar?.clear(id);
|
| 149 |
+
clearWarnings(id);
|
| 150 |
+
deleted++;
|
| 151 |
+
} else {
|
| 152 |
+
notFound.push(id);
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
return c.json({ success: true, deleted, notFound });
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
// Batch change account status
|
| 160 |
+
app.post("/auth/accounts/batch-status", async (c) => {
|
| 161 |
+
let body: unknown;
|
| 162 |
+
try {
|
| 163 |
+
body = await c.req.json();
|
| 164 |
+
} catch {
|
| 165 |
+
c.status(400);
|
| 166 |
+
return c.json({ error: "Malformed JSON request body" });
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
const parsed = BatchStatusSchema.safeParse(body);
|
| 170 |
+
if (!parsed.success) {
|
| 171 |
+
c.status(400);
|
| 172 |
+
return c.json({ error: "Invalid request", details: parsed.error.issues });
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
let updated = 0;
|
| 176 |
+
const notFound: string[] = [];
|
| 177 |
+
|
| 178 |
+
for (const id of parsed.data.ids) {
|
| 179 |
+
const entry = pool.getEntry(id);
|
| 180 |
+
if (entry) {
|
| 181 |
+
pool.markStatus(id, parsed.data.status);
|
| 182 |
+
updated++;
|
| 183 |
+
} else {
|
| 184 |
+
notFound.push(id);
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
return c.json({ success: true, updated, notFound });
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
// List all accounts
|
| 192 |
// ?quota=true → return cached quota (fast, from background refresh)
|
| 193 |
// ?quota=fresh → force live fetch from upstream (manual refresh button)
|
|
@@ -15,6 +15,7 @@ import { RotationSettings } from "./components/RotationSettings";
|
|
| 15 |
import { TestConnection } from "./components/TestConnection";
|
| 16 |
import { Footer } from "./components/Footer";
|
| 17 |
import { ProxySettings } from "./pages/ProxySettings";
|
|
|
|
| 18 |
import { useAccounts } from "../../shared/hooks/use-accounts";
|
| 19 |
import { useProxies } from "../../shared/hooks/use-proxies";
|
| 20 |
import { useStatus } from "../../shared/hooks/use-status";
|
|
@@ -187,11 +188,12 @@ function useHash(): string {
|
|
| 187 |
export function App() {
|
| 188 |
const hash = useHash();
|
| 189 |
const isProxySettings = hash === "#/proxy-settings";
|
|
|
|
| 190 |
|
| 191 |
return (
|
| 192 |
<I18nProvider>
|
| 193 |
<ThemeProvider>
|
| 194 |
-
{isProxySettings ? <ProxySettingsPage /> : <Dashboard />}
|
| 195 |
</ThemeProvider>
|
| 196 |
</I18nProvider>
|
| 197 |
);
|
|
|
|
| 15 |
import { TestConnection } from "./components/TestConnection";
|
| 16 |
import { Footer } from "./components/Footer";
|
| 17 |
import { ProxySettings } from "./pages/ProxySettings";
|
| 18 |
+
import { AccountManagement } from "./pages/AccountManagement";
|
| 19 |
import { useAccounts } from "../../shared/hooks/use-accounts";
|
| 20 |
import { useProxies } from "../../shared/hooks/use-proxies";
|
| 21 |
import { useStatus } from "../../shared/hooks/use-status";
|
|
|
|
| 188 |
export function App() {
|
| 189 |
const hash = useHash();
|
| 190 |
const isProxySettings = hash === "#/proxy-settings";
|
| 191 |
+
const isAccountManagement = hash === "#/account-management";
|
| 192 |
|
| 193 |
return (
|
| 194 |
<I18nProvider>
|
| 195 |
<ThemeProvider>
|
| 196 |
+
{isProxySettings ? <ProxySettingsPage /> : isAccountManagement ? <AccountManagement /> : <Dashboard />}
|
| 197 |
</ThemeProvider>
|
| 198 |
</I18nProvider>
|
| 199 |
);
|
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from "preact/hooks";
|
| 2 |
+
import { useT } from "../../../shared/i18n/context";
|
| 3 |
+
|
| 4 |
+
interface AccountBulkActionsProps {
|
| 5 |
+
selectedCount: number;
|
| 6 |
+
loading: boolean;
|
| 7 |
+
onBatchDelete: () => void;
|
| 8 |
+
onSetActive: () => void;
|
| 9 |
+
onSetDisabled: () => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function AccountBulkActions({
|
| 13 |
+
selectedCount,
|
| 14 |
+
loading,
|
| 15 |
+
onBatchDelete,
|
| 16 |
+
onSetActive,
|
| 17 |
+
onSetDisabled,
|
| 18 |
+
}: AccountBulkActionsProps) {
|
| 19 |
+
const t = useT();
|
| 20 |
+
const [confirming, setConfirming] = useState(false);
|
| 21 |
+
|
| 22 |
+
const handleDelete = useCallback(() => {
|
| 23 |
+
if (!confirming) {
|
| 24 |
+
setConfirming(true);
|
| 25 |
+
return;
|
| 26 |
+
}
|
| 27 |
+
setConfirming(false);
|
| 28 |
+
onBatchDelete();
|
| 29 |
+
}, [confirming, onBatchDelete]);
|
| 30 |
+
|
| 31 |
+
const cancelConfirm = useCallback(() => setConfirming(false), []);
|
| 32 |
+
|
| 33 |
+
if (selectedCount === 0) return null;
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div class="sticky bottom-0 z-40 bg-white dark:bg-card-dark border-t border-gray-200 dark:border-border-dark shadow-lg px-4 py-3">
|
| 37 |
+
<div class="flex items-center gap-3 flex-wrap">
|
| 38 |
+
<span class="text-sm font-medium text-slate-700 dark:text-text-main shrink-0">
|
| 39 |
+
{selectedCount} {t("accountsCount")} {t("selected")}
|
| 40 |
+
</span>
|
| 41 |
+
|
| 42 |
+
<div class="h-4 w-px bg-gray-200 dark:bg-border-dark hidden sm:block" />
|
| 43 |
+
|
| 44 |
+
<button
|
| 45 |
+
onClick={onSetActive}
|
| 46 |
+
disabled={loading}
|
| 47 |
+
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-green-200 dark:border-green-800/40 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
| 48 |
+
>
|
| 49 |
+
{t("setActive")}
|
| 50 |
+
</button>
|
| 51 |
+
|
| 52 |
+
<button
|
| 53 |
+
onClick={onSetDisabled}
|
| 54 |
+
disabled={loading}
|
| 55 |
+
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-slate-200 dark:border-slate-700/40 bg-slate-50 dark:bg-slate-800/20 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
| 56 |
+
>
|
| 57 |
+
{t("setDisabled")}
|
| 58 |
+
</button>
|
| 59 |
+
|
| 60 |
+
<div class="h-4 w-px bg-gray-200 dark:bg-border-dark hidden sm:block" />
|
| 61 |
+
|
| 62 |
+
{confirming ? (
|
| 63 |
+
<div class="flex items-center gap-2">
|
| 64 |
+
<button
|
| 65 |
+
onClick={handleDelete}
|
| 66 |
+
disabled={loading}
|
| 67 |
+
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-red-600 text-white hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
| 68 |
+
>
|
| 69 |
+
{t("batchDeleteConfirm")}
|
| 70 |
+
</button>
|
| 71 |
+
<button
|
| 72 |
+
onClick={cancelConfirm}
|
| 73 |
+
disabled={loading}
|
| 74 |
+
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:bg-slate-50 dark:hover:bg-border-dark/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
| 75 |
+
>
|
| 76 |
+
{t("cancel")}
|
| 77 |
+
</button>
|
| 78 |
+
</div>
|
| 79 |
+
) : (
|
| 80 |
+
<button
|
| 81 |
+
onClick={handleDelete}
|
| 82 |
+
disabled={loading}
|
| 83 |
+
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-red-200 dark:border-red-800/40 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
| 84 |
+
>
|
| 85 |
+
{t("batchDelete")}
|
| 86 |
+
</button>
|
| 87 |
+
)}
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
);
|
| 91 |
+
}
|
|
@@ -70,6 +70,12 @@ export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing
|
|
| 70 |
{t("updatedAt")} {updatedAtText}
|
| 71 |
</span>
|
| 72 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
{onExport && onImport && (
|
| 74 |
<AccountImportExport onExport={onExport} onImport={onImport} selectedIds={selectedIds} />
|
| 75 |
)}
|
|
|
|
| 70 |
{t("updatedAt")} {updatedAtText}
|
| 71 |
</span>
|
| 72 |
)}
|
| 73 |
+
<a
|
| 74 |
+
href="#/account-management"
|
| 75 |
+
class="text-[0.75rem] text-primary hover:text-primary/80 font-medium transition-colors"
|
| 76 |
+
>
|
| 77 |
+
{t("manageAccounts")} →
|
| 78 |
+
</a>
|
| 79 |
{onExport && onImport && (
|
| 80 |
<AccountImportExport onExport={onExport} onImport={onImport} selectedIds={selectedIds} />
|
| 81 |
)}
|
|
@@ -35,11 +35,11 @@ const statusStyles: Record<string, [string, TranslationKey]> = {
|
|
| 35 |
|
| 36 |
interface AccountTableProps {
|
| 37 |
accounts: AssignmentAccount[];
|
| 38 |
-
proxies: ProxyEntry[];
|
| 39 |
selectedIds: Set<string>;
|
| 40 |
onSelectionChange: (ids: Set<string>) => void;
|
| 41 |
-
onSingleProxyChange: (accountId: string, proxyId: string) => void;
|
| 42 |
-
filterGroup: string | null;
|
| 43 |
statusFilter: string;
|
| 44 |
onStatusFilterChange: (status: string) => void;
|
| 45 |
}
|
|
@@ -50,11 +50,12 @@ export function AccountTable({
|
|
| 50 |
selectedIds,
|
| 51 |
onSelectionChange,
|
| 52 |
onSingleProxyChange,
|
| 53 |
-
filterGroup,
|
| 54 |
statusFilter,
|
| 55 |
onStatusFilterChange,
|
| 56 |
}: AccountTableProps) {
|
| 57 |
const t = useT();
|
|
|
|
| 58 |
const [search, setSearch] = useState("");
|
| 59 |
const [page, setPage] = useState(0);
|
| 60 |
const lastClickedIndex = useRef<number | null>(null);
|
|
@@ -182,7 +183,7 @@ export function AccountTable({
|
|
| 182 |
</label>
|
| 183 |
<span class="flex-1 min-w-0">Email</span>
|
| 184 |
<span class="w-20 text-center hidden sm:block">{t("statusFilter")}</span>
|
| 185 |
-
<span class="w-40 text-center hidden md:block">{t("proxyAssignment")}</span>
|
| 186 |
</div>
|
| 187 |
|
| 188 |
{/* Rows */}
|
|
@@ -224,23 +225,25 @@ export function AccountTable({
|
|
| 224 |
{t(statusKey)}
|
| 225 |
</span>
|
| 226 |
</span>
|
| 227 |
-
|
| 228 |
-
<
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
{p.
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
| 244 |
</div>
|
| 245 |
);
|
| 246 |
})
|
|
|
|
| 35 |
|
| 36 |
interface AccountTableProps {
|
| 37 |
accounts: AssignmentAccount[];
|
| 38 |
+
proxies?: ProxyEntry[];
|
| 39 |
selectedIds: Set<string>;
|
| 40 |
onSelectionChange: (ids: Set<string>) => void;
|
| 41 |
+
onSingleProxyChange?: (accountId: string, proxyId: string) => void;
|
| 42 |
+
filterGroup?: string | null;
|
| 43 |
statusFilter: string;
|
| 44 |
onStatusFilterChange: (status: string) => void;
|
| 45 |
}
|
|
|
|
| 50 |
selectedIds,
|
| 51 |
onSelectionChange,
|
| 52 |
onSingleProxyChange,
|
| 53 |
+
filterGroup = null,
|
| 54 |
statusFilter,
|
| 55 |
onStatusFilterChange,
|
| 56 |
}: AccountTableProps) {
|
| 57 |
const t = useT();
|
| 58 |
+
const showProxy = !!proxies;
|
| 59 |
const [search, setSearch] = useState("");
|
| 60 |
const [page, setPage] = useState(0);
|
| 61 |
const lastClickedIndex = useRef<number | null>(null);
|
|
|
|
| 183 |
</label>
|
| 184 |
<span class="flex-1 min-w-0">Email</span>
|
| 185 |
<span class="w-20 text-center hidden sm:block">{t("statusFilter")}</span>
|
| 186 |
+
{showProxy && <span class="w-40 text-center hidden md:block">{t("proxyAssignment")}</span>}
|
| 187 |
</div>
|
| 188 |
|
| 189 |
{/* Rows */}
|
|
|
|
| 225 |
{t(statusKey)}
|
| 226 |
</span>
|
| 227 |
</span>
|
| 228 |
+
{showProxy && (
|
| 229 |
+
<span class="w-40 hidden md:block" onClick={(e) => e.stopPropagation()}>
|
| 230 |
+
<select
|
| 231 |
+
value={acct.proxyId || "global"}
|
| 232 |
+
onChange={(e) => onSingleProxyChange?.(acct.id, (e.target as HTMLSelectElement).value)}
|
| 233 |
+
class="w-full text-xs px-2 py-1 rounded-md border border-gray-200 dark:border-border-dark bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
|
| 234 |
+
>
|
| 235 |
+
<option value="global">{t("globalDefault")}</option>
|
| 236 |
+
<option value="direct">{t("directNoProxy")}</option>
|
| 237 |
+
<option value="auto">{t("autoRoundRobin")}</option>
|
| 238 |
+
{proxies.map((p) => (
|
| 239 |
+
<option key={p.id} value={p.id}>
|
| 240 |
+
{p.name}
|
| 241 |
+
{p.health?.exitIp ? ` (${p.health.exitIp})` : ""}
|
| 242 |
+
</option>
|
| 243 |
+
))}
|
| 244 |
+
</select>
|
| 245 |
+
</span>
|
| 246 |
+
)}
|
| 247 |
</div>
|
| 248 |
);
|
| 249 |
})
|
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback, useMemo } from "preact/hooks";
|
| 2 |
+
import { useT } from "../../../shared/i18n/context";
|
| 3 |
+
import { useAccounts } from "../../../shared/hooks/use-accounts";
|
| 4 |
+
import { AccountTable } from "../components/AccountTable";
|
| 5 |
+
import { AccountBulkActions } from "../components/AccountBulkActions";
|
| 6 |
+
import { AccountImportExport } from "../components/AccountImportExport";
|
| 7 |
+
import type { AssignmentAccount } from "../../../shared/hooks/use-proxy-assignments";
|
| 8 |
+
import type { TranslationKey } from "../../../shared/i18n/translations";
|
| 9 |
+
|
| 10 |
+
const statusOrder: Array<{ key: string; label: TranslationKey }> = [
|
| 11 |
+
{ key: "active", label: "active" },
|
| 12 |
+
{ key: "expired", label: "expired" },
|
| 13 |
+
{ key: "rate_limited", label: "rateLimited" },
|
| 14 |
+
{ key: "refreshing", label: "refreshing" },
|
| 15 |
+
{ key: "disabled", label: "disabled" },
|
| 16 |
+
{ key: "banned", label: "banned" },
|
| 17 |
+
];
|
| 18 |
+
|
| 19 |
+
export function AccountManagement() {
|
| 20 |
+
const t = useT();
|
| 21 |
+
const { list, loading: listLoading, batchDelete, batchSetStatus, exportAccounts, importAccounts } = useAccounts();
|
| 22 |
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
| 23 |
+
const [statusFilter, setStatusFilter] = useState("all");
|
| 24 |
+
const [message, setMessage] = useState<{ text: string; error?: boolean } | null>(null);
|
| 25 |
+
const [busy, setBusy] = useState(false);
|
| 26 |
+
|
| 27 |
+
const tableAccounts: AssignmentAccount[] = useMemo(
|
| 28 |
+
() =>
|
| 29 |
+
list.map((a) => ({
|
| 30 |
+
id: a.id,
|
| 31 |
+
email: a.email || a.id.slice(0, 8),
|
| 32 |
+
status: a.status,
|
| 33 |
+
proxyId: a.proxyId || "global",
|
| 34 |
+
proxyName: "",
|
| 35 |
+
})),
|
| 36 |
+
[list],
|
| 37 |
+
);
|
| 38 |
+
|
| 39 |
+
const statusCounts = useMemo(() => {
|
| 40 |
+
const counts: Record<string, number> = {};
|
| 41 |
+
for (const a of list) {
|
| 42 |
+
counts[a.status] = (counts[a.status] || 0) + 1;
|
| 43 |
+
}
|
| 44 |
+
return counts;
|
| 45 |
+
}, [list]);
|
| 46 |
+
|
| 47 |
+
const showMessage = useCallback((text: string, error = false) => {
|
| 48 |
+
setMessage({ text, error });
|
| 49 |
+
setTimeout(() => setMessage(null), 3000);
|
| 50 |
+
}, []);
|
| 51 |
+
|
| 52 |
+
const handleBatchDelete = useCallback(async () => {
|
| 53 |
+
setBusy(true);
|
| 54 |
+
try {
|
| 55 |
+
const err = await batchDelete([...selectedIds]);
|
| 56 |
+
if (err) {
|
| 57 |
+
showMessage(err, true);
|
| 58 |
+
} else {
|
| 59 |
+
setSelectedIds(new Set());
|
| 60 |
+
showMessage(t("deleteSuccess"));
|
| 61 |
+
}
|
| 62 |
+
} finally {
|
| 63 |
+
setBusy(false);
|
| 64 |
+
}
|
| 65 |
+
}, [selectedIds, batchDelete, t, showMessage]);
|
| 66 |
+
|
| 67 |
+
const handleSetActive = useCallback(async () => {
|
| 68 |
+
setBusy(true);
|
| 69 |
+
try {
|
| 70 |
+
const err = await batchSetStatus([...selectedIds], "active");
|
| 71 |
+
if (err) {
|
| 72 |
+
showMessage(err, true);
|
| 73 |
+
} else {
|
| 74 |
+
setSelectedIds(new Set());
|
| 75 |
+
showMessage(t("statusChangeSuccess"));
|
| 76 |
+
}
|
| 77 |
+
} finally {
|
| 78 |
+
setBusy(false);
|
| 79 |
+
}
|
| 80 |
+
}, [selectedIds, batchSetStatus, t, showMessage]);
|
| 81 |
+
|
| 82 |
+
const handleSetDisabled = useCallback(async () => {
|
| 83 |
+
setBusy(true);
|
| 84 |
+
try {
|
| 85 |
+
const err = await batchSetStatus([...selectedIds], "disabled");
|
| 86 |
+
if (err) {
|
| 87 |
+
showMessage(err, true);
|
| 88 |
+
} else {
|
| 89 |
+
setSelectedIds(new Set());
|
| 90 |
+
showMessage(t("statusChangeSuccess"));
|
| 91 |
+
}
|
| 92 |
+
} finally {
|
| 93 |
+
setBusy(false);
|
| 94 |
+
}
|
| 95 |
+
}, [selectedIds, batchSetStatus, t, showMessage]);
|
| 96 |
+
|
| 97 |
+
const handleStatusChipClick = useCallback((status: string) => {
|
| 98 |
+
setStatusFilter((prev) => (prev === status ? "all" : status));
|
| 99 |
+
}, []);
|
| 100 |
+
|
| 101 |
+
return (
|
| 102 |
+
<div class="min-h-screen bg-slate-50 dark:bg-bg-dark flex flex-col">
|
| 103 |
+
{/* Header */}
|
| 104 |
+
<header class="sticky top-0 z-50 bg-white dark:bg-card-dark border-b border-gray-200 dark:border-border-dark px-4 py-3">
|
| 105 |
+
<div class="max-w-[1100px] mx-auto flex items-center gap-3">
|
| 106 |
+
<a
|
| 107 |
+
href="#/"
|
| 108 |
+
class="text-sm text-slate-500 dark:text-text-dim hover:text-primary transition-colors"
|
| 109 |
+
>
|
| 110 |
+
← {t("backToDashboard")}
|
| 111 |
+
</a>
|
| 112 |
+
<h1 class="text-base font-semibold text-slate-800 dark:text-text-main">
|
| 113 |
+
{t("accountManagement")}
|
| 114 |
+
</h1>
|
| 115 |
+
<div class="flex-1" />
|
| 116 |
+
<AccountImportExport
|
| 117 |
+
onExport={exportAccounts}
|
| 118 |
+
onImport={importAccounts}
|
| 119 |
+
selectedIds={selectedIds}
|
| 120 |
+
/>
|
| 121 |
+
</div>
|
| 122 |
+
</header>
|
| 123 |
+
|
| 124 |
+
{/* Main content */}
|
| 125 |
+
<main class="flex-grow px-4 md:px-8 py-6 max-w-[1100px] mx-auto w-full">
|
| 126 |
+
{/* Status summary chips */}
|
| 127 |
+
<div class="flex flex-wrap gap-2 mb-4">
|
| 128 |
+
{statusOrder.map(({ key, label }) => {
|
| 129 |
+
const count = statusCounts[key] || 0;
|
| 130 |
+
if (count === 0) return null;
|
| 131 |
+
const isActive = statusFilter === key;
|
| 132 |
+
return (
|
| 133 |
+
<button
|
| 134 |
+
key={key}
|
| 135 |
+
onClick={() => handleStatusChipClick(key)}
|
| 136 |
+
class={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${
|
| 137 |
+
isActive
|
| 138 |
+
? "bg-primary text-white border-primary"
|
| 139 |
+
: "bg-white dark:bg-card-dark border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:border-primary/50"
|
| 140 |
+
}`}
|
| 141 |
+
>
|
| 142 |
+
{t(label)} ({count})
|
| 143 |
+
</button>
|
| 144 |
+
);
|
| 145 |
+
})}
|
| 146 |
+
<span class="px-3 py-1 text-xs text-slate-400 dark:text-text-dim">
|
| 147 |
+
{list.length} {t("totalItems")}
|
| 148 |
+
</span>
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
{/* Message toast */}
|
| 152 |
+
{message && (
|
| 153 |
+
<div class={`mb-4 px-4 py-2 rounded-lg text-sm font-medium ${
|
| 154 |
+
message.error
|
| 155 |
+
? "bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400"
|
| 156 |
+
: "bg-primary/10 text-primary"
|
| 157 |
+
}`}>
|
| 158 |
+
{message.text}
|
| 159 |
+
</div>
|
| 160 |
+
)}
|
| 161 |
+
|
| 162 |
+
{/* Table */}
|
| 163 |
+
{listLoading ? (
|
| 164 |
+
<div class="text-center py-12 text-slate-400 dark:text-text-dim">Loading...</div>
|
| 165 |
+
) : (
|
| 166 |
+
<AccountTable
|
| 167 |
+
accounts={tableAccounts}
|
| 168 |
+
selectedIds={selectedIds}
|
| 169 |
+
onSelectionChange={setSelectedIds}
|
| 170 |
+
statusFilter={statusFilter}
|
| 171 |
+
onStatusFilterChange={setStatusFilter}
|
| 172 |
+
/>
|
| 173 |
+
)}
|
| 174 |
+
</main>
|
| 175 |
+
|
| 176 |
+
{/* Bulk actions bar */}
|
| 177 |
+
<AccountBulkActions
|
| 178 |
+
selectedCount={selectedIds.size}
|
| 179 |
+
loading={busy}
|
| 180 |
+
onBatchDelete={handleBatchDelete}
|
| 181 |
+
onSetActive={handleSetActive}
|
| 182 |
+
onSetDisabled={handleSetDisabled}
|
| 183 |
+
/>
|
| 184 |
+
</div>
|
| 185 |
+
);
|
| 186 |
+
}
|