From 8a112747fcd9a2fe714be46bdb46a0c2ec45e415 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 15 Aug 2026 01:36:04 +0200 Subject: [PATCH] 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. --- src/main/account-commands.ts | 40 +++- src/main/app-controller.ts | 10 +- src/main/main.ts | 5 +- src/preload/preload.ts | 3 + src/renderer/App.tsx | 78 ++++++- src/renderer/account-edit.ts | 6 +- .../views/settings/AccountWorkspace.tsx | 216 ++++++++++++++---- src/renderer/views/settings/settings-model.ts | 48 ++++ src/renderer/views/settings/settings.css | 97 +++++++- src/shared/ipc.ts | 1 + src/shared/preload-api.ts | 3 + src/shared/types.ts | 9 + tests/account-commands.test.ts | 36 +++ tests/account-preload.test.ts | 14 ++ tests/settings-view.test.tsx | 48 +++- tests/visual/mock-electron-api.ts | 1 + 16 files changed, 552 insertions(+), 63 deletions(-) diff --git a/src/main/account-commands.ts b/src/main/account-commands.ts index a05a762..c23cae0 100644 --- a/src/main/account-commands.ts +++ b/src/main/account-commands.ts @@ -9,7 +9,7 @@ import { serializeMegaDebridAccounts, type MegaDebridAccountMode } from "../shared/mega-debrid-accounts"; -import type { AccountCommand, AccountCredentialCheckInput, AppSettings, DebridProvider, RendererAccountKind } from "../shared/types"; +import type { AccountCommand, AccountCredentialCheckInput, AccountSecretRequest, AppSettings, DebridProvider, RendererAccountKind } from "../shared/types"; export interface AppliedAccountCommand { settings: AppSettings; @@ -134,6 +134,44 @@ export function validateAccountCredentialCheckInput(value: unknown): AccountCred }; } +export function validateAccountSecretRequest(value: unknown): AccountSecretRequest { + if (!value || typeof value !== "object" || Array.isArray(value)) invalid(); + const raw = value as Record; + if (Object.keys(raw).some((key) => key !== "kind" && key !== "accountId")) invalid(); + if (typeof raw.kind !== "string" || !ACCOUNT_KINDS.has(raw.kind as RendererAccountKind)) invalid(); + return { + kind: raw.kind as RendererAccountKind, + accountId: requiredString(raw.accountId, 256).trim() + }; +} + +function storedSecretMissing(): never { + throw new Error("Der gespeicherte Zugang wurde nicht gefunden."); +} + +export function resolveStoredAccountSecret(settings: AppSettings, request: AccountSecretRequest): string { + if (request.kind === "megadebrid-api" || request.kind === "megadebrid-web") { + const mode = request.kind === "megadebrid-web" ? "web" : "api"; + const account = getMegaDebridAccountsForMode(settings, mode).find((entry) => entry.id === request.accountId); + if (!account?.password) storedSecretMissing(); + return account.password; + } + if (request.kind === "debridlink-api") { + const key = parseDebridLinkApiKeys(settings.debridLinkApiKeys).find((entry) => entry.id === request.accountId); + if (!key?.token) storedSecretMissing(); + return key.token; + } + const provider = singleProvider(request.kind); + if (request.accountId !== `svc-${provider}` || !singleConfigured(settings, request.kind)) storedSecretMissing(); + if (request.kind === "realdebrid-api" && settings.token) return settings.token; + if (request.kind === "bestdebrid-api" && settings.bestToken) return settings.bestToken; + if (request.kind === "alldebrid-api" && settings.allDebridToken) return settings.allDebridToken; + if (request.kind === "ddownload-login" && settings.ddownloadPassword) return settings.ddownloadPassword; + if (request.kind === "onefichier-api" && settings.oneFichierApiKey) return settings.oneFichierApiKey; + if (request.kind === "linksnappy-login" && settings.linkSnappyPassword) return settings.linkSnappyPassword; + return storedSecretMissing(); +} + function withoutKeys(record: Record, ...keys: string[]): Record { const removed = new Set(keys); return Object.fromEntries(Object.entries(record).filter(([key]) => !removed.has(key))); diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 8e9f1a1..0be6d5c 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -10,6 +10,8 @@ import { AccountCommand, AccountCommandResult, AccountCredentialCheckInput, + AccountSecretRequest, + AccountSecretResult, DebridAccountStatus, DebridProvider, DuplicatePolicy, @@ -35,7 +37,7 @@ import { checkAllDebridAccounts, checkDebridLinkKey, checkMegaDebridAccount, che import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; -import { applyAccountCommand } from "./account-commands"; +import { applyAccountCommand, resolveStoredAccountSecret } from "./account-commands"; import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus, sanitizeDebridAccountStatuses } from "./account-status-sanitizer"; import { createRendererState } from "./renderer-state"; import { parseCollectorInput } from "./link-parser"; @@ -534,6 +536,12 @@ export class AppController { return { ...applied.response, ...state }; } + public revealAccountSecret(request: AccountSecretRequest): AccountSecretResult { + const secret = resolveStoredAccountSecret(this.settings, request); + this.audit("INFO", "Gespeicherter Account-Zugang explizit angezeigt", { kind: request.kind }); + return { secret }; + } + public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise { const redactions = collectAccountStatusRedactionValues(this.settings, input); if (input.kind === "realdebrid-api" || input.kind === "realdebrid-web") { diff --git a/src/main/main.ts b/src/main/main.ts index 8e8e8c8..c3e8071 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -16,7 +16,7 @@ import { DEV_SERVER_URL } from "./dev-server-url"; import { resolveAppIconPath } from "./app-icon"; import { configureCredentialProtector } from "./credential-protection"; import { isMdd2Backup } from "./backup-crypto"; -import { validateAccountCommand, validateAccountCredentialCheckInput } from "./account-commands"; +import { validateAccountCommand, validateAccountCredentialCheckInput, validateAccountSecretRequest } from "./account-commands"; import { createRendererSettings } from "./renderer-state"; import { validateRendererSettingsUpdate } from "./renderer-settings"; import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security"; @@ -441,6 +441,9 @@ function registerIpcHandlers(): void { if (command.action !== "delete") throw new Error("Account-Payload ist ungültig"); return controller.executeAccountCommand(command); }); + handleTrusted(IPC_CHANNELS.REVEAL_ACCOUNT_SECRET, (_event: IpcMainInvokeEvent, rawRequest: unknown) => { + return controller.revealAccountSecret(validateAccountSecretRequest(rawRequest)); + }); handleTrusted(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => { validatePlainObject(payload ?? {}, "payload"); validateString(payload?.rawText, "rawText"); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 7c10974..c227bf9 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -4,6 +4,8 @@ import { AccountCheckScope, AccountCommandResult, AccountCredentialCheckInput, + AccountSecretRequest, + AccountSecretResult, AccountCreateCommand, AccountDeleteCommand, AccountReplaceCommand, @@ -106,6 +108,7 @@ const api: ElectronApi = { getDebridLinkHostLimits: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS), checkDebridAccounts: (scope: AccountCheckScope = "active"): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, scope), checkAccountCredentials: (input: AccountCredentialCheckInput): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_ACCOUNT_CREDENTIALS, input), + revealAccountSecret: (input: AccountSecretRequest): Promise => ipcRenderer.invoke(IPC_CHANNELS.REVEAL_ACCOUNT_SECRET, input), retryExtraction: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), extractNow: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), resetPackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5d678ac..67c4e1d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -42,7 +42,7 @@ import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./packag import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection"; import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, buildScopedAccountEnabledState, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountStatusState, resolveAccountUsername, resolveVisibleAccountKind, runOptimisticAccountUpdate } from "./account-ui"; import type { AccountModeFilter } from "./account-ui"; -import { buildAccountDeleteCommand, buildAccountReplaceCommand, createAccountEditState, validateAccountEdit } from "./account-edit"; +import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit"; import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit"; import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons"; import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state"; @@ -1609,6 +1609,8 @@ export function App(): ReactElement { const [linkPopup, setLinkPopup] = useState(null); const [accountDialog, setAccountDialog] = useState(null); const [accountEditDialog, setAccountEditDialog] = useState(null); + const [accountEditSecretVisible, setAccountEditSecretVisible] = useState>({}); + const [accountEditSecretBusy, setAccountEditSecretBusy] = useState(null); const [accountDialogSearch, setAccountDialogSearch] = useState(""); const [accountDialogModeFilter, setAccountDialogModeFilter] = useState("all"); const [keyStatsPopup, setKeyStatsPopup] = useState(null); @@ -2680,6 +2682,8 @@ export function App(): ReactElement { const openEditAccountDialog = (row: AccountTableRow): void => { try { + setAccountEditSecretVisible({}); + setAccountEditSecretBusy(null); setAccountEditDialog(createAccountEditState(row.editTarget, snapshot.accounts)); } catch (error) { showToast(String(error), 3200); @@ -2716,6 +2720,8 @@ export function App(): ReactElement { const closeAccountEditDialog = useCallback((): void => { setAccountEditDialog(null); + setAccountEditSecretVisible({}); + setAccountEditSecretBusy(null); }, []); const onSaveAccountEditDialog = async (quickAction?: AccountQuickAction): Promise => { @@ -5188,13 +5194,75 @@ export function App(): ReactElement { }} /> ); + const accountEditSecretRequest = accountEditDialog ? buildAccountSecretRequest(accountEditDialog.target) : null; + const accountEditHasStoredSecret = accountEditSecretRequest + ? snapshot.accounts.some((account) => account.kind === accountEditSecretRequest.kind + && account.accountId === accountEditSecretRequest.accountId + && account.hasSecret) + : false; + const toggleAccountEditSecret = async (fieldId: string): Promise => { + if (fieldId !== "password" && fieldId !== "token") return; + const dialog = accountEditDialog; + if (!dialog) return; + if (accountEditSecretVisible[fieldId]) { + setAccountEditSecretVisible((current) => ({ ...current, [fieldId]: false })); + return; + } + if (dialog[fieldId]) { + setAccountEditSecretVisible((current) => ({ ...current, [fieldId]: true })); + return; + } + const request = buildAccountSecretRequest(dialog.target); + setAccountEditSecretBusy(fieldId); + try { + const result = await window.rd.revealAccountSecret(request); + setAccountEditDialog((current) => { + if (!current) return current; + const currentRequest = buildAccountSecretRequest(current.target); + if (currentRequest.kind !== request.kind || currentRequest.accountId !== request.accountId) return current; + return { ...current, [fieldId]: result.secret }; + }); + setAccountEditSecretVisible((current) => ({ ...current, [fieldId]: true })); + } catch (error) { + showToast(`Gespeicherter Zugang konnte nicht angezeigt werden: ${String(error)}`, 3200); + } finally { + setAccountEditSecretBusy((current) => current === fieldId ? null : current); + } + }; + const copyAccountEditSecret = async (fieldId: string): Promise => { + if (fieldId !== "password" && fieldId !== "token") return; + const secret = accountEditDialog?.[fieldId] || ""; + if (!secret) return; + try { + const copied = await window.rd.writeClipboardText(secret); + showToast(copied ? "Zugang in die Zwischenablage kopiert" : "Zugang konnte nicht kopiert werden", 2200); + } catch (error) { + showToast(`Zugang konnte nicht kopiert werden: ${String(error)}`, 3200); + } + }; const accountEditFields: AccountDialogField[] = accountEditDialog && accountEditOption ? [ ...((accountEditDialog.target.type === "mega" || accountEditOption.needsCredentials) ? [ { id: "login", label: "Login / E-Mail", type: "text" as const, value: accountEditDialog.login }, - { id: "password", label: "Passwort", type: "password" as const, value: accountEditDialog.password } + { + id: "password", + label: "Passwort", + type: "password" as const, + value: accountEditDialog.password, + storedSecret: accountEditHasStoredSecret, + secretVisible: Boolean(accountEditSecretVisible.password), + secretBusy: accountEditSecretBusy === "password" + } ] : []), ...((accountEditDialog.target.type === "debridlink" || accountEditOption.needsToken) ? [ - { id: "token", label: accountEditDialog.target.type === "debridlink" ? "API-Key" : "Token / API-Key", type: "password" as const, value: accountEditDialog.token } + { + id: "token", + label: accountEditDialog.target.type === "debridlink" ? "API-Key" : "Token / API-Key", + type: "password" as const, + value: accountEditDialog.token, + storedSecret: accountEditHasStoredSecret, + secretVisible: Boolean(accountEditSecretVisible.token), + secretBusy: accountEditSecretBusy === "token" + } ] : []), { id: "dailyLimitGb", @@ -5247,7 +5315,9 @@ export function App(): ReactElement { }, onToggleEnabled: () => { if (accountEditRow) toggleAccountTableRow(accountEditRow); - } + }, + onToggleSecret: (fieldId) => { void toggleAccountEditSecret(fieldId); }, + onCopySecret: (fieldId) => { void copyAccountEditSecret(fieldId); } }} model={{ open: true, diff --git a/src/renderer/account-edit.ts b/src/renderer/account-edit.ts index fbdb30d..f013f1b 100644 --- a/src/renderer/account-edit.ts +++ b/src/renderer/account-edit.ts @@ -1,4 +1,4 @@ -import type { AccountDeleteCommand, AccountReplaceCommand, DebridProvider, RendererAccount, RendererAccountKind } from "../shared/types"; +import type { AccountDeleteCommand, AccountReplaceCommand, AccountSecretRequest, DebridProvider, RendererAccount, RendererAccountKind } from "../shared/types"; export type AccountService = "realdebrid" | "megadebrid-api" | "megadebrid-web" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy"; export type AccountKind = RendererAccountKind; @@ -97,3 +97,7 @@ export function buildAccountReplaceCommand(state: AccountEditState): AccountRepl export function buildAccountDeleteCommand(target: AccountEditTarget): AccountDeleteCommand { return { action: "delete", kind: target.kind, accountId: targetAccountId(target) }; } + +export function buildAccountSecretRequest(target: AccountEditTarget): AccountSecretRequest { + return { kind: target.kind, accountId: targetAccountId(target) }; +} diff --git a/src/renderer/views/settings/AccountWorkspace.tsx b/src/renderer/views/settings/AccountWorkspace.tsx index 99b4c68..8eaf683 100644 --- a/src/renderer/views/settings/AccountWorkspace.tsx +++ b/src/renderer/views/settings/AccountWorkspace.tsx @@ -1,9 +1,10 @@ -import { - cloneElement, - type DragEvent, - type KeyboardEvent, - type MouseEvent, - type ReactElement, +import { + cloneElement, + type DragEvent, + type KeyboardEvent, + type MouseEvent, + type PointerEvent, + type ReactElement, type UIEvent } from "react"; import { SlidingSelection } from "../../ui/SlidingSelection"; @@ -16,11 +17,18 @@ import { import { Dialog } from "../../ui/Dialog"; import { ACCOUNT_COLUMNS, + ACCOUNT_TABLE_COLUMN_IDS, + createAccountTableColumnWidths, + getAccountTableGridTemplate, + getAccountTableMinWidth, getSettingsSelectNavigationIndex, + resizeAccountTableColumn, type AccountAddFilter, - type AccountAddOption, - type AccountRowViewModel -} from "./settings-model"; + type AccountAddOption, + type AccountRowViewModel, + type AccountTableColumnId, + type AccountTableColumnWidths +} from "./settings-model"; export type AccountWorkspacePanel = "overview" | "rules"; @@ -29,6 +37,36 @@ const ACCOUNT_WORKSPACE_PANELS: readonly { id: AccountWorkspacePanel; label: str { id: "rules", label: "Verwendungsregeln" } ]; +const ACCOUNT_TABLE_COLUMN_STORAGE_KEY = "mdd.account-table-columns.v1"; +let accountTableResizeSession: { column: AccountTableColumnId; startX: number; initial: AccountTableColumnWidths } | null = null; + +function loadAccountTableColumnWidths(): AccountTableColumnWidths { + try { + const stored = typeof window === "undefined" ? null : window.localStorage.getItem(ACCOUNT_TABLE_COLUMN_STORAGE_KEY); + return createAccountTableColumnWidths(stored ? JSON.parse(stored) : undefined); + } catch { + return createAccountTableColumnWidths(); + } +} + +function applyAccountTableColumnWidths(source: HTMLElement, widths: AccountTableColumnWidths): void { + const table = source.closest(".settings-account-table"); + if (!table) return; + const template = getAccountTableGridTemplate(widths); + const minWidth = `${getAccountTableMinWidth(widths)}px`; + table.querySelectorAll(".settings-account-table-grid, .settings-account-row").forEach((row) => { + row.style.gridTemplateColumns = template; + row.style.minWidth = minWidth; + }); +} + +function persistAccountTableColumnWidths(widths: AccountTableColumnWidths): void { + try { + window.localStorage.setItem(ACCOUNT_TABLE_COLUMN_STORAGE_KEY, JSON.stringify(widths)); + } catch { + } +} + function getAccountPanelNavigationIndex(currentIndex: number, key: string): number | null { if (key === "ArrowRight") { return getSettingsSelectNavigationIndex(currentIndex, ACCOUNT_WORKSPACE_PANELS.length, "ArrowDown"); @@ -128,13 +166,16 @@ export interface AccountWorkspaceProps { actions: AccountWorkspaceActions; } -export interface AccountDialogField { +export interface AccountDialogField { id: string; label: string; type: "text" | "password" | "number" | "textarea"; value: string; placeholder?: string; - help?: string; + help?: string; + storedSecret?: boolean; + secretVisible?: boolean; + secretBusy?: boolean; } export interface AccountAddDialogModel { @@ -168,22 +209,28 @@ export interface AccountEditDialogModel { busy: boolean; } -export interface AccountEditDialogActions { +export interface AccountEditDialogActions { onFieldChange: (fieldId: string, value: string) => void; onClose: () => void; onCheck: () => void; onSave: () => void; onRemove: () => void; - onToggleEnabled: () => void; -} + onToggleEnabled: () => void; + onToggleSecret: (fieldId: string) => void; + onCopySecret: (fieldId: string) => void; +} -function AccountDialogFields({ - fields, - onChange -}: { - fields: readonly AccountDialogField[]; - onChange: (fieldId: string, value: string) => void; -}): ReactElement { +function AccountDialogFields({ + fields, + onChange, + onToggleSecret, + onCopySecret +}: { + fields: readonly AccountDialogField[]; + onChange: (fieldId: string, value: string) => void; + onToggleSecret?: (fieldId: string) => void; + onCopySecret?: (fieldId: string) => void; +}): ReactElement { return (
{fields.map((field) => ( @@ -197,7 +244,32 @@ function AccountDialogFields({ rows={4} value={field.value} /> - ) : ( + ) : field.storedSecret ? ( + + onChange(field.id, event.target.value)} + placeholder="••••••••••••" + type={field.secretVisible ? "text" : "password"} + value={field.value} + /> + + + + ) : ( actions.onSelect(row.id); const onClick = (): void => selectRow(); @@ -248,8 +324,9 @@ function AccountRow({ onContextMenu={openContextMenu} onDoubleClick={() => actions.onEdit(row.id)} onKeyDown={onKeyDown} - role="row" - tabIndex={0} + role="row" + style={{ gridTemplateColumns, minWidth }} + tabIndex={0} > ): void { } } -function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElement { - const selectedIds = new Set(model.selectedIds); - return ( +function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElement { + const selectedIds = new Set(model.selectedIds); + const columnWidths = loadAccountTableColumnWidths(); + const gridTemplateColumns = getAccountTableGridTemplate(columnWidths); + const minWidth = getAccountTableMinWidth(columnWidths); + const beginResize = (event: PointerEvent, column: AccountTableColumnId): void => { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture?.(event.pointerId); + accountTableResizeSession = { column, startX: event.clientX, initial: loadAccountTableColumnWidths() }; + }; + const continueResize = (event: PointerEvent): void => { + const active = accountTableResizeSession; + if (!active) return; + const next = resizeAccountTableColumn(active.initial, active.column, event.clientX - active.startX); + applyAccountTableColumnWidths(event.currentTarget, next); + persistAccountTableColumnWidths(next); + }; + const finishResize = (event: PointerEvent): void => { + if (!accountTableResizeSession) return; + event.currentTarget.releasePointerCapture?.(event.pointerId); + accountTableResizeSession = null; + }; + const resizeWithKeyboard = (event: KeyboardEvent, column: AccountTableColumnId): void => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + event.stopPropagation(); + const next = resizeAccountTableColumn(loadAccountTableColumnWidths(), column, event.key === "ArrowRight" ? 16 : -16); + applyAccountTableColumnWidths(event.currentTarget, next); + persistAccountTableColumnWidths(next); + }; + return ( <> -
- - {ACCOUNT_COLUMNS.map((column) => ( - - {column === "Status" && actions.onStatusSort ? ( +
+ + {ACCOUNT_COLUMNS.map((column, index) => ( + + {column === "Status" && actions.onStatusSort ? ( - ) : column} - + ) : column} +
@@ -328,7 +447,7 @@ function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElemen ) : model.rows.length === 0 ? ( ) : model.rows.map((row) => cloneElement( - AccountRow({ actions, busy: model.busy, row, selected: selectedIds.has(row.id) }), + AccountRow({ actions, busy: model.busy, gridTemplateColumns, minWidth, row, selected: selectedIds.has(row.id) }), { key: row.id } ))} @@ -337,7 +456,7 @@ function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElemen
- +
{model.rows.length} {model.rows.length === 1 ? "Account" : "Accounts"} @@ -519,7 +638,7 @@ export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): Rea id="settings-account-overview" role="tabpanel" > - {AccountOverview({ actions, model })} + {AccountOverview({ actions, model })}
{model.hoster} · {model.mode} {model.identity}
- - + +
+ +
{model.error ?

{model.error}

: null} ); diff --git a/src/renderer/views/settings/settings-model.ts b/src/renderer/views/settings/settings-model.ts index 5ff2fc8..8bec352 100644 --- a/src/renderer/views/settings/settings-model.ts +++ b/src/renderer/views/settings/settings-model.ts @@ -24,6 +24,54 @@ export const ACCOUNT_COLUMNS = [ "Passwort/Zugang" ] as const; +export const ACCOUNT_TABLE_COLUMN_IDS = [ + "hoster", + "status", + "traffic", + "username", + "email", + "expires", + "credential" +] as const; + +export type AccountTableColumnId = typeof ACCOUNT_TABLE_COLUMN_IDS[number]; +export type AccountTableColumnWidths = Record; + +const ACCOUNT_TABLE_COLUMN_LIMITS: Record = { + hoster: { initial: 210, min: 140, max: 520 }, + status: { initial: 250, min: 120, max: 520 }, + traffic: { initial: 210, min: 150, max: 420 }, + username: { initial: 170, min: 120, max: 420 }, + email: { initial: 200, min: 140, max: 480 }, + expires: { initial: 150, min: 120, max: 280 }, + credential: { initial: 170, min: 130, max: 320 } +}; + +export function createAccountTableColumnWidths(value?: unknown): AccountTableColumnWidths { + const raw = value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + return Object.fromEntries(ACCOUNT_TABLE_COLUMN_IDS.map((id) => { + const limits = ACCOUNT_TABLE_COLUMN_LIMITS[id]; + const candidate = typeof raw[id] === "number" && Number.isFinite(raw[id]) ? Math.round(raw[id]) : limits.initial; + return [id, Math.max(limits.min, Math.min(limits.max, candidate))]; + })) as AccountTableColumnWidths; +} + +export function resizeAccountTableColumn( + widths: AccountTableColumnWidths, + column: AccountTableColumnId, + delta: number +): AccountTableColumnWidths { + return createAccountTableColumnWidths({ ...widths, [column]: widths[column] + delta }); +} + +export function getAccountTableGridTemplate(widths: AccountTableColumnWidths): string { + return `42px ${ACCOUNT_TABLE_COLUMN_IDS.map((id) => `${widths[id]}px`).join(" ")} 64px`; +} + +export function getAccountTableMinWidth(widths: AccountTableColumnWidths): number { + return 42 + 64 + ACCOUNT_TABLE_COLUMN_IDS.reduce((sum, id) => sum + widths[id], 0); +} + export function getSettingsSaveLabel(state: SettingsSaveState): string { switch (state) { case "dirty": diff --git a/src/renderer/views/settings/settings.css b/src/renderer/views/settings/settings.css index ff831fc..850d80b 100644 --- a/src/renderer/views/settings/settings.css +++ b/src/renderer/views/settings/settings.css @@ -557,8 +557,6 @@ .settings-account-table-grid, .settings-account-row { display: grid; - grid-template-columns: 42px minmax(170px, 1.1fr) minmax(150px, 0.9fr) minmax(190px, 1.2fr) minmax(145px, 0.9fr) minmax(190px, 1.1fr) minmax(130px, 0.8fr) minmax(145px, 0.85fr) 44px; - min-width: 1260px; align-items: center; } @@ -581,6 +579,45 @@ padding: 0 11px; } +.settings-account-resizable-header { + position: relative; + height: 100%; + display: flex; + align-items: center; +} + +.settings-account-column-resizer { + position: absolute; + top: 7px; + right: -4px; + bottom: 7px; + z-index: 2; + width: 9px; + padding: 0; + border: 0; + outline: 0; + background: transparent; + cursor: col-resize; + touch-action: none; +} + +.settings-account-column-resizer::after { + position: absolute; + top: 0; + bottom: 0; + left: 4px; + width: 1px; + background: var(--ui-border); + content: ""; + opacity: 0; +} + +.settings-account-column-resizer:hover::after, +.settings-account-column-resizer:focus-visible::after { + background: var(--ui-accent); + opacity: 1; +} + .settings-account-table-body { overflow: auto; } @@ -628,6 +665,10 @@ place-items: center; } +.settings-account-column-actions { + padding-right: 18px !important; +} + .settings-account-hoster { display: flex; align-items: center; @@ -999,6 +1040,48 @@ gap: 7px; } +.settings-account-secret-control { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) 38px 38px; +} + +.settings-account-secret-control .settings-control { + grid-column: 1 / -1; + grid-row: 1; + padding-right: 84px; +} + +.settings-account-secret-button { + z-index: 1; + grid-row: 1; + align-self: stretch; + padding: 0; + border: 0; + border-left: 1px solid var(--ui-control-border); + background: transparent; + color: var(--ui-text-secondary); + cursor: pointer; +} + +.settings-account-secret-button:first-of-type { + grid-column: 2; +} + +.settings-account-secret-button:last-of-type { + grid-column: 3; +} + +.settings-account-secret-button:hover:not(:disabled), +.settings-account-secret-button:focus-visible { + color: var(--ui-accent); +} + +.settings-account-secret-button:disabled { + cursor: default; + opacity: 0.4; +} + .settings-account-dialog-textarea { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } @@ -1037,6 +1120,11 @@ min-height: 30px; } +.settings-account-edit-enabled-row { + display: flex; + justify-content: flex-end; +} + @media (max-width: 1366px) { .settings-view { grid-template-columns: 56px minmax(0, 1fr); @@ -1050,11 +1138,6 @@ padding: 20px; } - .settings-account-table-grid, - .settings-account-row { - grid-template-columns: 40px 160px 140px 180px 135px 170px 120px 140px 42px; - min-width: 1127px; - } } @media (max-width: 1120px) { diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 140929a..d394e5e 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -73,6 +73,7 @@ export const IPC_CHANNELS = { GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits", CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts", CHECK_ACCOUNT_CREDENTIALS: "app:check-account-credentials", + REVEAL_ACCOUNT_SECRET: "app:reveal-account-secret", RETRY_EXTRACTION: "queue:retry-extraction", EXTRACT_NOW: "queue:extract-now", RESET_PACKAGE: "queue:reset-package", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 9fa43cd..fbe1194 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -3,6 +3,8 @@ import type { AccountCheckScope, AccountCommandResult, AccountCredentialCheckInput, + AccountSecretRequest, + AccountSecretResult, AccountCreateCommand, AccountDeleteCommand, AccountReplaceCommand, @@ -103,6 +105,7 @@ export interface ElectronApi { getDebridLinkHostLimits: () => Promise; checkDebridAccounts: (scope?: AccountCheckScope) => Promise; checkAccountCredentials: (input: AccountCredentialCheckInput) => Promise; + revealAccountSecret: (input: AccountSecretRequest) => Promise; retryExtraction: (packageId: string) => Promise; extractNow: (packageId: string) => Promise; resetPackage: (packageId: string) => Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index b7f7bcf..7886ebe 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -346,6 +346,15 @@ export interface AccountCredentialCheckInput { identity?: string; secret?: string; } + +export interface AccountSecretRequest { + kind: RendererAccountKind; + accountId: string; +} + +export interface AccountSecretResult { + secret: string; +} export interface DownloadItem { id: string; diff --git a/tests/account-commands.test.ts b/tests/account-commands.test.ts index c67dfed..d63c935 100644 --- a/tests/account-commands.test.ts +++ b/tests/account-commands.test.ts @@ -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, diff --git a/tests/account-preload.test.ts b/tests/account-preload.test.ts index 76d34af..79deb35 100644 --- a/tests/account-preload.test.ts +++ b/tests/account-preload.test.ts @@ -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" }); + }); }); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index ef3e1ec..b44cd44 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -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( + ({ ...row, enabled: false })) }} + /> + ); + + expect(html).toMatch(/]*disabled=""[^>]*>↻ Aktive aktualisieren<\/button>/); + expect(html).toMatch(/]*>↻ Alle aktualisieren<\/button>/); + }); + + it("clamps and serializes persistent account table column widths", () => { + const api = settingsModel as typeof settingsModel & { + createAccountTableColumnWidths?: () => Record; + resizeAccountTableColumn?: (widths: Record, column: string, delta: number) => Record; + getAccountTableGridTemplate?: (widths: Record) => 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", () => { diff --git a/tests/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index aa277c1..8909d51 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -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 }),