feat(accounts): improve table controls and secure credential reveal
Add persistent keyboard-accessible column resizing and increase the action-column edge spacing. Disable the active-only refresh action when no enabled checkable account exists and move the account-enabled control below the daily limit field. Add an explicit trusted IPC path that reveals only the selected stored credential on demand, keeps credentials out of renderer snapshots and audit fields, clears dialog state on close, and supports native clipboard copying. Cover UI behavior, request validation, provider-specific credential lookup, preload forwarding, renderer-state secrecy, and IPC trust boundaries.
This commit is contained in:
@@ -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<string, unknown>;
|
||||
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<T>(record: Record<string, T>, ...keys: string[]): Record<string, T> {
|
||||
const removed = new Set(keys);
|
||||
return Object.fromEntries(Object.entries(record).filter(([key]) => !removed.has(key)));
|
||||
|
||||
@@ -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<DebridAccountStatus> {
|
||||
const redactions = collectAccountStatusRedactionValues(this.settings, input);
|
||||
if (input.kind === "realdebrid-api" || input.kind === "realdebrid-web") {
|
||||
|
||||
+4
-1
@@ -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");
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
AccountCheckScope,
|
||||
AccountCommandResult,
|
||||
AccountCredentialCheckInput,
|
||||
AccountSecretRequest,
|
||||
AccountSecretResult,
|
||||
AccountCreateCommand,
|
||||
AccountDeleteCommand,
|
||||
AccountReplaceCommand,
|
||||
@@ -106,6 +108,7 @@ const api: ElectronApi = {
|
||||
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
|
||||
checkDebridAccounts: (scope: AccountCheckScope = "active"): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, scope),
|
||||
checkAccountCredentials: (input: AccountCredentialCheckInput): Promise<DebridAccountStatus> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_ACCOUNT_CREDENTIALS, input),
|
||||
revealAccountSecret: (input: AccountSecretRequest): Promise<AccountSecretResult> => ipcRenderer.invoke(IPC_CHANNELS.REVEAL_ACCOUNT_SECRET, input),
|
||||
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
|
||||
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
|
||||
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),
|
||||
|
||||
+74
-4
@@ -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<LinkPopupState | null>(null);
|
||||
const [accountDialog, setAccountDialog] = useState<AccountDialogState | null>(null);
|
||||
const [accountEditDialog, setAccountEditDialog] = useState<AccountEditState | null>(null);
|
||||
const [accountEditSecretVisible, setAccountEditSecretVisible] = useState<Record<string, boolean>>({});
|
||||
const [accountEditSecretBusy, setAccountEditSecretBusy] = useState<string | null>(null);
|
||||
const [accountDialogSearch, setAccountDialogSearch] = useState("");
|
||||
const [accountDialogModeFilter, setAccountDialogModeFilter] = useState<AccountModeFilter>("all");
|
||||
const [keyStatsPopup, setKeyStatsPopup] = useState<string | null>(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<void> => {
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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,
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type DragEvent,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
type PointerEvent,
|
||||
type ReactElement,
|
||||
type UIEvent
|
||||
} from "react";
|
||||
@@ -16,10 +17,17 @@ import {
|
||||
import { Dialog } from "../../ui/Dialog";
|
||||
import {
|
||||
ACCOUNT_COLUMNS,
|
||||
ACCOUNT_TABLE_COLUMN_IDS,
|
||||
createAccountTableColumnWidths,
|
||||
getAccountTableGridTemplate,
|
||||
getAccountTableMinWidth,
|
||||
getSettingsSelectNavigationIndex,
|
||||
resizeAccountTableColumn,
|
||||
type AccountAddFilter,
|
||||
type AccountAddOption,
|
||||
type AccountRowViewModel
|
||||
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<HTMLElement>(".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");
|
||||
@@ -135,6 +173,9 @@ export interface AccountDialogField {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
help?: string;
|
||||
storedSecret?: boolean;
|
||||
secretVisible?: boolean;
|
||||
secretBusy?: boolean;
|
||||
}
|
||||
|
||||
export interface AccountAddDialogModel {
|
||||
@@ -175,14 +216,20 @@ export interface AccountEditDialogActions {
|
||||
onSave: () => void;
|
||||
onRemove: () => void;
|
||||
onToggleEnabled: () => void;
|
||||
onToggleSecret: (fieldId: string) => void;
|
||||
onCopySecret: (fieldId: string) => void;
|
||||
}
|
||||
|
||||
function AccountDialogFields({
|
||||
fields,
|
||||
onChange
|
||||
onChange,
|
||||
onToggleSecret,
|
||||
onCopySecret
|
||||
}: {
|
||||
fields: readonly AccountDialogField[];
|
||||
onChange: (fieldId: string, value: string) => void;
|
||||
onToggleSecret?: (fieldId: string) => void;
|
||||
onCopySecret?: (fieldId: string) => void;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="settings-account-dialog-fields">
|
||||
@@ -197,6 +244,31 @@ function AccountDialogFields({
|
||||
rows={4}
|
||||
value={field.value}
|
||||
/>
|
||||
) : field.storedSecret ? (
|
||||
<span className="settings-account-secret-control">
|
||||
<input
|
||||
autoComplete="off"
|
||||
className="settings-control"
|
||||
onChange={(event) => onChange(field.id, event.target.value)}
|
||||
placeholder="••••••••••••"
|
||||
type={field.secretVisible ? "text" : "password"}
|
||||
value={field.value}
|
||||
/>
|
||||
<button
|
||||
aria-label={`${field.label} ${field.secretVisible ? "ausblenden" : "anzeigen"}`}
|
||||
className="settings-account-secret-button"
|
||||
disabled={field.secretBusy}
|
||||
onClick={() => onToggleSecret?.(field.id)}
|
||||
type="button"
|
||||
>{field.secretVisible ? "◉" : "◎"}</button>
|
||||
<button
|
||||
aria-label={`${field.label} kopieren`}
|
||||
className="settings-account-secret-button"
|
||||
disabled={!field.value || field.secretBusy}
|
||||
onClick={() => onCopySecret?.(field.id)}
|
||||
type="button"
|
||||
>⧉</button>
|
||||
</span>
|
||||
) : (
|
||||
<input
|
||||
autoComplete={field.type === "password" ? "off" : undefined}
|
||||
@@ -219,12 +291,16 @@ function AccountRow({
|
||||
row,
|
||||
selected,
|
||||
busy,
|
||||
actions
|
||||
actions,
|
||||
gridTemplateColumns,
|
||||
minWidth
|
||||
}: {
|
||||
row: AccountRowViewModel;
|
||||
selected: boolean;
|
||||
busy: boolean;
|
||||
actions: AccountWorkspaceActions;
|
||||
gridTemplateColumns: string;
|
||||
minWidth: number;
|
||||
}): ReactElement {
|
||||
const selectRow = (): void => actions.onSelect(row.id);
|
||||
const onClick = (): void => selectRow();
|
||||
@@ -249,6 +325,7 @@ function AccountRow({
|
||||
onDoubleClick={() => actions.onEdit(row.id)}
|
||||
onKeyDown={onKeyDown}
|
||||
role="row"
|
||||
style={{ gridTemplateColumns, minWidth }}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="settings-account-column-enable" role="cell">
|
||||
@@ -302,19 +379,61 @@ function syncAccountTableScroll(event: UIEvent<HTMLDivElement>): void {
|
||||
|
||||
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<HTMLButtonElement>, column: AccountTableColumnId): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
accountTableResizeSession = { column, startX: event.clientX, initial: loadAccountTableColumnWidths() };
|
||||
};
|
||||
const continueResize = (event: PointerEvent<HTMLButtonElement>): 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<HTMLButtonElement>): void => {
|
||||
if (!accountTableResizeSession) return;
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
accountTableResizeSession = null;
|
||||
};
|
||||
const resizeWithKeyboard = (event: KeyboardEvent<HTMLButtonElement>, 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 (
|
||||
<>
|
||||
<DataTable aria-busy={model.busy} className="settings-account-table" label="Accounts">
|
||||
<DataTableHeader className="settings-account-table-header">
|
||||
<div className="settings-account-table-grid" role="row">
|
||||
<div className="settings-account-table-grid" role="row" style={{ gridTemplateColumns, minWidth }}>
|
||||
<span aria-label="Aktiviert" className="settings-account-column-enable" role="columnheader" />
|
||||
{ACCOUNT_COLUMNS.map((column) => (
|
||||
<span key={column} role="columnheader">
|
||||
{ACCOUNT_COLUMNS.map((column, index) => (
|
||||
<span className="settings-account-resizable-header" key={column} role="columnheader">
|
||||
{column === "Status" && actions.onStatusSort ? (
|
||||
<button className="settings-account-sort" onClick={actions.onStatusSort} type="button">
|
||||
{column}{model.statusSort === "desc" ? " ▼" : model.statusSort === "asc" ? " ▲" : ""}
|
||||
</button>
|
||||
) : column}
|
||||
<button
|
||||
aria-label={`${column} Spaltenbreite ändern`}
|
||||
aria-orientation="vertical"
|
||||
className="settings-account-column-resizer"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => resizeWithKeyboard(event, ACCOUNT_TABLE_COLUMN_IDS[index])}
|
||||
onPointerCancel={finishResize}
|
||||
onPointerDown={(event) => beginResize(event, ACCOUNT_TABLE_COLUMN_IDS[index])}
|
||||
onPointerMove={continueResize}
|
||||
onPointerUp={finishResize}
|
||||
role="separator"
|
||||
type="button"
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
<span aria-label="Aktionen" className="settings-account-column-actions" role="columnheader" />
|
||||
@@ -328,7 +447,7 @@ function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElemen
|
||||
) : model.rows.length === 0 ? (
|
||||
<DataTableEmpty description="Füge einen Account hinzu, um Downloads über einen Anbieter zu starten." title="Noch keine Accounts" />
|
||||
) : 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 }
|
||||
))}
|
||||
</DataTableBody>
|
||||
@@ -337,7 +456,7 @@ function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElemen
|
||||
<div>
|
||||
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onAdd} type="button">+ Hinzufügen</button>
|
||||
<button className="settings-button settings-button-secondary" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button">− Entfernen</button>
|
||||
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onCheckActive} title="Prüft nur aktivierte Accounts." type="button">↻ Aktive aktualisieren</button>
|
||||
<button className="settings-button settings-button-secondary" disabled={model.busy || !model.rows.some((row) => row.enabled && row.canCheck)} onClick={actions.onCheckActive} title="Prüft nur aktivierte Accounts." type="button">↻ Aktive aktualisieren</button>
|
||||
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onCheckAll} title="Prüft alle angelegten Accounts, auch deaktivierte." type="button">↻ Alle aktualisieren</button>
|
||||
</div>
|
||||
<span>{model.rows.length} {model.rows.length === 1 ? "Account" : "Accounts"}</span>
|
||||
@@ -646,11 +765,18 @@ export function AccountEditDialog({
|
||||
<span>{model.hoster} · {model.mode}</span>
|
||||
<strong className="settings-copyable">{model.identity}</strong>
|
||||
</div>
|
||||
<label className="settings-rule-toggle settings-account-edit-enabled">
|
||||
<input checked={model.enabled} disabled={model.busy} onChange={actions.onToggleEnabled} type="checkbox" />
|
||||
<span>Account aktiviert</span>
|
||||
</label>
|
||||
<AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} />
|
||||
<AccountDialogFields
|
||||
fields={model.fields}
|
||||
onChange={actions.onFieldChange}
|
||||
onCopySecret={actions.onCopySecret}
|
||||
onToggleSecret={actions.onToggleSecret}
|
||||
/>
|
||||
<div className="settings-account-edit-enabled-row">
|
||||
<label className="settings-rule-toggle settings-account-edit-enabled">
|
||||
<input checked={model.enabled} disabled={model.busy} onChange={actions.onToggleEnabled} type="checkbox" />
|
||||
<span>Account aktiviert</span>
|
||||
</label>
|
||||
</div>
|
||||
{model.error ? <p className="settings-account-dialog-error" role="alert">{model.error}</p> : null}
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -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<AccountTableColumnId, number>;
|
||||
|
||||
const ACCOUNT_TABLE_COLUMN_LIMITS: Record<AccountTableColumnId, { initial: number; min: number; max: number }> = {
|
||||
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<string, unknown> : {};
|
||||
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":
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
AccountCheckScope,
|
||||
AccountCommandResult,
|
||||
AccountCredentialCheckInput,
|
||||
AccountSecretRequest,
|
||||
AccountSecretResult,
|
||||
AccountCreateCommand,
|
||||
AccountDeleteCommand,
|
||||
AccountReplaceCommand,
|
||||
@@ -103,6 +105,7 @@ export interface ElectronApi {
|
||||
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
|
||||
checkDebridAccounts: (scope?: AccountCheckScope) => Promise<DebridAccountStatus[]>;
|
||||
checkAccountCredentials: (input: AccountCredentialCheckInput) => Promise<DebridAccountStatus>;
|
||||
revealAccountSecret: (input: AccountSecretRequest) => Promise<AccountSecretResult>;
|
||||
retryExtraction: (packageId: string) => Promise<void>;
|
||||
extractNow: (packageId: string) => Promise<void>;
|
||||
resetPackage: (packageId: string) => Promise<void>;
|
||||
|
||||
@@ -347,6 +347,15 @@ export interface AccountCredentialCheckInput {
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export interface AccountSecretRequest {
|
||||
kind: RendererAccountKind;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
export interface AccountSecretResult {
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export interface DownloadItem {
|
||||
id: string;
|
||||
packageId: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type AccountRowSource,
|
||||
type SettingsFormViewModel
|
||||
} from "../src/renderer/views/settings/settings-model";
|
||||
import * as settingsModel from "../src/renderer/views/settings/settings-model";
|
||||
import {
|
||||
AccountAddDialog,
|
||||
AccountEditDialog,
|
||||
@@ -729,6 +730,9 @@ describe("account workspace", () => {
|
||||
expect(html).not.toContain("test-token");
|
||||
expect(html).not.toContain("table-pagination");
|
||||
expect(html).not.toContain("role=\"toolbar\"");
|
||||
expect(count(html, 'role="separator"')).toBe(ACCOUNT_COLUMNS.length);
|
||||
expect(html).toContain('aria-label="Status Spaltenbreite ändern"');
|
||||
expect(settingsCss).toMatch(/\.settings-account-column-actions\s*{[^}]*padding-right:\s*18px/s);
|
||||
});
|
||||
|
||||
it("offers separate checks for active accounts and every configured account", () => {
|
||||
@@ -740,6 +744,38 @@ describe("account workspace", () => {
|
||||
expect(html).toContain('title="Prüft alle angelegten Accounts, auch deaktivierte."');
|
||||
});
|
||||
|
||||
it("disables the active account check when no enabled account can be checked", () => {
|
||||
const model = workspaceModel();
|
||||
const html = renderToStaticMarkup(
|
||||
<AccountWorkspace
|
||||
actions={workspaceActions()}
|
||||
model={{ ...model, rows: model.rows.map((row) => ({ ...row, enabled: false })) }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>↻ Aktive aktualisieren<\/button>/);
|
||||
expect(html).toMatch(/<button[^>]*>↻ Alle aktualisieren<\/button>/);
|
||||
});
|
||||
|
||||
it("clamps and serializes persistent account table column widths", () => {
|
||||
const api = settingsModel as typeof settingsModel & {
|
||||
createAccountTableColumnWidths?: () => Record<string, number>;
|
||||
resizeAccountTableColumn?: (widths: Record<string, number>, column: string, delta: number) => Record<string, number>;
|
||||
getAccountTableGridTemplate?: (widths: Record<string, number>) => string;
|
||||
};
|
||||
|
||||
expect(api.createAccountTableColumnWidths).toBeTypeOf("function");
|
||||
expect(api.resizeAccountTableColumn).toBeTypeOf("function");
|
||||
expect(api.getAccountTableGridTemplate).toBeTypeOf("function");
|
||||
const initial = api.createAccountTableColumnWidths?.() || {};
|
||||
const widened = api.resizeAccountTableColumn?.(initial, "status", 120) || {};
|
||||
const narrowed = api.resizeAccountTableColumn?.(widened, "status", -10_000) || {};
|
||||
|
||||
expect(widened.status).toBeGreaterThan(initial.status);
|
||||
expect(narrowed.status).toBeGreaterThanOrEqual(120);
|
||||
expect(api.getAccountTableGridTemplate?.(widened)).toContain(`${widened.status}px`);
|
||||
});
|
||||
|
||||
it("keeps row selection, enable toggles, edit and context actions separate", () => {
|
||||
const calls: string[] = [];
|
||||
const tree = AccountWorkspace({
|
||||
@@ -841,7 +877,9 @@ describe("account workspace", () => {
|
||||
onCheck: () => {},
|
||||
onSave: () => {},
|
||||
onRemove: () => {},
|
||||
onToggleEnabled: () => {}
|
||||
onToggleEnabled: () => {},
|
||||
onToggleSecret: () => {},
|
||||
onCopySecret: () => {}
|
||||
}}
|
||||
model={{
|
||||
open: true,
|
||||
@@ -851,8 +889,9 @@ describe("account workspace", () => {
|
||||
enabled: true,
|
||||
fields: [
|
||||
{ id: "login", label: "Login", type: "text", value: "member@example.test" },
|
||||
{ id: "password", label: "Passwort", type: "password", value: "test-password" },
|
||||
{ id: "token", label: "Token", type: "password", value: "test-token" }
|
||||
{ id: "password", label: "Passwort", type: "password", value: "", storedSecret: true, secretVisible: false },
|
||||
{ id: "token", label: "Token", type: "password", value: "", storedSecret: true, secretVisible: false },
|
||||
{ id: "dailyLimitGb", label: "Tageslimit (GB, optional)", type: "number", value: "" }
|
||||
],
|
||||
error: "",
|
||||
busy: false
|
||||
@@ -884,6 +923,9 @@ describe("account workspace", () => {
|
||||
expect(editHtml).toContain("Prüfen");
|
||||
expect(count(addHtml, "type=\"password\"")).toBe(1);
|
||||
expect(count(editHtml, "type=\"password\"")).toBe(2);
|
||||
expect(editHtml).toContain('aria-label="Passwort anzeigen"');
|
||||
expect(editHtml).toContain('aria-label="Token anzeigen"');
|
||||
expect(editHtml.indexOf("Tageslimit (GB, optional)")).toBeLessThan(editHtml.indexOf("Account aktiviert"));
|
||||
});
|
||||
|
||||
it("selects the account option through the compact service table", () => {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user