feat(accounts): improve table controls and secure credential reveal

Add persistent keyboard-accessible column resizing and increase the action-column edge spacing. Disable the active-only refresh action when no enabled checkable account exists and move the account-enabled control below the daily limit field. Add an explicit trusted IPC path that reveals only the selected stored credential on demand, keeps credentials out of renderer snapshots and audit fields, clears dialog state on close, and supports native clipboard copying. Cover UI behavior, request validation, provider-specific credential lookup, preload forwarding, renderer-state secrecy, and IPC trust boundaries.
This commit is contained in:
Sucukdeluxe
2026-08-15 01:36:04 +02:00
parent 479c2b600a
commit 8a112747fc
16 changed files with 552 additions and 63 deletions
+36
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { applyAccountCommand, validateAccountCommand, validateAccountCredentialCheckInput } from "../src/main/account-commands";
import * as accountCommands from "../src/main/account-commands";
import { defaultSettings } from "../src/main/constants";
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
@@ -42,6 +43,41 @@ const ACCOUNT_KINDS: RendererAccountKind[] = [
];
describe("write-only account commands", () => {
it("reveals only the exact explicitly requested stored account secret", () => {
const api = accountCommands as typeof accountCommands & {
resolveStoredAccountSecret?: (settings: AppSettings, request: { kind: RendererAccountKind; accountId: string }) => string;
validateAccountSecretRequest?: (value: unknown) => { kind: RendererAccountKind; accountId: string };
};
const megaIdentity = "reveal-mega@example.test";
const megaId = getMegaDebridAccountId(megaIdentity);
const settings = {
...defaultSettings(),
token: "fixture-reveal-rd-1aB2",
megaDebridApiCredentials: `${megaIdentity}:fixture-reveal-mega-3cD4`,
debridLinkApiKeys: "fixture-reveal-dl-5eF6",
bestToken: "fixture-reveal-best-7gH8",
allDebridToken: "fixture-reveal-all-9iJ1",
ddownloadLogin: "reveal-dd@example.test",
ddownloadPassword: "fixture-reveal-dd-2kL3",
oneFichierApiKey: "fixture-reveal-one-4mN5",
linkSnappyLogin: "reveal-ls@example.test",
linkSnappyPassword: "fixture-reveal-ls-6pQ7"
};
expect(api.resolveStoredAccountSecret).toBeTypeOf("function");
expect(api.validateAccountSecretRequest).toBeTypeOf("function");
expect(api.resolveStoredAccountSecret?.(settings, { kind: "realdebrid-api", accountId: "svc-realdebrid" })).toBe("fixture-reveal-rd-1aB2");
expect(api.resolveStoredAccountSecret?.(settings, { kind: "megadebrid-api", accountId: megaId })).toBe("fixture-reveal-mega-3cD4");
expect(api.resolveStoredAccountSecret?.(settings, { kind: "debridlink-api", accountId: getDebridLinkApiKeyId("fixture-reveal-dl-5eF6") })).toBe("fixture-reveal-dl-5eF6");
expect(api.resolveStoredAccountSecret?.(settings, { kind: "bestdebrid-api", accountId: "svc-bestdebrid" })).toBe("fixture-reveal-best-7gH8");
expect(api.resolveStoredAccountSecret?.(settings, { kind: "alldebrid-api", accountId: "svc-alldebrid" })).toBe("fixture-reveal-all-9iJ1");
expect(api.resolveStoredAccountSecret?.(settings, { kind: "ddownload-login", accountId: "svc-ddownload" })).toBe("fixture-reveal-dd-2kL3");
expect(api.resolveStoredAccountSecret?.(settings, { kind: "onefichier-api", accountId: "svc-onefichier" })).toBe("fixture-reveal-one-4mN5");
expect(api.resolveStoredAccountSecret?.(settings, { kind: "linksnappy-login", accountId: "svc-linksnappy" })).toBe("fixture-reveal-ls-6pQ7");
expect(() => api.resolveStoredAccountSecret?.(settings, { kind: "megadebrid-api", accountId: "missing" })).toThrow(/nicht gefunden/i);
expect(() => api.validateAccountSecretRequest?.({ kind: "realdebrid-api", accountId: "svc-realdebrid", secret: "not-allowed" })).toThrow(/ungültig/i);
});
it.each(["realdebrid-api", "realdebrid-web"] as const)("accepts %s credential checks at the IPC boundary", (kind) => {
expect(validateAccountCredentialCheckInput({ kind, accountId: "svc-realdebrid" })).toEqual({
kind,
+14
View File
@@ -73,4 +73,18 @@ describe("account preload contract", () => {
[IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, "all"]
]);
});
it("reveals a stored secret only through the explicit account channel", async () => {
electron.invoke.mockResolvedValueOnce({ secret: "fixture-revealed-secret-7gH8" });
const result = await (electron.api as ElectronApi & {
revealAccountSecret: (request: { kind: "realdebrid-api"; accountId: string }) => Promise<{ secret: string }>;
}).revealAccountSecret({ kind: "realdebrid-api", accountId: "svc-realdebrid" });
expect(electron.invoke).toHaveBeenCalledWith(
IPC_CHANNELS.REVEAL_ACCOUNT_SECRET,
{ kind: "realdebrid-api", accountId: "svc-realdebrid" }
);
expect(result).toEqual({ secret: "fixture-revealed-secret-7gH8" });
});
});
+45 -3
View File
@@ -33,6 +33,7 @@ import {
type AccountRowSource,
type SettingsFormViewModel
} from "../src/renderer/views/settings/settings-model";
import * as settingsModel from "../src/renderer/views/settings/settings-model";
import {
AccountAddDialog,
AccountEditDialog,
@@ -729,6 +730,9 @@ describe("account workspace", () => {
expect(html).not.toContain("test-token");
expect(html).not.toContain("table-pagination");
expect(html).not.toContain("role=\"toolbar\"");
expect(count(html, 'role="separator"')).toBe(ACCOUNT_COLUMNS.length);
expect(html).toContain('aria-label="Status Spaltenbreite ändern"');
expect(settingsCss).toMatch(/\.settings-account-column-actions\s*{[^}]*padding-right:\s*18px/s);
});
it("offers separate checks for active accounts and every configured account", () => {
@@ -740,6 +744,38 @@ describe("account workspace", () => {
expect(html).toContain('title="Prüft alle angelegten Accounts, auch deaktivierte."');
});
it("disables the active account check when no enabled account can be checked", () => {
const model = workspaceModel();
const html = renderToStaticMarkup(
<AccountWorkspace
actions={workspaceActions()}
model={{ ...model, rows: model.rows.map((row) => ({ ...row, enabled: false })) }}
/>
);
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>↻ Aktive aktualisieren<\/button>/);
expect(html).toMatch(/<button[^>]*>↻ Alle aktualisieren<\/button>/);
});
it("clamps and serializes persistent account table column widths", () => {
const api = settingsModel as typeof settingsModel & {
createAccountTableColumnWidths?: () => Record<string, number>;
resizeAccountTableColumn?: (widths: Record<string, number>, column: string, delta: number) => Record<string, number>;
getAccountTableGridTemplate?: (widths: Record<string, number>) => string;
};
expect(api.createAccountTableColumnWidths).toBeTypeOf("function");
expect(api.resizeAccountTableColumn).toBeTypeOf("function");
expect(api.getAccountTableGridTemplate).toBeTypeOf("function");
const initial = api.createAccountTableColumnWidths?.() || {};
const widened = api.resizeAccountTableColumn?.(initial, "status", 120) || {};
const narrowed = api.resizeAccountTableColumn?.(widened, "status", -10_000) || {};
expect(widened.status).toBeGreaterThan(initial.status);
expect(narrowed.status).toBeGreaterThanOrEqual(120);
expect(api.getAccountTableGridTemplate?.(widened)).toContain(`${widened.status}px`);
});
it("keeps row selection, enable toggles, edit and context actions separate", () => {
const calls: string[] = [];
const tree = AccountWorkspace({
@@ -841,7 +877,9 @@ describe("account workspace", () => {
onCheck: () => {},
onSave: () => {},
onRemove: () => {},
onToggleEnabled: () => {}
onToggleEnabled: () => {},
onToggleSecret: () => {},
onCopySecret: () => {}
}}
model={{
open: true,
@@ -851,8 +889,9 @@ describe("account workspace", () => {
enabled: true,
fields: [
{ id: "login", label: "Login", type: "text", value: "member@example.test" },
{ id: "password", label: "Passwort", type: "password", value: "test-password" },
{ id: "token", label: "Token", type: "password", value: "test-token" }
{ id: "password", label: "Passwort", type: "password", value: "", storedSecret: true, secretVisible: false },
{ id: "token", label: "Token", type: "password", value: "", storedSecret: true, secretVisible: false },
{ id: "dailyLimitGb", label: "Tageslimit (GB, optional)", type: "number", value: "" }
],
error: "",
busy: false
@@ -884,6 +923,9 @@ describe("account workspace", () => {
expect(editHtml).toContain("Prüfen");
expect(count(addHtml, "type=\"password\"")).toBe(1);
expect(count(editHtml, "type=\"password\"")).toBe(2);
expect(editHtml).toContain('aria-label="Passwort anzeigen"');
expect(editHtml).toContain('aria-label="Token anzeigen"');
expect(editHtml.indexOf("Tageslimit (GB, optional)")).toBeLessThan(editHtml.indexOf("Account aktiviert"));
});
it("selects the account option through the compact service table", () => {
+1
View File
@@ -38,6 +38,7 @@ export function createVisualElectronApi(
createAccount: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }),
replaceAccount: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }),
updateAccountSecret: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }),
revealAccountSecret: async () => ({ secret: "" }),
deleteAccount: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }),
addLinks: async () => ({ addedPackages: 0, addedLinks: 0, invalidCount: 0 }),
addContainers: async () => ({ addedPackages: 0, addedLinks: 0 }),