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:
Sucukdeluxe
2026-08-15 01:36:04 +02:00
parent 479c2b600a
commit 8a112747fc
16 changed files with 552 additions and 63 deletions
+171 -45
View File
@@ -1,9 +1,10 @@
import {
cloneElement,
type DragEvent,
type KeyboardEvent,
type MouseEvent,
type ReactElement,
import {
cloneElement,
type DragEvent,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
type ReactElement,
type UIEvent
} from "react";
import { SlidingSelection } from "../../ui/SlidingSelection";
@@ -16,11 +17,18 @@ import {
import { Dialog } from "../../ui/Dialog";
import {
ACCOUNT_COLUMNS,
ACCOUNT_TABLE_COLUMN_IDS,
createAccountTableColumnWidths,
getAccountTableGridTemplate,
getAccountTableMinWidth,
getSettingsSelectNavigationIndex,
resizeAccountTableColumn,
type AccountAddFilter,
type AccountAddOption,
type AccountRowViewModel
} from "./settings-model";
type AccountAddOption,
type AccountRowViewModel,
type AccountTableColumnId,
type AccountTableColumnWidths
} from "./settings-model";
export type AccountWorkspacePanel = "overview" | "rules";
@@ -29,6 +37,36 @@ const ACCOUNT_WORKSPACE_PANELS: readonly { id: AccountWorkspacePanel; label: str
{ id: "rules", label: "Verwendungsregeln" }
];
const ACCOUNT_TABLE_COLUMN_STORAGE_KEY = "mdd.account-table-columns.v1";
let accountTableResizeSession: { column: AccountTableColumnId; startX: number; initial: AccountTableColumnWidths } | null = null;
function loadAccountTableColumnWidths(): AccountTableColumnWidths {
try {
const stored = typeof window === "undefined" ? null : window.localStorage.getItem(ACCOUNT_TABLE_COLUMN_STORAGE_KEY);
return createAccountTableColumnWidths(stored ? JSON.parse(stored) : undefined);
} catch {
return createAccountTableColumnWidths();
}
}
function applyAccountTableColumnWidths(source: HTMLElement, widths: AccountTableColumnWidths): void {
const table = source.closest(".settings-account-table");
if (!table) return;
const template = getAccountTableGridTemplate(widths);
const minWidth = `${getAccountTableMinWidth(widths)}px`;
table.querySelectorAll<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");
@@ -128,13 +166,16 @@ export interface AccountWorkspaceProps {
actions: AccountWorkspaceActions;
}
export interface AccountDialogField {
export interface AccountDialogField {
id: string;
label: string;
type: "text" | "password" | "number" | "textarea";
value: string;
placeholder?: string;
help?: string;
help?: string;
storedSecret?: boolean;
secretVisible?: boolean;
secretBusy?: boolean;
}
export interface AccountAddDialogModel {
@@ -168,22 +209,28 @@ export interface AccountEditDialogModel {
busy: boolean;
}
export interface AccountEditDialogActions {
export interface AccountEditDialogActions {
onFieldChange: (fieldId: string, value: string) => void;
onClose: () => void;
onCheck: () => void;
onSave: () => void;
onRemove: () => void;
onToggleEnabled: () => void;
}
onToggleEnabled: () => void;
onToggleSecret: (fieldId: string) => void;
onCopySecret: (fieldId: string) => void;
}
function AccountDialogFields({
fields,
onChange
}: {
fields: readonly AccountDialogField[];
onChange: (fieldId: string, value: string) => void;
}): ReactElement {
function AccountDialogFields({
fields,
onChange,
onToggleSecret,
onCopySecret
}: {
fields: readonly AccountDialogField[];
onChange: (fieldId: string, value: string) => void;
onToggleSecret?: (fieldId: string) => void;
onCopySecret?: (fieldId: string) => void;
}): ReactElement {
return (
<div className="settings-account-dialog-fields">
{fields.map((field) => (
@@ -197,7 +244,32 @@ 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}
className="settings-control"
@@ -215,16 +287,20 @@ function AccountDialogFields({
);
}
function AccountRow({
function AccountRow({
row,
selected,
busy,
actions
actions,
gridTemplateColumns,
minWidth
}: {
row: AccountRowViewModel;
selected: boolean;
busy: boolean;
actions: AccountWorkspaceActions;
actions: AccountWorkspaceActions;
gridTemplateColumns: string;
minWidth: number;
}): ReactElement {
const selectRow = (): void => actions.onSelect(row.id);
const onClick = (): void => selectRow();
@@ -248,8 +324,9 @@ function AccountRow({
onContextMenu={openContextMenu}
onDoubleClick={() => actions.onEdit(row.id)}
onKeyDown={onKeyDown}
role="row"
tabIndex={0}
role="row"
style={{ gridTemplateColumns, minWidth }}
tabIndex={0}
>
<span className="settings-account-column-enable" role="cell">
<input
@@ -300,22 +377,64 @@ function syncAccountTableScroll(event: UIEvent<HTMLDivElement>): void {
}
}
function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElement {
const selectedIds = new Set(model.selectedIds);
return (
function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElement {
const selectedIds = new Set(model.selectedIds);
const columnWidths = loadAccountTableColumnWidths();
const gridTemplateColumns = getAccountTableGridTemplate(columnWidths);
const minWidth = getAccountTableMinWidth(columnWidths);
const beginResize = (event: PointerEvent<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">
<span aria-label="Aktiviert" className="settings-account-column-enable" role="columnheader" />
{ACCOUNT_COLUMNS.map((column) => (
<span key={column} role="columnheader">
{column === "Status" && actions.onStatusSort ? (
<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, 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}
</span>
) : 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" />
</div>
@@ -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>
@@ -519,7 +638,7 @@ export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): Rea
id="settings-account-overview"
role="tabpanel"
>
{AccountOverview({ actions, model })}
{AccountOverview({ actions, model })}
</div>
<div
aria-labelledby="settings-account-rules-tab"
@@ -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":
+90 -7
View File
@@ -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) {