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 }),