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,
|
serializeMegaDebridAccounts,
|
||||||
type MegaDebridAccountMode
|
type MegaDebridAccountMode
|
||||||
} from "../shared/mega-debrid-accounts";
|
} 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 {
|
export interface AppliedAccountCommand {
|
||||||
settings: AppSettings;
|
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> {
|
function withoutKeys<T>(record: Record<string, T>, ...keys: string[]): Record<string, T> {
|
||||||
const removed = new Set(keys);
|
const removed = new Set(keys);
|
||||||
return Object.fromEntries(Object.entries(record).filter(([key]) => !removed.has(key)));
|
return Object.fromEntries(Object.entries(record).filter(([key]) => !removed.has(key)));
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
AccountCommand,
|
AccountCommand,
|
||||||
AccountCommandResult,
|
AccountCommandResult,
|
||||||
AccountCredentialCheckInput,
|
AccountCredentialCheckInput,
|
||||||
|
AccountSecretRequest,
|
||||||
|
AccountSecretResult,
|
||||||
DebridAccountStatus,
|
DebridAccountStatus,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
@@ -35,7 +37,7 @@ import { checkAllDebridAccounts, checkDebridLinkKey, checkMegaDebridAccount, che
|
|||||||
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||||
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
|
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
|
||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
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 { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus, sanitizeDebridAccountStatuses } from "./account-status-sanitizer";
|
||||||
import { createRendererState } from "./renderer-state";
|
import { createRendererState } from "./renderer-state";
|
||||||
import { parseCollectorInput } from "./link-parser";
|
import { parseCollectorInput } from "./link-parser";
|
||||||
@@ -534,6 +536,12 @@ export class AppController {
|
|||||||
return { ...applied.response, ...state };
|
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> {
|
public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise<DebridAccountStatus> {
|
||||||
const redactions = collectAccountStatusRedactionValues(this.settings, input);
|
const redactions = collectAccountStatusRedactionValues(this.settings, input);
|
||||||
if (input.kind === "realdebrid-api" || input.kind === "realdebrid-web") {
|
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 { resolveAppIconPath } from "./app-icon";
|
||||||
import { configureCredentialProtector } from "./credential-protection";
|
import { configureCredentialProtector } from "./credential-protection";
|
||||||
import { isMdd2Backup } from "./backup-crypto";
|
import { isMdd2Backup } from "./backup-crypto";
|
||||||
import { validateAccountCommand, validateAccountCredentialCheckInput } from "./account-commands";
|
import { validateAccountCommand, validateAccountCredentialCheckInput, validateAccountSecretRequest } from "./account-commands";
|
||||||
import { createRendererSettings } from "./renderer-state";
|
import { createRendererSettings } from "./renderer-state";
|
||||||
import { validateRendererSettingsUpdate } from "./renderer-settings";
|
import { validateRendererSettingsUpdate } from "./renderer-settings";
|
||||||
import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security";
|
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");
|
if (command.action !== "delete") throw new Error("Account-Payload ist ungültig");
|
||||||
return controller.executeAccountCommand(command);
|
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) => {
|
handleTrusted(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => {
|
||||||
validatePlainObject(payload ?? {}, "payload");
|
validatePlainObject(payload ?? {}, "payload");
|
||||||
validateString(payload?.rawText, "rawText");
|
validateString(payload?.rawText, "rawText");
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
AccountCheckScope,
|
AccountCheckScope,
|
||||||
AccountCommandResult,
|
AccountCommandResult,
|
||||||
AccountCredentialCheckInput,
|
AccountCredentialCheckInput,
|
||||||
|
AccountSecretRequest,
|
||||||
|
AccountSecretResult,
|
||||||
AccountCreateCommand,
|
AccountCreateCommand,
|
||||||
AccountDeleteCommand,
|
AccountDeleteCommand,
|
||||||
AccountReplaceCommand,
|
AccountReplaceCommand,
|
||||||
@@ -106,6 +108,7 @@ const api: ElectronApi = {
|
|||||||
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
|
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),
|
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),
|
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),
|
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
|
||||||
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, 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),
|
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 { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection";
|
||||||
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, buildScopedAccountEnabledState, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountStatusState, resolveAccountUsername, resolveVisibleAccountKind, runOptimisticAccountUpdate } from "./account-ui";
|
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, buildScopedAccountEnabledState, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountStatusState, resolveAccountUsername, resolveVisibleAccountKind, runOptimisticAccountUpdate } from "./account-ui";
|
||||||
import type { AccountModeFilter } 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 type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
|
||||||
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
|
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
|
||||||
import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state";
|
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 [linkPopup, setLinkPopup] = useState<LinkPopupState | null>(null);
|
||||||
const [accountDialog, setAccountDialog] = useState<AccountDialogState | null>(null);
|
const [accountDialog, setAccountDialog] = useState<AccountDialogState | null>(null);
|
||||||
const [accountEditDialog, setAccountEditDialog] = useState<AccountEditState | 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 [accountDialogSearch, setAccountDialogSearch] = useState("");
|
||||||
const [accountDialogModeFilter, setAccountDialogModeFilter] = useState<AccountModeFilter>("all");
|
const [accountDialogModeFilter, setAccountDialogModeFilter] = useState<AccountModeFilter>("all");
|
||||||
const [keyStatsPopup, setKeyStatsPopup] = useState<string | null>(null);
|
const [keyStatsPopup, setKeyStatsPopup] = useState<string | null>(null);
|
||||||
@@ -2680,6 +2682,8 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const openEditAccountDialog = (row: AccountTableRow): void => {
|
const openEditAccountDialog = (row: AccountTableRow): void => {
|
||||||
try {
|
try {
|
||||||
|
setAccountEditSecretVisible({});
|
||||||
|
setAccountEditSecretBusy(null);
|
||||||
setAccountEditDialog(createAccountEditState(row.editTarget, snapshot.accounts));
|
setAccountEditDialog(createAccountEditState(row.editTarget, snapshot.accounts));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(String(error), 3200);
|
showToast(String(error), 3200);
|
||||||
@@ -2716,6 +2720,8 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const closeAccountEditDialog = useCallback((): void => {
|
const closeAccountEditDialog = useCallback((): void => {
|
||||||
setAccountEditDialog(null);
|
setAccountEditDialog(null);
|
||||||
|
setAccountEditSecretVisible({});
|
||||||
|
setAccountEditSecretBusy(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onSaveAccountEditDialog = async (quickAction?: AccountQuickAction): Promise<void> => {
|
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 ? [
|
const accountEditFields: AccountDialogField[] = accountEditDialog && accountEditOption ? [
|
||||||
...((accountEditDialog.target.type === "mega" || accountEditOption.needsCredentials) ? [
|
...((accountEditDialog.target.type === "mega" || accountEditOption.needsCredentials) ? [
|
||||||
{ id: "login", label: "Login / E-Mail", type: "text" as const, value: accountEditDialog.login },
|
{ 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) ? [
|
...((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",
|
id: "dailyLimitGb",
|
||||||
@@ -5247,7 +5315,9 @@ export function App(): ReactElement {
|
|||||||
},
|
},
|
||||||
onToggleEnabled: () => {
|
onToggleEnabled: () => {
|
||||||
if (accountEditRow) toggleAccountTableRow(accountEditRow);
|
if (accountEditRow) toggleAccountTableRow(accountEditRow);
|
||||||
}
|
},
|
||||||
|
onToggleSecret: (fieldId) => { void toggleAccountEditSecret(fieldId); },
|
||||||
|
onCopySecret: (fieldId) => { void copyAccountEditSecret(fieldId); }
|
||||||
}}
|
}}
|
||||||
model={{
|
model={{
|
||||||
open: true,
|
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 AccountService = "realdebrid" | "megadebrid-api" | "megadebrid-web" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy";
|
||||||
export type AccountKind = RendererAccountKind;
|
export type AccountKind = RendererAccountKind;
|
||||||
@@ -97,3 +97,7 @@ export function buildAccountReplaceCommand(state: AccountEditState): AccountRepl
|
|||||||
export function buildAccountDeleteCommand(target: AccountEditTarget): AccountDeleteCommand {
|
export function buildAccountDeleteCommand(target: AccountEditTarget): AccountDeleteCommand {
|
||||||
return { action: "delete", kind: target.kind, accountId: targetAccountId(target) };
|
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 DragEvent,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
|
type PointerEvent,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
type UIEvent
|
type UIEvent
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -16,10 +17,17 @@ import {
|
|||||||
import { Dialog } from "../../ui/Dialog";
|
import { Dialog } from "../../ui/Dialog";
|
||||||
import {
|
import {
|
||||||
ACCOUNT_COLUMNS,
|
ACCOUNT_COLUMNS,
|
||||||
|
ACCOUNT_TABLE_COLUMN_IDS,
|
||||||
|
createAccountTableColumnWidths,
|
||||||
|
getAccountTableGridTemplate,
|
||||||
|
getAccountTableMinWidth,
|
||||||
getSettingsSelectNavigationIndex,
|
getSettingsSelectNavigationIndex,
|
||||||
|
resizeAccountTableColumn,
|
||||||
type AccountAddFilter,
|
type AccountAddFilter,
|
||||||
type AccountAddOption,
|
type AccountAddOption,
|
||||||
type AccountRowViewModel
|
type AccountRowViewModel,
|
||||||
|
type AccountTableColumnId,
|
||||||
|
type AccountTableColumnWidths
|
||||||
} from "./settings-model";
|
} from "./settings-model";
|
||||||
|
|
||||||
export type AccountWorkspacePanel = "overview" | "rules";
|
export type AccountWorkspacePanel = "overview" | "rules";
|
||||||
@@ -29,6 +37,36 @@ const ACCOUNT_WORKSPACE_PANELS: readonly { id: AccountWorkspacePanel; label: str
|
|||||||
{ id: "rules", label: "Verwendungsregeln" }
|
{ 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 {
|
function getAccountPanelNavigationIndex(currentIndex: number, key: string): number | null {
|
||||||
if (key === "ArrowRight") {
|
if (key === "ArrowRight") {
|
||||||
return getSettingsSelectNavigationIndex(currentIndex, ACCOUNT_WORKSPACE_PANELS.length, "ArrowDown");
|
return getSettingsSelectNavigationIndex(currentIndex, ACCOUNT_WORKSPACE_PANELS.length, "ArrowDown");
|
||||||
@@ -135,6 +173,9 @@ export interface AccountDialogField {
|
|||||||
value: string;
|
value: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
help?: string;
|
help?: string;
|
||||||
|
storedSecret?: boolean;
|
||||||
|
secretVisible?: boolean;
|
||||||
|
secretBusy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountAddDialogModel {
|
export interface AccountAddDialogModel {
|
||||||
@@ -175,14 +216,20 @@ export interface AccountEditDialogActions {
|
|||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onToggleEnabled: () => void;
|
onToggleEnabled: () => void;
|
||||||
|
onToggleSecret: (fieldId: string) => void;
|
||||||
|
onCopySecret: (fieldId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountDialogFields({
|
function AccountDialogFields({
|
||||||
fields,
|
fields,
|
||||||
onChange
|
onChange,
|
||||||
|
onToggleSecret,
|
||||||
|
onCopySecret
|
||||||
}: {
|
}: {
|
||||||
fields: readonly AccountDialogField[];
|
fields: readonly AccountDialogField[];
|
||||||
onChange: (fieldId: string, value: string) => void;
|
onChange: (fieldId: string, value: string) => void;
|
||||||
|
onToggleSecret?: (fieldId: string) => void;
|
||||||
|
onCopySecret?: (fieldId: string) => void;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="settings-account-dialog-fields">
|
<div className="settings-account-dialog-fields">
|
||||||
@@ -197,6 +244,31 @@ function AccountDialogFields({
|
|||||||
rows={4}
|
rows={4}
|
||||||
value={field.value}
|
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
|
<input
|
||||||
autoComplete={field.type === "password" ? "off" : undefined}
|
autoComplete={field.type === "password" ? "off" : undefined}
|
||||||
@@ -219,12 +291,16 @@ function AccountRow({
|
|||||||
row,
|
row,
|
||||||
selected,
|
selected,
|
||||||
busy,
|
busy,
|
||||||
actions
|
actions,
|
||||||
|
gridTemplateColumns,
|
||||||
|
minWidth
|
||||||
}: {
|
}: {
|
||||||
row: AccountRowViewModel;
|
row: AccountRowViewModel;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
actions: AccountWorkspaceActions;
|
actions: AccountWorkspaceActions;
|
||||||
|
gridTemplateColumns: string;
|
||||||
|
minWidth: number;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const selectRow = (): void => actions.onSelect(row.id);
|
const selectRow = (): void => actions.onSelect(row.id);
|
||||||
const onClick = (): void => selectRow();
|
const onClick = (): void => selectRow();
|
||||||
@@ -249,6 +325,7 @@ function AccountRow({
|
|||||||
onDoubleClick={() => actions.onEdit(row.id)}
|
onDoubleClick={() => actions.onEdit(row.id)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
role="row"
|
role="row"
|
||||||
|
style={{ gridTemplateColumns, minWidth }}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<span className="settings-account-column-enable" role="cell">
|
<span className="settings-account-column-enable" role="cell">
|
||||||
@@ -302,19 +379,61 @@ function syncAccountTableScroll(event: UIEvent<HTMLDivElement>): void {
|
|||||||
|
|
||||||
function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElement {
|
function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElement {
|
||||||
const selectedIds = new Set(model.selectedIds);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable aria-busy={model.busy} className="settings-account-table" label="Accounts">
|
<DataTable aria-busy={model.busy} className="settings-account-table" label="Accounts">
|
||||||
<DataTableHeader className="settings-account-table-header">
|
<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" />
|
<span aria-label="Aktiviert" className="settings-account-column-enable" role="columnheader" />
|
||||||
{ACCOUNT_COLUMNS.map((column) => (
|
{ACCOUNT_COLUMNS.map((column, index) => (
|
||||||
<span key={column} role="columnheader">
|
<span className="settings-account-resizable-header" key={column} role="columnheader">
|
||||||
{column === "Status" && actions.onStatusSort ? (
|
{column === "Status" && actions.onStatusSort ? (
|
||||||
<button className="settings-account-sort" onClick={actions.onStatusSort} type="button">
|
<button className="settings-account-sort" onClick={actions.onStatusSort} type="button">
|
||||||
{column}{model.statusSort === "desc" ? " ▼" : model.statusSort === "asc" ? " ▲" : ""}
|
{column}{model.statusSort === "desc" ? " ▼" : model.statusSort === "asc" ? " ▲" : ""}
|
||||||
</button>
|
</button>
|
||||||
) : column}
|
) : 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>
|
||||||
))}
|
))}
|
||||||
<span aria-label="Aktionen" className="settings-account-column-actions" role="columnheader" />
|
<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 ? (
|
) : model.rows.length === 0 ? (
|
||||||
<DataTableEmpty description="Füge einen Account hinzu, um Downloads über einen Anbieter zu starten." title="Noch keine Accounts" />
|
<DataTableEmpty description="Füge einen Account hinzu, um Downloads über einen Anbieter zu starten." title="Noch keine Accounts" />
|
||||||
) : model.rows.map((row) => cloneElement(
|
) : 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 }
|
{ key: row.id }
|
||||||
))}
|
))}
|
||||||
</DataTableBody>
|
</DataTableBody>
|
||||||
@@ -337,7 +456,7 @@ function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElemen
|
|||||||
<div>
|
<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} 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 || 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>
|
<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>
|
</div>
|
||||||
<span>{model.rows.length} {model.rows.length === 1 ? "Account" : "Accounts"}</span>
|
<span>{model.rows.length} {model.rows.length === 1 ? "Account" : "Accounts"}</span>
|
||||||
@@ -646,11 +765,18 @@ export function AccountEditDialog({
|
|||||||
<span>{model.hoster} · {model.mode}</span>
|
<span>{model.hoster} · {model.mode}</span>
|
||||||
<strong className="settings-copyable">{model.identity}</strong>
|
<strong className="settings-copyable">{model.identity}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<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">
|
<label className="settings-rule-toggle settings-account-edit-enabled">
|
||||||
<input checked={model.enabled} disabled={model.busy} onChange={actions.onToggleEnabled} type="checkbox" />
|
<input checked={model.enabled} disabled={model.busy} onChange={actions.onToggleEnabled} type="checkbox" />
|
||||||
<span>Account aktiviert</span>
|
<span>Account aktiviert</span>
|
||||||
</label>
|
</label>
|
||||||
<AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} />
|
</div>
|
||||||
{model.error ? <p className="settings-account-dialog-error" role="alert">{model.error}</p> : null}
|
{model.error ? <p className="settings-account-dialog-error" role="alert">{model.error}</p> : null}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,54 @@ export const ACCOUNT_COLUMNS = [
|
|||||||
"Passwort/Zugang"
|
"Passwort/Zugang"
|
||||||
] as const;
|
] 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 {
|
export function getSettingsSaveLabel(state: SettingsSaveState): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "dirty":
|
case "dirty":
|
||||||
|
|||||||
@@ -557,8 +557,6 @@
|
|||||||
.settings-account-table-grid,
|
.settings-account-table-grid,
|
||||||
.settings-account-row {
|
.settings-account-row {
|
||||||
display: grid;
|
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;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,6 +579,45 @@
|
|||||||
padding: 0 11px;
|
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 {
|
.settings-account-table-body {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
@@ -628,6 +665,10 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-account-column-actions {
|
||||||
|
padding-right: 18px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-account-hoster {
|
.settings-account-hoster {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -999,6 +1040,48 @@
|
|||||||
gap: 7px;
|
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 {
|
.settings-account-dialog-textarea {
|
||||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
}
|
}
|
||||||
@@ -1037,6 +1120,11 @@
|
|||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-account-edit-enabled-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1366px) {
|
@media (max-width: 1366px) {
|
||||||
.settings-view {
|
.settings-view {
|
||||||
grid-template-columns: 56px minmax(0, 1fr);
|
grid-template-columns: 56px minmax(0, 1fr);
|
||||||
@@ -1050,11 +1138,6 @@
|
|||||||
padding: 20px;
|
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) {
|
@media (max-width: 1120px) {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export const IPC_CHANNELS = {
|
|||||||
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
|
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
|
||||||
CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts",
|
CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts",
|
||||||
CHECK_ACCOUNT_CREDENTIALS: "app:check-account-credentials",
|
CHECK_ACCOUNT_CREDENTIALS: "app:check-account-credentials",
|
||||||
|
REVEAL_ACCOUNT_SECRET: "app:reveal-account-secret",
|
||||||
RETRY_EXTRACTION: "queue:retry-extraction",
|
RETRY_EXTRACTION: "queue:retry-extraction",
|
||||||
EXTRACT_NOW: "queue:extract-now",
|
EXTRACT_NOW: "queue:extract-now",
|
||||||
RESET_PACKAGE: "queue:reset-package",
|
RESET_PACKAGE: "queue:reset-package",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type {
|
|||||||
AccountCheckScope,
|
AccountCheckScope,
|
||||||
AccountCommandResult,
|
AccountCommandResult,
|
||||||
AccountCredentialCheckInput,
|
AccountCredentialCheckInput,
|
||||||
|
AccountSecretRequest,
|
||||||
|
AccountSecretResult,
|
||||||
AccountCreateCommand,
|
AccountCreateCommand,
|
||||||
AccountDeleteCommand,
|
AccountDeleteCommand,
|
||||||
AccountReplaceCommand,
|
AccountReplaceCommand,
|
||||||
@@ -103,6 +105,7 @@ export interface ElectronApi {
|
|||||||
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
|
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
|
||||||
checkDebridAccounts: (scope?: AccountCheckScope) => Promise<DebridAccountStatus[]>;
|
checkDebridAccounts: (scope?: AccountCheckScope) => Promise<DebridAccountStatus[]>;
|
||||||
checkAccountCredentials: (input: AccountCredentialCheckInput) => Promise<DebridAccountStatus>;
|
checkAccountCredentials: (input: AccountCredentialCheckInput) => Promise<DebridAccountStatus>;
|
||||||
|
revealAccountSecret: (input: AccountSecretRequest) => Promise<AccountSecretResult>;
|
||||||
retryExtraction: (packageId: string) => Promise<void>;
|
retryExtraction: (packageId: string) => Promise<void>;
|
||||||
extractNow: (packageId: string) => Promise<void>;
|
extractNow: (packageId: string) => Promise<void>;
|
||||||
resetPackage: (packageId: string) => Promise<void>;
|
resetPackage: (packageId: string) => Promise<void>;
|
||||||
|
|||||||
@@ -347,6 +347,15 @@ export interface AccountCredentialCheckInput {
|
|||||||
secret?: string;
|
secret?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AccountSecretRequest {
|
||||||
|
kind: RendererAccountKind;
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountSecretResult {
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DownloadItem {
|
export interface DownloadItem {
|
||||||
id: string;
|
id: string;
|
||||||
packageId: string;
|
packageId: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { applyAccountCommand, validateAccountCommand, validateAccountCredentialCheckInput } from "../src/main/account-commands";
|
import { applyAccountCommand, validateAccountCommand, validateAccountCredentialCheckInput } from "../src/main/account-commands";
|
||||||
|
import * as accountCommands from "../src/main/account-commands";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
import { defaultSettings } from "../src/main/constants";
|
||||||
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
|
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
|
||||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||||
@@ -42,6 +43,41 @@ const ACCOUNT_KINDS: RendererAccountKind[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe("write-only account commands", () => {
|
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) => {
|
it.each(["realdebrid-api", "realdebrid-web"] as const)("accepts %s credential checks at the IPC boundary", (kind) => {
|
||||||
expect(validateAccountCredentialCheckInput({ kind, accountId: "svc-realdebrid" })).toEqual({
|
expect(validateAccountCredentialCheckInput({ kind, accountId: "svc-realdebrid" })).toEqual({
|
||||||
kind,
|
kind,
|
||||||
|
|||||||
@@ -73,4 +73,18 @@ describe("account preload contract", () => {
|
|||||||
[IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, "all"]
|
[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 AccountRowSource,
|
||||||
type SettingsFormViewModel
|
type SettingsFormViewModel
|
||||||
} from "../src/renderer/views/settings/settings-model";
|
} from "../src/renderer/views/settings/settings-model";
|
||||||
|
import * as settingsModel from "../src/renderer/views/settings/settings-model";
|
||||||
import {
|
import {
|
||||||
AccountAddDialog,
|
AccountAddDialog,
|
||||||
AccountEditDialog,
|
AccountEditDialog,
|
||||||
@@ -729,6 +730,9 @@ describe("account workspace", () => {
|
|||||||
expect(html).not.toContain("test-token");
|
expect(html).not.toContain("test-token");
|
||||||
expect(html).not.toContain("table-pagination");
|
expect(html).not.toContain("table-pagination");
|
||||||
expect(html).not.toContain("role=\"toolbar\"");
|
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", () => {
|
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."');
|
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", () => {
|
it("keeps row selection, enable toggles, edit and context actions separate", () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const tree = AccountWorkspace({
|
const tree = AccountWorkspace({
|
||||||
@@ -841,7 +877,9 @@ describe("account workspace", () => {
|
|||||||
onCheck: () => {},
|
onCheck: () => {},
|
||||||
onSave: () => {},
|
onSave: () => {},
|
||||||
onRemove: () => {},
|
onRemove: () => {},
|
||||||
onToggleEnabled: () => {}
|
onToggleEnabled: () => {},
|
||||||
|
onToggleSecret: () => {},
|
||||||
|
onCopySecret: () => {}
|
||||||
}}
|
}}
|
||||||
model={{
|
model={{
|
||||||
open: true,
|
open: true,
|
||||||
@@ -851,8 +889,9 @@ describe("account workspace", () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
fields: [
|
fields: [
|
||||||
{ id: "login", label: "Login", type: "text", value: "member@example.test" },
|
{ id: "login", label: "Login", type: "text", value: "member@example.test" },
|
||||||
{ id: "password", label: "Passwort", type: "password", value: "test-password" },
|
{ id: "password", label: "Passwort", type: "password", value: "", storedSecret: true, secretVisible: false },
|
||||||
{ id: "token", label: "Token", type: "password", value: "test-token" }
|
{ id: "token", label: "Token", type: "password", value: "", storedSecret: true, secretVisible: false },
|
||||||
|
{ id: "dailyLimitGb", label: "Tageslimit (GB, optional)", type: "number", value: "" }
|
||||||
],
|
],
|
||||||
error: "",
|
error: "",
|
||||||
busy: false
|
busy: false
|
||||||
@@ -884,6 +923,9 @@ describe("account workspace", () => {
|
|||||||
expect(editHtml).toContain("Prüfen");
|
expect(editHtml).toContain("Prüfen");
|
||||||
expect(count(addHtml, "type=\"password\"")).toBe(1);
|
expect(count(addHtml, "type=\"password\"")).toBe(1);
|
||||||
expect(count(editHtml, "type=\"password\"")).toBe(2);
|
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", () => {
|
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) }),
|
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) }),
|
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) }),
|
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) }),
|
deleteAccount: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }),
|
||||||
addLinks: async () => ({ addedPackages: 0, addedLinks: 0, invalidCount: 0 }),
|
addLinks: async () => ({ addedPackages: 0, addedLinks: 0, invalidCount: 0 }),
|
||||||
addContainers: async () => ({ addedPackages: 0, addedLinks: 0 }),
|
addContainers: async () => ({ addedPackages: 0, addedLinks: 0 }),
|
||||||
|
|||||||
Reference in New Issue
Block a user