feat: refine account management and targeted editing

Add local provider icons, larger compact account rows, the requested column order, and full username display while keeping secrets masked. Replace provider-wide editing with a row-specific editor that preserves sibling accounts, exact limits, live usage counters, and status metadata. Add targeted credential validation, release notices, and regression coverage.
This commit is contained in:
Sucukdeluxe
2026-08-09 21:32:09 +02:00
parent cf72ea2db1
commit 9f0140dd3c
30 changed files with 1356 additions and 130 deletions
+298
View File
@@ -0,0 +1,298 @@
import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import {
applyAccountEdit,
buildAccountEditCheckSettings,
createAccountEditState,
removeAccountTarget,
validateAccountEdit,
validateAccountEditStatuses,
type AccountEditTarget
} from "../src/renderer/account-edit";
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
const GIB = 1024 * 1024 * 1024;
function megaTarget(login: string): AccountEditTarget {
return {
type: "mega",
rowKey: `mega-${getMegaDebridAccountId(login)}`,
kind: "megadebrid-api",
service: "megadebrid-api",
accountId: getMegaDebridAccountId(login)
};
}
function debridLinkTarget(token: string): AccountEditTarget {
return {
type: "debridlink",
rowKey: `dl-${getDebridLinkApiKeyId(token)}`,
kind: "debridlink-api",
service: "debridlink",
keyId: getDebridLinkApiKeyId(token)
};
}
describe("account-specific editing", () => {
it("changes only the selected Mega-Debrid account and preserves sibling order and mode settings", () => {
const oldId = getMegaDebridAccountId("second@example.com");
const newId = getMegaDebridAccountId("renamed@example.com");
const firstId = getMegaDebridAccountId("first@example.com");
const settings = {
...defaultSettings(),
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass\nthird@example.com:third-pass",
megaLogin: "first@example.com",
megaPassword: "first-pass",
megaDebridApiEnabled: true,
megaDebridWebEnabled: true,
megaDebridPreferApi: false,
megaDebridDisabledAccountIds: [oldId, firstId],
megaDebridAccountDailyLimitBytes: { [oldId]: 15 * GIB, [firstId]: 9 * GIB },
megaDebridAccountDailyUsageBytes: { [oldId]: 4 * GIB, [firstId]: 2 * GIB },
megaDebridAccountTotalUsageBytes: { [oldId]: 40 * GIB, [firstId]: 20 * GIB },
debridAccountStatuses: {
[oldId]: { accountId: oldId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "se***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 },
[firstId]: { accountId: firstId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
}
};
const state = {
...createAccountEditState(megaTarget("second@example.com"), settings),
login: "renamed@example.com",
password: "new-pass",
dailyLimitGb: "25,5"
};
expect(validateAccountEdit(state, settings)).toBeNull();
const next = applyAccountEdit(settings, state);
expect(next.megaCredentials).toBe("first@example.com:first-pass\nrenamed@example.com:new-pass\nthird@example.com:third-pass");
expect(next.megaLogin).toBe("first@example.com");
expect(next.megaPassword).toBe("first-pass");
expect(next.megaDebridApiEnabled).toBe(true);
expect(next.megaDebridWebEnabled).toBe(true);
expect(next.megaDebridPreferApi).toBe(false);
expect(next.megaDebridDisabledAccountIds).toEqual([firstId, newId]);
expect(next.megaDebridAccountDailyLimitBytes).toEqual({ [firstId]: 9 * GIB, [newId]: Math.floor(25.5 * GIB) });
expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [firstId]: 2 * GIB });
expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [firstId]: 20 * GIB });
expect(next.debridAccountStatuses).toEqual({
[firstId]: { accountId: firstId, provider: "megadebrid", label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
});
});
it("keeps Mega-Debrid usage when only the password changes", () => {
const id = getMegaDebridAccountId("user@example.com");
const settings = {
...defaultSettings(),
megaCredentials: "user@example.com:old-pass",
megaLogin: "user@example.com",
megaPassword: "old-pass",
megaDebridAccountDailyLimitBytes: { [id]: 8 * GIB },
megaDebridAccountDailyUsageBytes: { [id]: 3 * GIB },
megaDebridAccountTotalUsageBytes: { [id]: 33 * GIB }
};
const state = { ...createAccountEditState(megaTarget("user@example.com"), settings), password: "new-pass" };
const next = applyAccountEdit(settings, state);
expect(next.megaCredentials).toBe("user@example.com:new-pass");
expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [id]: 3 * GIB });
expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [id]: 33 * GIB });
});
it("preserves exact account limits when the rounded display value is left unchanged", () => {
const megaLogin = "user@example.com";
const megaId = getMegaDebridAccountId(megaLogin);
const key = "debrid-link-token";
const keyId = getDebridLinkApiKeyId(key);
const exactLimit = Math.floor(10.05 * GIB);
const settings = {
...defaultSettings(),
megaCredentials: `${megaLogin}:pass`,
megaLogin,
megaPassword: "pass",
megaDebridAccountDailyLimitBytes: { [megaId]: exactLimit },
debridLinkApiKeys: key,
debridLinkApiKeyDailyLimitBytes: { [keyId]: exactLimit }
};
const megaNext = applyAccountEdit(settings, createAccountEditState(megaTarget(megaLogin), settings));
const debridLinkNext = applyAccountEdit(settings, createAccountEditState(debridLinkTarget(key), settings));
expect(megaNext.megaDebridAccountDailyLimitBytes[megaId]).toBe(exactLimit);
expect(debridLinkNext.debridLinkApiKeyDailyLimitBytes[keyId]).toBe(exactLimit);
});
it("rejects whitespace-only Mega-Debrid passwords and empty or mismatched check results", () => {
const login = "user@example.com";
const settings = {
...defaultSettings(),
megaCredentials: `${login}:pass`,
megaLogin: login,
megaPassword: "pass"
};
const state = { ...createAccountEditState(megaTarget(login), settings), password: " " };
expect(validateAccountEdit(state, settings)).toMatch(/Passwort/i);
expect(validateAccountEditStatuses(state, [])).toMatch(/keinen Account/i);
expect(validateAccountEditStatuses(state, [{
accountId: "mda_other",
provider: "megadebrid",
label: "Account 1",
maskedLogin: "ot***er",
valid: true,
isPremium: true,
premiumUntilMs: null,
message: "OK",
checkedAt: 1
}])).toMatch(/falschen Account/i);
});
it("rejects duplicate Mega-Debrid logins and missing row targets", () => {
const settings = {
...defaultSettings(),
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass"
};
const duplicate = {
...createAccountEditState(megaTarget("second@example.com"), settings),
login: "first@example.com"
};
expect(validateAccountEdit(duplicate, settings)).toMatch(/bereits vorhanden/i);
expect(() => createAccountEditState(megaTarget("missing@example.com"), settings)).toThrow(/nicht gefunden/i);
});
it("replaces only the selected Debrid-Link key and migrates its own metadata", () => {
const keyA = "token-a";
const keyB = "token-b";
const keyC = "token-c";
const newKey = "token-b-new";
const idA = getDebridLinkApiKeyId(keyA);
const idB = getDebridLinkApiKeyId(keyB);
const idNew = getDebridLinkApiKeyId(newKey);
const settings = {
...defaultSettings(),
debridLinkApiKeys: `${keyA}\n${keyB}\n${keyC}`,
debridLinkDisabledKeyIds: [idB, idA],
debridLinkApiKeyDailyLimitBytes: { [idA]: 5 * GIB, [idB]: 10 * GIB },
debridLinkApiKeyDailyUsageBytes: { [idA]: 2 * GIB, [idB]: 4 * GIB },
debridLinkApiKeyTotalUsageBytes: { [idA]: 12 * GIB, [idB]: 24 * GIB }
};
const state = {
...createAccountEditState(debridLinkTarget(keyB), settings),
token: newKey,
dailyLimitGb: "12"
};
const next = applyAccountEdit(settings, state);
expect(next.debridLinkApiKeys).toBe(`${keyA}\n${newKey}\n${keyC}`);
expect(next.debridLinkDisabledKeyIds).toEqual([idA, idNew]);
expect(next.debridLinkApiKeyDailyLimitBytes).toEqual({ [idA]: 5 * GIB, [idNew]: 12 * GIB });
expect(next.debridLinkApiKeyDailyUsageBytes).toEqual({ [idA]: 2 * GIB });
expect(next.debridLinkApiKeyTotalUsageBytes).toEqual({ [idA]: 12 * GIB });
});
it("edits a single login without changing unrelated credentials", () => {
const settings = {
...defaultSettings(),
ddownloadLogin: "old@example.com",
ddownloadPassword: "old-pass",
linkSnappyLogin: "keep@example.com",
linkSnappyPassword: "keep-pass"
};
const target: AccountEditTarget = {
type: "single",
rowKey: "svc-ddownload",
kind: "ddownload-login",
service: "ddownload",
provider: "ddownload"
};
const state = {
...createAccountEditState(target, settings),
login: "new@example.com",
password: "new-pass",
dailyLimitGb: "7"
};
const next = applyAccountEdit(settings, state);
expect(next.ddownloadLogin).toBe("new@example.com");
expect(next.ddownloadPassword).toBe("new-pass");
expect(next.linkSnappyLogin).toBe("keep@example.com");
expect(next.linkSnappyPassword).toBe("keep-pass");
expect(next.providerDailyLimitBytes.ddownload).toBe(7 * GIB);
});
it("rejects whitespace-only passwords for direct login accounts", () => {
const settings = {
...defaultSettings(),
ddownloadLogin: "user@example.com",
ddownloadPassword: "password",
linkSnappyLogin: "member@example.com",
linkSnappyPassword: "secret"
};
const ddownloadTarget: AccountEditTarget = {
type: "single",
rowKey: "svc-ddownload",
kind: "ddownload-login",
service: "ddownload",
provider: "ddownload"
};
const linkSnappyTarget: AccountEditTarget = {
type: "single",
rowKey: "svc-linksnappy",
kind: "linksnappy-login",
service: "linksnappy",
provider: "linksnappy"
};
expect(validateAccountEdit({ ...createAccountEditState(ddownloadTarget, settings), password: " " }, settings)).toMatch(/Passwort/i);
expect(validateAccountEdit({ ...createAccountEditState(linkSnappyTarget, settings), password: "\t" }, settings)).toMatch(/Passwort/i);
});
it("builds a targeted check snapshot without invalid sibling accounts", () => {
const settings = {
...defaultSettings(),
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass",
megaLogin: "first@example.com",
megaPassword: "first-pass",
debridLinkApiKeys: "one\ntwo"
};
const target = megaTarget("second@example.com");
const state = createAccountEditState(target, settings);
const checkSettings = buildAccountEditCheckSettings(settings, state);
expect(checkSettings.megaCredentials).toBe("second@example.com:second-pass");
expect(checkSettings.megaLogin).toBe("second@example.com");
expect(checkSettings.megaPassword).toBe("second-pass");
expect(checkSettings.debridLinkApiKeys).toBe("");
});
it("removes only the selected Mega-Debrid account and all metadata belonging to it", () => {
const removeId = getMegaDebridAccountId("second@example.com");
const keepId = getMegaDebridAccountId("first@example.com");
const settings = {
...defaultSettings(),
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass",
megaLogin: "first@example.com",
megaPassword: "first-pass",
megaDebridDisabledAccountIds: [removeId, keepId],
megaDebridAccountDailyLimitBytes: { [removeId]: 2, [keepId]: 1 },
megaDebridAccountDailyUsageBytes: { [removeId]: 4, [keepId]: 3 },
megaDebridAccountTotalUsageBytes: { [removeId]: 6, [keepId]: 5 },
debridAccountStatuses: {
[removeId]: { accountId: removeId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "se***om", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 },
[keepId]: { accountId: keepId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
}
};
const next = removeAccountTarget(settings, megaTarget("second@example.com"));
expect(next.megaCredentials).toBe("first@example.com:first-pass");
expect(next.megaDebridDisabledAccountIds).toEqual([keepId]);
expect(next.megaDebridAccountDailyLimitBytes).toEqual({ [keepId]: 1 });
expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [keepId]: 3 });
expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [keepId]: 5 });
expect(next.debridAccountStatuses).toEqual({
[keepId]: { accountId: keepId, provider: "megadebrid", label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
});
});
});
+34
View File
@@ -0,0 +1,34 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { ACCOUNT_SERVICE_ICONS } from "../src/renderer/account-service-icons";
const services = [
"realdebrid",
"megadebrid-api",
"megadebrid-web",
"bestdebrid",
"alldebrid",
"ddownload",
"onefichier",
"debridlink",
"linksnappy"
] as const;
describe("account service icons", () => {
it("bundles a valid local PNG or ICO for every account service", async () => {
expect(Object.keys(ACCOUNT_SERVICE_ICONS).sort()).toEqual([...services].sort());
for (const service of services) {
const relativePath = ACCOUNT_SERVICE_ICONS[service];
expect(relativePath.startsWith("./provider-icons/")).toBe(true);
const bytes = await readFile(path.resolve("assets", relativePath.slice(2)));
const png = bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
const ico = bytes.subarray(0, 4).equals(Buffer.from([0x00, 0x00, 0x01, 0x00]));
expect(png || ico).toBe(true);
}
});
it("uses the same Mega-Debrid icon for API and Web accounts", () => {
expect(ACCOUNT_SERVICE_ICONS["megadebrid-api"]).toBe(ACCOUNT_SERVICE_ICONS["megadebrid-web"]);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { canPersistExpectedAccountStatus } from "../src/main/account-status-persistence";
import type { DebridAccountStatus } from "../src/shared/types";
function status(accountId: string, valid = true): DebridAccountStatus {
return {
accountId,
provider: "megadebrid",
label: "Account 1",
maskedLogin: "us***om",
valid,
isPremium: true,
premiumUntilMs: null,
message: valid ? "OK" : "Ungültig",
checkedAt: 1
};
}
describe("account status persistence", () => {
it("accepts only one valid status for the expected account", () => {
expect(canPersistExpectedAccountStatus([status("expected")], "expected")).toBe(true);
expect(canPersistExpectedAccountStatus([], "expected")).toBe(false);
expect(canPersistExpectedAccountStatus([status("other")], "expected")).toBe(false);
expect(canPersistExpectedAccountStatus([status("expected", false)], "expected")).toBe(false);
expect(canPersistExpectedAccountStatus([status("expected"), status("other")], "expected")).toBe(false);
expect(canPersistExpectedAccountStatus([status("expected")], "")).toBe(false);
});
});
+9
View File
@@ -6,6 +6,7 @@ import {
isAccountRowSelectionKey,
matchesAccountModeFilter,
pruneAccountRowSelection,
resolveAccountUsername,
resolveVisibleAccountKind
} from "../src/renderer/account-ui";
@@ -89,3 +90,11 @@ describe("account selection", () => {
expect(isAccountRowSelectionKey(" ", false)).toBe(false);
});
});
describe("account usernames", () => {
it("shows the full stored login and prefers a checked provider email", () => {
expect(resolveAccountUsername("member@example.com", undefined)).toBe("member@example.com");
expect(resolveAccountUsername("stored@example.com", "verified@example.com")).toBe("verified@example.com");
expect(resolveAccountUsername("", undefined)).toBe("—");
});
});
+10
View File
@@ -41,6 +41,7 @@ const { verifyPublicRelease, verifyReleaseArchives } = await import(verifierUrl)
const fixtureRoots: string[] = [];
const redistributionFiles = [
"LICENSE",
"THIRD_PARTY_NOTICES.md",
"resources/extractor-jvm/licenses/LGPL-2.1.txt",
"resources/extractor-jvm/licenses/7-Zip-license.txt",
"resources/extractor-jvm/licenses/Apache-2.0.txt",
@@ -59,6 +60,8 @@ function writeRedistributionFiles(rootDir: string, packaged = false): void {
let targetPath: string = relativePath;
if (packaged && relativePath === "LICENSE") {
targetPath = "win-unpacked/resources/LICENSE";
} else if (packaged && relativePath === "THIRD_PARTY_NOTICES.md") {
targetPath = "win-unpacked/resources/THIRD_PARTY_NOTICES.md";
} else if (packaged) {
targetPath = `win-unpacked/resources/app.asar.unpacked/${relativePath}`;
}
@@ -74,6 +77,8 @@ function writeArchivePayload(outputDir: string, omittedName = ""): void {
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
const targetPath = relativePath === "LICENSE"
? "resources/LICENSE"
: relativePath === "THIRD_PARTY_NOTICES.md"
? "resources/THIRD_PARTY_NOTICES.md"
: `resources/app.asar.unpacked/${relativePath}`;
writeFile(outputDir, targetPath, content);
}
@@ -117,12 +122,17 @@ function createReleaseFixture(): string {
"build/renderer/**/*",
"resources/extractor-jvm/**/*",
"LICENSE",
"THIRD_PARTY_NOTICES.md",
"package.json"
],
extraResources: [
{
from: "LICENSE",
to: "LICENSE"
},
{
from: "THIRD_PARTY_NOTICES.md",
to: "THIRD_PARTY_NOTICES.md"
}
],
nsis: {
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import { overlayLiveUsageCounters } from "../src/main/settings-live-overlay";
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
describe("live settings overlay", () => {
it("keeps current Mega-Debrid counters and drops data for identities no longer configured", () => {
const keepMegaId = getMegaDebridAccountId("keep@example.com");
const removedMegaId = getMegaDebridAccountId("removed@example.com");
const keepKeyId = getDebridLinkApiKeyId("keep-key");
const removedKeyId = getDebridLinkApiKeyId("removed-key");
const target = {
...defaultSettings(),
megaCredentials: "keep@example.com:pass",
megaLogin: "keep@example.com",
megaPassword: "pass",
debridLinkApiKeys: "keep-key",
megaDebridAccountDailyUsageBytes: { [keepMegaId]: 1 },
megaDebridAccountTotalUsageBytes: { [keepMegaId]: 2 }
};
const live = {
...defaultSettings(),
megaCredentials: "keep@example.com:pass\nremoved@example.com:pass",
debridLinkApiKeys: "keep-key\nremoved-key",
megaDebridAccountDailyUsageBytes: { [keepMegaId]: 300, [removedMegaId]: 400 },
megaDebridAccountTotalUsageBytes: { [keepMegaId]: 3_000, [removedMegaId]: 4_000 },
debridLinkApiKeyDailyUsageBytes: { [keepKeyId]: 500, [removedKeyId]: 600 },
debridLinkApiKeyTotalUsageBytes: { [keepKeyId]: 5_000, [removedKeyId]: 6_000 },
debridAccountStatuses: {
[keepMegaId]: { accountId: keepMegaId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "ke***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 },
[removedMegaId]: { accountId: removedMegaId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "re***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 },
[keepKeyId]: { accountId: keepKeyId, provider: "debridlink" as const, label: "Key 1", maskedLogin: "kee***key", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 },
[removedKeyId]: { accountId: removedKeyId, provider: "debridlink" as const, label: "Key 2", maskedLogin: "rem***key", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 }
}
};
overlayLiveUsageCounters(target, live, 9_000);
expect(target.megaDebridAccountDailyUsageBytes).toEqual({ [keepMegaId]: 300 });
expect(target.megaDebridAccountTotalUsageBytes).toEqual({ [keepMegaId]: 3_000 });
expect(target.debridLinkApiKeyDailyUsageBytes).toEqual({ [keepKeyId]: 500 });
expect(target.debridLinkApiKeyTotalUsageBytes).toEqual({ [keepKeyId]: 5_000 });
expect(Object.keys(target.debridAccountStatuses).sort()).toEqual([keepKeyId, keepMegaId].sort());
expect(target.totalRuntimeAllTimeMs).toBe(9_000);
});
});