feat(cert-manager): Schlüssel, PFX und CSR erkennen, Passwort je Datei
- Private Schlüssel (PKCS#1, PKCS#8, SEC1; PEM und DER; unverschlüsselt, verschlüsselt, klassisch verschlüsselt) für RSA und EC über node:crypto - PKCS#12 lesen (OpenSSL 3, kompatibel, RC2; EC-Zertifikate und -Schlüssel), auch ohne Endung und im ZIP - Zertifikatsanfragen (CSR) als PEM und DER mit Inhaber, SAN und Schlüssel - Zuordnung von Schlüssel und Anfrage zum Zertifikat (checkPrivateKey, SPKI-Vergleich) - Feld passwords je Datei, gesperrte Dateien fragen nach dem Passwort; kein Passwort in Antwort oder Log - Oberfläche: Passwortfeld mit Anzeigen/Verbergen, Schlüssel- und Anfragekarten im Reiter Analysieren Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import de from '@/messages/de.json';
|
||||
import type { AnalysisResult, CertItem, ChainInfo } from '../actions';
|
||||
import type { AnalysisResult, CertItem, ChainInfo, CsrItem, KeyItem } from '../actions';
|
||||
import type { CertWorkspace } from '../use-cert-workspace';
|
||||
import type { WorkingEntry } from '../working-set';
|
||||
import { AnalyzeTab } from './AnalyzeTab';
|
||||
@@ -69,6 +69,7 @@ function workspace(
|
||||
errorKey: null,
|
||||
addFiles: () => [],
|
||||
addText: () => null,
|
||||
setPassword: () => {},
|
||||
remove: () => {},
|
||||
clear: () => {},
|
||||
retry: () => {},
|
||||
@@ -229,3 +230,134 @@ describe('AnalyzeTab', () => {
|
||||
expect(container.innerHTML).not.toContain('uppercase');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnalyzeTab: Schluessel, Anfragen, gesperrte Dateien', () => {
|
||||
const keyed = cert({
|
||||
id: 'c-k',
|
||||
cn: 'www.example.test',
|
||||
role: 'end-entity',
|
||||
keyId: 'k-1',
|
||||
csrIds: ['r-1'],
|
||||
});
|
||||
const key: KeyItem = {
|
||||
id: 'k-1',
|
||||
kind: 'privateKey',
|
||||
sources: [{ file: 0, path: 'key.pem' }],
|
||||
pem: 'PEM-KEY',
|
||||
baseName: 'schluessel',
|
||||
keyType: 'RSA',
|
||||
keyBits: 2048,
|
||||
curve: null,
|
||||
wasEncrypted: true,
|
||||
certIds: ['c-k'],
|
||||
};
|
||||
const csr: CsrItem = {
|
||||
id: 'r-1',
|
||||
kind: 'csr',
|
||||
sources: [{ file: 1, path: 'www.csr' }],
|
||||
pem: 'PEM-CSR',
|
||||
baseName: 'www.example.test',
|
||||
cn: 'www.example.test',
|
||||
organization: 'Tessera Test',
|
||||
san: ['www.example.test', 'example.test'],
|
||||
keyType: 'RSA',
|
||||
keyBits: 2048,
|
||||
curve: null,
|
||||
keyId: 'k-1',
|
||||
certIds: ['c-k'],
|
||||
};
|
||||
const analysis: AnalysisResult = {
|
||||
items: [keyed, key, csr],
|
||||
chains: [],
|
||||
locked: [
|
||||
{ file: 2, path: 'vendor.pfx', container: 'pkcs12', reason: 'passwordNeeded' },
|
||||
{ file: 3, path: 'zweiter.pfx', container: 'pkcs12', reason: 'passwordWrong' },
|
||||
],
|
||||
ignored: [],
|
||||
};
|
||||
const entries = [entry('e1', 'key.pem'), entry('e2', 'www.csr')];
|
||||
|
||||
it('Zertifikat mit passendem Schluessel traegt den Hinweis, ohne Schluessel nicht', () => {
|
||||
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||
const [card] = screen.getAllByTestId('cert-card');
|
||||
expect(within(card).getByText('Passender Schlüssel vorhanden')).toBeInTheDocument();
|
||||
cleanup();
|
||||
render(
|
||||
<AnalyzeTab
|
||||
workspace={workspace(
|
||||
{
|
||||
items: [cert({ id: 'c-n', cn: 'ohne.example.test', role: 'end-entity' })],
|
||||
chains: [],
|
||||
locked: [],
|
||||
ignored: [],
|
||||
},
|
||||
entries,
|
||||
['e1'],
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Passender Schlüssel vorhanden')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Schluesselkarte: Art, Groesse, „War verschlüsselt“, zu welchem Zertifikat er gehoert', () => {
|
||||
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||
const card = screen.getByTestId('key-card');
|
||||
expect(within(card).getByText('Privater Schlüssel')).toBeInTheDocument();
|
||||
expect(within(card).getByText('RSA, 2048 Bit')).toBeInTheDocument();
|
||||
expect(within(card).getByText('War verschlüsselt')).toBeInTheDocument();
|
||||
expect(within(card).getByText('www.example.test')).toBeInTheDocument();
|
||||
expect(card).toHaveTextContent('key.pem');
|
||||
});
|
||||
|
||||
it('Schluessel ohne Zertifikat sagt es ruhig', () => {
|
||||
const lonely: AnalysisResult = {
|
||||
items: [{ ...key, certIds: [] }],
|
||||
chains: [],
|
||||
locked: [],
|
||||
ignored: [],
|
||||
};
|
||||
render(<AnalyzeTab workspace={workspace(lonely, entries, ['e1'])} />);
|
||||
expect(screen.getByText('Dazu liegt noch kein Zertifikat vor.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Anfragekarte: Inhaber, Namen, Schluessel, Zuordnung', () => {
|
||||
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||
const card = screen.getByTestId('csr-card');
|
||||
expect(within(card).getByText('Zertifikatsanfrage (CSR)')).toBeInTheDocument();
|
||||
expect(within(card).getByText('Tessera Test')).toBeInTheDocument();
|
||||
expect(within(card).getByText('www.example.test, example.test')).toBeInTheDocument();
|
||||
expect(within(card).getByText('RSA, 2048 Bit')).toBeInTheDocument();
|
||||
expect(within(card).getByText('Passender Schlüssel').nextSibling).toHaveTextContent(
|
||||
'vorhanden',
|
||||
);
|
||||
});
|
||||
|
||||
it('die Zusammenfassung nennt gesperrte Dateien und verweist auf den Reiter Dateien', () => {
|
||||
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||
const region = screen.getByRole('region', { name: 'Geschützte Dateien' });
|
||||
const rows = within(region).getAllByRole('listitem');
|
||||
expect(rows[0]).toHaveTextContent('vendor.pfx braucht ein Passwort');
|
||||
expect(rows[1]).toHaveTextContent('zweiter.pfx Das Passwort passt nicht');
|
||||
expect(region).toHaveTextContent('im Reiter „Dateien“');
|
||||
});
|
||||
|
||||
it('nur gesperrte Dateien: kein „nichts erkannt“, sondern die Zusammenfassung', () => {
|
||||
render(
|
||||
<AnalyzeTab
|
||||
workspace={workspace({ items: [], chains: [], locked: analysis.locked, ignored: [] })}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText('In den Dateien wurde noch kein Zertifikat erkannt.'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Geschützte Dateien' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('kein Passwort und kein Schluesselinhalt im Text', () => {
|
||||
const { container } = render(
|
||||
<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />,
|
||||
);
|
||||
expect(container.textContent).not.toContain('PEM-KEY');
|
||||
expect(container.textContent).not.toMatch(/[→←·]/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { CertItem } from '../actions';
|
||||
import type { CertItem, CsrItem, KeyItem } from '../actions';
|
||||
import type { CertWorkspace } from '../use-cert-workspace';
|
||||
import { ChainView } from './ChainView';
|
||||
import { ItemCard } from './ItemCard';
|
||||
@@ -27,8 +27,14 @@ export function AnalyzeTab({ workspace }: AnalyzeTabProps) {
|
||||
}
|
||||
|
||||
const certs = analysis.items.filter((i): i is CertItem => i.kind === 'certificate');
|
||||
const keys = analysis.items.filter((i): i is KeyItem => i.kind === 'privateKey');
|
||||
const csrs = analysis.items.filter((i): i is CsrItem => i.kind === 'csr');
|
||||
|
||||
if (certs.length === 0 && analysis.ignored.length === 0) {
|
||||
if (
|
||||
analysis.items.length === 0 &&
|
||||
analysis.ignored.length === 0 &&
|
||||
analysis.locked.length === 0
|
||||
) {
|
||||
return <p className="text-sm text-muted-foreground">{t('analyze.nothingFound')}</p>;
|
||||
}
|
||||
|
||||
@@ -52,12 +58,69 @@ export function AnalyzeTab({ workspace }: AnalyzeTabProps) {
|
||||
</h2>
|
||||
<ul className="space-y-4">
|
||||
{certs.map((cert) => (
|
||||
<ItemCard key={cert.id} cert={cert} entries={entries} analysisIds={analysisIds} />
|
||||
<ItemCard
|
||||
key={cert.id}
|
||||
item={cert}
|
||||
items={analysis.items}
|
||||
entries={entries}
|
||||
analysisIds={analysisIds}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{keys.length > 0 && (
|
||||
<section className="space-y-3" aria-label={t('analyze.keysTitle')}>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t('analyze.keysTitle')}</h2>
|
||||
<ul className="space-y-4">
|
||||
{keys.map((key) => (
|
||||
<ItemCard
|
||||
key={key.id}
|
||||
item={key}
|
||||
items={analysis.items}
|
||||
entries={entries}
|
||||
analysisIds={analysisIds}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{csrs.length > 0 && (
|
||||
<section className="space-y-3" aria-label={t('analyze.csrsTitle')}>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t('analyze.csrsTitle')}</h2>
|
||||
<ul className="space-y-4">
|
||||
{csrs.map((csr) => (
|
||||
<ItemCard
|
||||
key={csr.id}
|
||||
item={csr}
|
||||
items={analysis.items}
|
||||
entries={entries}
|
||||
analysisIds={analysisIds}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{analysis.locked.length > 0 && (
|
||||
<section className="space-y-2" aria-label={t('analyze.lockedTitle')}>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t('analyze.lockedTitle')}</h2>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
{analysis.locked.map((l) => (
|
||||
<li key={`${l.file}-${l.path}-${l.container}`}>
|
||||
<span className="break-all text-foreground">{l.path}</span>{' '}
|
||||
{l.reason === 'passwordWrong'
|
||||
? t('analyze.lockedWrong')
|
||||
: t('analyze.lockedNeeded')}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground">{t('analyze.lockedHint')}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{analysis.ignored.length > 0 && (
|
||||
<section className="space-y-2" aria-label={t('analyze.ignoredTitle')}>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t('analyze.ignoredTitle')}</h2>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import de from '@/messages/de.json';
|
||||
import type { AnalysisResult, CertItem } from '../actions';
|
||||
import type { AnalysisResult, CertItem, CsrItem, KeyItem } from '../actions';
|
||||
import { CertManagerRequestError } from '../actions';
|
||||
import { useCertWorkspace } from '../use-cert-workspace';
|
||||
import { FilesTab } from './FilesTab';
|
||||
@@ -290,4 +290,180 @@ describe('FilesTab', () => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('länger als');
|
||||
expect(mockAnalyze).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('geschuetzte Dateien', () => {
|
||||
const locked = (over: Partial<AnalysisResult['locked'][number]> = {}) => ({
|
||||
file: 0,
|
||||
path: 'server.pfx',
|
||||
container: 'pkcs12' as const,
|
||||
reason: 'passwordNeeded' as const,
|
||||
...over,
|
||||
});
|
||||
const keyItem = (): KeyItem => ({
|
||||
id: 'k-1',
|
||||
kind: 'privateKey',
|
||||
sources: [{ file: 0, path: 'key.pem' }],
|
||||
pem: '',
|
||||
baseName: 'schluessel',
|
||||
keyType: 'EC',
|
||||
keyBits: 256,
|
||||
curve: 'P-256',
|
||||
wasEncrypted: true,
|
||||
certIds: [],
|
||||
});
|
||||
const csrItem = (): CsrItem => ({
|
||||
id: 'r-1',
|
||||
kind: 'csr',
|
||||
sources: [{ file: 0, path: 'req.csr' }],
|
||||
pem: '',
|
||||
baseName: 'anfrage',
|
||||
cn: 'www.example.test',
|
||||
organization: '',
|
||||
san: [],
|
||||
keyType: 'RSA',
|
||||
keyBits: 2048,
|
||||
curve: null,
|
||||
keyId: null,
|
||||
certIds: [],
|
||||
});
|
||||
|
||||
it('zeigt den Hinweis und ein Passwortfeld; „Entsperren“ sendet das Passwort in der Analyse', async () => {
|
||||
mockAnalyze
|
||||
.mockResolvedValueOnce({
|
||||
items: [],
|
||||
chains: [],
|
||||
locked: [locked()],
|
||||
ignored: [],
|
||||
} satisfies AnalysisResult)
|
||||
.mockResolvedValueOnce({
|
||||
items: [cert('c-srv', 'www.example.test', 'end-entity', [0], ['server.pfx'])],
|
||||
chains: [],
|
||||
locked: [],
|
||||
ignored: [],
|
||||
} satisfies AnalysisResult);
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('server.pfx')]);
|
||||
expect(
|
||||
await screen.findByText('„server.pfx“ ist mit einem Passwort geschützt.'),
|
||||
).toBeInTheDocument();
|
||||
const field = screen.getByLabelText('Passwort für „server.pfx“') as HTMLInputElement;
|
||||
expect(field.type).toBe('password');
|
||||
expect(field).toHaveAttribute('autocomplete', 'off');
|
||||
const unlock = screen.getByRole('button', { name: 'Entsperren' });
|
||||
expect(unlock).toBeDisabled();
|
||||
fireEvent.change(field, { target: { value: 'geheim' } });
|
||||
fireEvent.click(unlock);
|
||||
await screen.findByText('www.example.test');
|
||||
const sent = mockAnalyze.mock.calls.at(-1)?.[0] as { password: string }[];
|
||||
expect(sent.map((e) => e.password)).toEqual(['geheim']);
|
||||
expect(screen.queryByLabelText('Passwort für „server.pfx“')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Enter im Feld entsperrt ebenfalls', async () => {
|
||||
mockAnalyze.mockResolvedValue({
|
||||
items: [],
|
||||
chains: [],
|
||||
locked: [locked()],
|
||||
ignored: [],
|
||||
} satisfies AnalysisResult);
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('server.pfx')]);
|
||||
const field = await screen.findByLabelText('Passwort für „server.pfx“');
|
||||
fireEvent.change(field, { target: { value: 'geheim' } });
|
||||
fireEvent.submit(field.closest('form') as HTMLFormElement);
|
||||
await waitFor(() => {
|
||||
const last = mockAnalyze.mock.calls.at(-1)?.[0] as { password: string }[];
|
||||
expect(last[0].password).toBe('geheim');
|
||||
});
|
||||
});
|
||||
|
||||
it('ein falsches Passwort wird als solches gemeldet, das Feld bleibt', async () => {
|
||||
mockAnalyze.mockResolvedValueOnce({
|
||||
items: [],
|
||||
chains: [],
|
||||
locked: [locked()],
|
||||
ignored: [],
|
||||
} satisfies AnalysisResult);
|
||||
mockAnalyze.mockResolvedValueOnce({
|
||||
items: [],
|
||||
chains: [],
|
||||
locked: [locked({ reason: 'passwordWrong' })],
|
||||
ignored: [],
|
||||
} satisfies AnalysisResult);
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('server.pfx')]);
|
||||
const field = await screen.findByLabelText('Passwort für „server.pfx“');
|
||||
fireEvent.change(field, { target: { value: 'falsch' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Entsperren' }));
|
||||
expect(await screen.findByText('Das Passwort passt nicht.')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Passwort für „server.pfx“')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('der Knopf zeigt und verbirgt das Passwort', async () => {
|
||||
mockAnalyze.mockResolvedValue({
|
||||
items: [],
|
||||
chains: [],
|
||||
locked: [locked()],
|
||||
ignored: [],
|
||||
} satisfies AnalysisResult);
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('server.pfx')]);
|
||||
const field = (await screen.findByLabelText('Passwort für „server.pfx“')) as HTMLInputElement;
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Passwort anzeigen' }));
|
||||
expect(field.type).toBe('text');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Passwort verbergen' }));
|
||||
expect(field.type).toBe('password');
|
||||
});
|
||||
|
||||
it('eine gesperrte PFX bei vorhandenem Zertifikat samt Schluessel: ruhiger Hinweis, Feld erst nach „Trotzdem entsperren“', async () => {
|
||||
const withKey = {
|
||||
...cert('c-srv', 'www.example.test', 'end-entity', [0], ['leaf.pem']),
|
||||
keyId: 'k-1',
|
||||
};
|
||||
mockAnalyze.mockResolvedValue({
|
||||
items: [withKey, keyItem()],
|
||||
chains: [],
|
||||
locked: [locked({ file: 1, path: 'server.pfx' })],
|
||||
ignored: [],
|
||||
} satisfies AnalysisResult);
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('leaf.pem'), makeFile('server.pfx')]);
|
||||
expect(await screen.findByText(/Sie brauchen die Datei nicht/)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Passwort für „server.pfx“')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Trotzdem entsperren' }));
|
||||
expect(screen.getByLabelText('Passwort für „server.pfx“')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Schluessel und Anfragen stehen je Eintrag wie Zertifikate, ohne Passwort im Text', async () => {
|
||||
mockAnalyze.mockResolvedValue({
|
||||
items: [keyItem(), csrItem()],
|
||||
chains: [],
|
||||
locked: [],
|
||||
ignored: [],
|
||||
} satisfies AnalysisResult);
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('key.pem'), makeFile('req.csr')]);
|
||||
expect(await screen.findByText('Privater Schlüssel')).toBeInTheDocument();
|
||||
expect(screen.getByText('EC, Kurve P-256')).toBeInTheDocument();
|
||||
expect(screen.getByText('war verschlüsselt')).toBeInTheDocument();
|
||||
expect(screen.getByText('Zertifikatsanfrage (CSR)')).toBeInTheDocument();
|
||||
expect(screen.getByText('www.example.test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('kein dargestellter Text enthaelt das eingegebene Passwort', async () => {
|
||||
mockAnalyze.mockResolvedValue({
|
||||
items: [],
|
||||
chains: [],
|
||||
locked: [locked({ reason: 'passwordWrong' })],
|
||||
ignored: [],
|
||||
} satisfies AnalysisResult);
|
||||
const { container } = render(<Harness />);
|
||||
selectFiles([makeFile('server.pfx')]);
|
||||
const field = await screen.findByLabelText('Passwort für „server.pfx“');
|
||||
fireEvent.change(field, { target: { value: 'geheim-xyz' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Entsperren' }));
|
||||
await waitFor(() => expect(mockAnalyze).toHaveBeenCalledTimes(2));
|
||||
expect(container.textContent).not.toContain('geheim-xyz');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import type { AnyItem, IgnoredEntry } from '../actions';
|
||||
import type { AnyItem, IgnoredEntry, LockedEntry } from '../actions';
|
||||
import type { CertWorkspace } from '../use-cert-workspace';
|
||||
import {
|
||||
formatBytes,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type RejectedFile,
|
||||
type WorkingEntry,
|
||||
} from '../working-set';
|
||||
import { PasswordInput } from './PasswordInput';
|
||||
|
||||
/** Farben der Rollenmarken (wie in der frueheren Uebersicht). */
|
||||
export const ROLE_STYLES: Record<string, string> = {
|
||||
@@ -48,8 +49,86 @@ function roleKey(item: AnyItem): string {
|
||||
return item.kind === 'certificate' ? item.role : item.kind;
|
||||
}
|
||||
|
||||
function itemName(item: AnyItem): string {
|
||||
return item.kind === 'privateKey' ? item.baseName : item.cn || item.baseName;
|
||||
function keyDescription(
|
||||
item: Extract<AnyItem, { kind: 'privateKey' }>,
|
||||
t: ReturnType<typeof useTranslations>,
|
||||
): string {
|
||||
if (item.keyType === 'RSA') return t('analyze.keyRsa', { bits: item.keyBits ?? 0 });
|
||||
if (item.keyType === 'EC') return t('analyze.keyEc', { curve: item.curve ?? '' });
|
||||
return item.keyType;
|
||||
}
|
||||
|
||||
function itemName(item: AnyItem, t: ReturnType<typeof useTranslations>): string {
|
||||
return item.kind === 'privateKey' ? keyDescription(item, t) : item.cn || item.baseName;
|
||||
}
|
||||
|
||||
interface UnlockPanelProps {
|
||||
entry: WorkingEntry;
|
||||
locked: LockedEntry[];
|
||||
/** Die Datei wird nicht gebraucht (Zertifikat und Schluessel liegen schon vor): ruhiger Hinweis statt Feld */
|
||||
optional: boolean;
|
||||
onUnlock: (password: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gesperrte Datei: sagt, welche Teile ein Passwort brauchen, und nimmt es entgegen (Enter oder
|
||||
* „Entsperren“). Ist die Datei entbehrlich, steht zuerst nur ein ruhiger Hinweis mit „Trotzdem entsperren“.
|
||||
*/
|
||||
function UnlockPanel({ entry, locked, optional, onUnlock }: UnlockPanelProps) {
|
||||
const t = useTranslations('certManager');
|
||||
const [value, setValue] = useState('');
|
||||
const [forced, setForced] = useState(false);
|
||||
const wrong = locked.some((l) => l.reason === 'passwordWrong');
|
||||
const calm = optional && !forced;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mt-2 rounded-lg p-3 text-sm ${calm ? 'bg-muted text-muted-foreground' : 'border border-border bg-muted'}`}
|
||||
data-testid="cert-locked"
|
||||
>
|
||||
<ul className="space-y-0.5">
|
||||
{locked.map((l) => (
|
||||
<li key={`${l.path}-${l.container}`} className={calm ? '' : 'text-foreground'}>
|
||||
{t(calm ? 'files.locked.optional' : 'files.locked.message', { path: l.path })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{calm ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForced(true)}
|
||||
className="mt-2 rounded border border-border px-3 py-1 text-foreground hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{t('files.locked.unlockAnyway')}
|
||||
</button>
|
||||
) : (
|
||||
<form
|
||||
className="mt-2 space-y-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (value !== '') onUnlock(value);
|
||||
}}
|
||||
>
|
||||
<PasswordInput
|
||||
label={t('files.locked.passwordLabel', { name: entry.label })}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button type="submit" disabled={value === ''} className="btn btn-secondary">
|
||||
{t('files.locked.unlock')}
|
||||
</button>
|
||||
{wrong && entry.password !== '' && (
|
||||
<span className="text-sm text-status-down-fg" aria-live="polite">
|
||||
{t('files.locked.wrong')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('files.locked.note')}</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Was in einer Datei (bei ZIP: unter einem Pfad im ZIP) erkannt oder uebersprungen wurde. */
|
||||
@@ -113,6 +192,14 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
||||
return [...groups.values()].sort((a, b) => a.path.localeCompare(b.path));
|
||||
};
|
||||
|
||||
const lockedOf = (entry: WorkingEntry): LockedEntry[] =>
|
||||
(analysis?.locked ?? []).filter((l) => analysisIds[l.file] === entry.id);
|
||||
|
||||
// Liegt schon ein Serverzertifikat samt passendem Schluessel vor, ist eine gesperrte PFX entbehrlich.
|
||||
const haveCertWithKey = (analysis?.items ?? []).some(
|
||||
(i) => i.kind === 'certificate' && i.role === 'end-entity' && i.keyId !== null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
@@ -251,6 +338,10 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
||||
<ul className="space-y-2">
|
||||
{entries.map((entry) => {
|
||||
const groups = groupsOf(entry);
|
||||
const lockedHere = lockedOf(entry);
|
||||
const optional =
|
||||
haveCertWithKey &&
|
||||
lockedHere.every((l) => l.container === 'pkcs12' && l.reason === 'passwordNeeded');
|
||||
const analysed = analysisIds.includes(entry.id);
|
||||
return (
|
||||
<li key={entry.id} className="rounded-lg border border-border p-3">
|
||||
@@ -285,7 +376,12 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
||||
>
|
||||
{t(`roles.${roleKey(item)}`)}
|
||||
</span>
|
||||
<span className="break-all text-foreground">{itemName(item)}</span>
|
||||
<span className="break-all text-foreground">{itemName(item, t)}</span>
|
||||
{item.kind === 'privateKey' && item.wasEncrypted && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('files.keyWasEncrypted')}
|
||||
</span>
|
||||
)}
|
||||
{item.kind === 'certificate' && item.isExpired && (
|
||||
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-300">
|
||||
{t('files.expired')}
|
||||
@@ -308,6 +404,14 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
||||
<li className="text-sm text-muted-foreground">{t('files.checking')}</li>
|
||||
)}
|
||||
</ul>
|
||||
{lockedHere.length > 0 && (
|
||||
<UnlockPanel
|
||||
entry={entry}
|
||||
locked={lockedHere}
|
||||
optional={optional}
|
||||
onUnlock={(password) => workspace.setPassword(entry.id, password)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import type { CertItem, ItemSource } from '../actions';
|
||||
import type { AnyItem, CertItem, CsrItem, ItemSource, KeyItem } from '../actions';
|
||||
import type { WorkingEntry } from '../working-set';
|
||||
import { ROLE_STYLES } from './FilesTab';
|
||||
|
||||
interface ItemCardProps {
|
||||
cert: CertItem;
|
||||
item: AnyItem;
|
||||
/** Alle erkannten Teile: Schluessel und Anfragen nennen damit ihre Zertifikate */
|
||||
items: AnyItem[];
|
||||
/** Eintraege des Arbeitsbereichs und ihre Kennungen zum Analysezeitpunkt, fuer die Namen der Quellen */
|
||||
entries: WorkingEntry[];
|
||||
analysisIds: string[];
|
||||
@@ -19,12 +21,129 @@ function sourceLabel(source: ItemSource, entries: WorkingEntry[], analysisIds: s
|
||||
return source.path;
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useTranslations>;
|
||||
|
||||
function keyDescription(
|
||||
item: { keyType: string; keyBits: number | null; curve: string | null },
|
||||
t: Translate,
|
||||
): string {
|
||||
if (item.keyType === 'RSA') return t('analyze.keyRsa', { bits: item.keyBits ?? 0 });
|
||||
if (item.keyType === 'EC') return t('analyze.keyEc', { curve: item.curve ?? '' });
|
||||
return item.keyType;
|
||||
}
|
||||
|
||||
/** Eine Karte je erkanntem Teil im Reiter „Analysieren“: Zertifikat, privater Schluessel oder Anfrage. */
|
||||
export function ItemCard({ item, items, entries, analysisIds }: ItemCardProps) {
|
||||
if (item.kind === 'certificate') {
|
||||
return <CertCard cert={item} entries={entries} analysisIds={analysisIds} />;
|
||||
}
|
||||
if (item.kind === 'privateKey') {
|
||||
return <KeyCard item={item} items={items} entries={entries} analysisIds={analysisIds} />;
|
||||
}
|
||||
return <CsrCard item={item} items={items} entries={entries} analysisIds={analysisIds} />;
|
||||
}
|
||||
|
||||
function certNames(ids: string[], items: AnyItem[]): string[] {
|
||||
return ids.flatMap((id) => {
|
||||
const found = items.find((i): i is CertItem => i.kind === 'certificate' && i.id === id);
|
||||
return found ? [found.cn || found.baseName] : [];
|
||||
});
|
||||
}
|
||||
|
||||
interface PartCardProps<T> {
|
||||
item: T;
|
||||
items: AnyItem[];
|
||||
entries: WorkingEntry[];
|
||||
analysisIds: string[];
|
||||
}
|
||||
|
||||
function KeyCard({ item, items, entries, analysisIds }: PartCardProps<KeyItem>) {
|
||||
const t = useTranslations('certManager');
|
||||
const owners = certNames(item.certIds, items);
|
||||
const sources = item.sources.map((s) => sourceLabel(s, entries, analysisIds));
|
||||
return (
|
||||
<li className="rounded-lg border border-border p-4" data-testid="key-card">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES.privateKey}`}>
|
||||
{t('roles.privateKey')}
|
||||
</span>
|
||||
<span className="break-all font-medium text-foreground">{keyDescription(item, t)}</span>
|
||||
{item.wasEncrypted && (
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{t('analyze.wasEncrypted')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('analyze.explainKey')}</p>
|
||||
<dl className="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
|
||||
<dt className="text-muted-foreground">{t('analyze.belongsTo')}</dt>
|
||||
<dd className="break-all text-foreground">
|
||||
{owners.length > 0 ? owners.join(', ') : t('analyze.noCertificateYet')}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t('analyze.source')}</dt>
|
||||
<dd className="break-all text-foreground">{sources.join(', ')}</dd>
|
||||
</dl>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function CsrCard({ item, items, entries, analysisIds }: PartCardProps<CsrItem>) {
|
||||
const t = useTranslations('certManager');
|
||||
const owners = certNames(item.certIds, items);
|
||||
const sources = item.sources.map((s) => sourceLabel(s, entries, analysisIds));
|
||||
return (
|
||||
<li className="rounded-lg border border-border p-4" data-testid="csr-card">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES.csr}`}>
|
||||
{t('roles.csr')}
|
||||
</span>
|
||||
<span className="break-all font-medium text-foreground">{item.cn || item.baseName}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('analyze.explainCsr')}</p>
|
||||
<dl className="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
|
||||
{item.organization && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('analyze.subject')}</dt>
|
||||
<dd className="break-all text-foreground">{item.organization}</dd>
|
||||
</>
|
||||
)}
|
||||
{item.san.length > 0 && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('analyze.names')}</dt>
|
||||
<dd className="break-all text-foreground">{item.san.join(', ')}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-muted-foreground">{t('analyze.key')}</dt>
|
||||
<dd className="text-foreground">{keyDescription(item, t)}</dd>
|
||||
<dt className="text-muted-foreground">{t('analyze.matchedKey')}</dt>
|
||||
<dd className="text-foreground">
|
||||
{item.keyId ? t('analyze.present') : t('analyze.absent')}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t('analyze.matchedCertificates')}</dt>
|
||||
<dd className="break-all text-foreground">
|
||||
{owners.length > 0 ? owners.join(', ') : t('analyze.absent')}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t('analyze.source')}</dt>
|
||||
<dd className="break-all text-foreground">{sources.join(', ')}</dd>
|
||||
</dl>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Karte je Zertifikat im Reiter „Analysieren“ (D-23): Rolle, Name, Gueltigkeit, Namen,
|
||||
* Schluessel, Seriennummer, Fingerabdruecke und woher das Zertifikat kam.
|
||||
* Karte eines Zertifikats (D-23): Rolle, Name, Gueltigkeit, Namen, Schluessel, Seriennummer,
|
||||
* Fingerabdruecke und woher das Zertifikat kam; mit Hinweis, wenn der passende Schluessel vorliegt.
|
||||
* Daten kommen in UTC, wie im Zertifikat; der Aussteller nennt dasselbe Datum.
|
||||
*/
|
||||
export function ItemCard({ cert, entries, analysisIds }: ItemCardProps) {
|
||||
function CertCard({
|
||||
cert,
|
||||
entries,
|
||||
analysisIds,
|
||||
}: {
|
||||
cert: CertItem;
|
||||
entries: WorkingEntry[];
|
||||
analysisIds: string[];
|
||||
}) {
|
||||
const t = useTranslations('certManager');
|
||||
const format = useFormatter();
|
||||
const date = (iso: string) =>
|
||||
@@ -39,12 +158,7 @@ export function ItemCard({ cert, entries, analysisIds }: ItemCardProps) {
|
||||
}
|
||||
: { cls: 'bg-status-ok/15 text-status-ok-fg', text: t('analyze.valid') };
|
||||
|
||||
const keyText =
|
||||
cert.keyType === 'RSA'
|
||||
? t('analyze.keyRsa', { bits: cert.keyBits ?? 0 })
|
||||
: cert.keyType === 'EC'
|
||||
? t('analyze.keyEc', { curve: cert.curve ?? '' })
|
||||
: cert.keyType;
|
||||
const keyText = keyDescription(cert, t);
|
||||
|
||||
const sources = cert.sources.map((s) => sourceLabel(s, entries, analysisIds));
|
||||
|
||||
@@ -58,6 +172,11 @@ export function ItemCard({ cert, entries, analysisIds }: ItemCardProps) {
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${status.cls}`}>
|
||||
{status.text}
|
||||
</span>
|
||||
{cert.keyId && (
|
||||
<span className="rounded bg-status-ok/15 px-2 py-0.5 text-xs font-medium text-status-ok-fg">
|
||||
{t('analyze.matchingKey')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t(`analyze.explain.${cert.role}`)}</p>
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ function workspace(items: CertItem[], chains: ChainInfo[]): CertWorkspace {
|
||||
errorKey: null,
|
||||
addFiles: () => [],
|
||||
addText: () => null,
|
||||
setPassword: () => {},
|
||||
remove: () => {},
|
||||
clear: () => {},
|
||||
retry: () => {},
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
interface PasswordInputProps {
|
||||
/** Sichtbare Beschriftung des Feldes */
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwortfeld mit Knopf zum Anzeigen und Verbergen (D-11). Das Passwort wird nie gespeichert:
|
||||
* kein Autovervollstaendigen, kein Schreiben in den Speicher des Browsers.
|
||||
*/
|
||||
export function PasswordInput({ label, value, onChange }: PasswordInputProps) {
|
||||
const t = useTranslations('certManager');
|
||||
const id = useId();
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label htmlFor={id} className="text-sm text-foreground">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type={visible ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="min-w-48 flex-1 rounded border border-border bg-background px-3 py-1.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className="rounded border border-border px-2 py-1 text-xs text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{visible ? t('passwordInput.hide') : t('passwordInput.show')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,7 @@ function workspace(items: AnyItem[] | null): CertWorkspace {
|
||||
errorKey: null,
|
||||
addFiles: () => [],
|
||||
addText: () => null,
|
||||
setPassword: () => {},
|
||||
remove: () => {},
|
||||
clear: () => {},
|
||||
retry: () => {},
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
addFiles as addToSet,
|
||||
type RejectedFile,
|
||||
removeEntry,
|
||||
setEntryPassword,
|
||||
type WorkingEntry,
|
||||
} from './working-set';
|
||||
|
||||
@@ -25,6 +26,8 @@ export interface CertWorkspace {
|
||||
/** Eingefuegten PEM-Text als Eintrag anhaengen; `label` liefert die Beschriftung in der Sprache der Oberflaeche */
|
||||
addText: (text: string, label?: (n: number) => string) => RejectedFile | null;
|
||||
remove: (id: string) => void;
|
||||
/** Passwort fuer eine Datei setzen und alles neu pruefen; das Passwort lebt nur in diesem Zustand */
|
||||
setPassword: (id: string, password: string) => void;
|
||||
clear: () => void;
|
||||
retry: () => void;
|
||||
}
|
||||
@@ -110,6 +113,14 @@ export function useCertWorkspace(): CertWorkspace {
|
||||
[commit],
|
||||
);
|
||||
|
||||
const setPassword = useCallback(
|
||||
(id: string, password: string) => {
|
||||
const next = setEntryPassword(entriesRef.current, id, password);
|
||||
if (next !== entriesRef.current) commit(next);
|
||||
},
|
||||
[commit],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
commit([]);
|
||||
}, [commit]);
|
||||
@@ -127,6 +138,7 @@ export function useCertWorkspace(): CertWorkspace {
|
||||
addFiles,
|
||||
addText,
|
||||
remove,
|
||||
setPassword,
|
||||
clear,
|
||||
retry,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MAX_FILE_BYTES,
|
||||
MAX_PASTE_CHARS,
|
||||
removeEntry,
|
||||
setEntryPassword,
|
||||
toFormData,
|
||||
type WorkingEntry,
|
||||
} from './working-set';
|
||||
@@ -143,3 +144,30 @@ describe('toFormData', () => {
|
||||
expect(names).toEqual(['b.pem', 'a.pem']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Passwoerter', () => {
|
||||
it('setEntryPassword aendert nur den einen Eintrag, ohne Aenderung bleibt die Liste gleich', () => {
|
||||
const { entries } = addFiles(NONE, [makeFile('a.pem'), makeFile('b.pfx')], nextId);
|
||||
const next = setEntryPassword(entries, entries[1].id, 'geheim');
|
||||
expect(next.map((e) => e.password)).toEqual(['', 'geheim']);
|
||||
expect(next[0]).toBe(entries[0]);
|
||||
expect(setEntryPassword(next, entries[1].id, 'geheim')).toBe(next);
|
||||
expect(setEntryPassword(next, 'gibt-es-nicht', 'x')).toBe(next);
|
||||
});
|
||||
|
||||
it('toFormData sendet `passwords` nur, wenn ein Passwort gesetzt ist, im Takt der Dateien', () => {
|
||||
const { entries } = addFiles(NONE, [makeFile('a.pem'), makeFile('b.pfx')], nextId);
|
||||
expect(toFormData(entries).has('passwords')).toBe(false);
|
||||
const withPassword = setEntryPassword(entries, entries[1].id, 'geheim');
|
||||
const form = toFormData(withPassword);
|
||||
expect(JSON.parse(form.get('passwords') as string)).toEqual(['', 'geheim']);
|
||||
expect(form.getAll('files')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('das Passwort steht in keinem Dateinamen und keiner Beschriftung', () => {
|
||||
const { entries } = addFiles(NONE, [makeFile('b.pfx')], nextId);
|
||||
const [entry] = setEntryPassword(entries, entries[0].id, 'geheim');
|
||||
expect(entry.label).not.toContain('geheim');
|
||||
expect(entry.file.name).not.toContain('geheim');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface WorkingEntry {
|
||||
origin: EntryOrigin;
|
||||
/** Nur fuer nachgeladene Zertifikate: der Server, von dem sie kamen */
|
||||
host: string | null;
|
||||
/** Passwort fuer diese Datei (ab Task 4); bleibt im Arbeitsspeicher */
|
||||
/** Passwort fuer diese Datei; bleibt im Arbeitsspeicher, wird nie gespeichert */
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -127,10 +127,27 @@ export function removeEntry(entries: WorkingEntry[], id: string): WorkingEntry[]
|
||||
return entries.filter((e) => e.id !== id);
|
||||
}
|
||||
|
||||
/** Multipart-Koerper fuer die Analyse: `files` in der Reihenfolge der Eintraege. */
|
||||
/** Setzt das Passwort eines Eintrags; ohne Aenderung kommt dieselbe Liste zurueck. */
|
||||
export function setEntryPassword(
|
||||
entries: WorkingEntry[],
|
||||
id: string,
|
||||
password: string,
|
||||
): WorkingEntry[] {
|
||||
if (!entries.some((e) => e.id === id && e.password !== password)) return entries;
|
||||
return entries.map((e) => (e.id === id ? { ...e, password } : e));
|
||||
}
|
||||
|
||||
/**
|
||||
* Multipart-Koerper fuer die Analyse: `files` in der Reihenfolge der Eintraege und, nur wenn
|
||||
* mindestens ein Passwort eingegeben wurde, `passwords` als JSON-Liste im Takt der Dateien.
|
||||
* Passwoerter stehen nur in diesem Koerper, nie in einer Adresse.
|
||||
*/
|
||||
export function toFormData(entries: WorkingEntry[]): FormData {
|
||||
const form = new FormData();
|
||||
for (const entry of entries) form.append('files', entry.file, entry.file.name);
|
||||
if (entries.some((e) => e.password !== '')) {
|
||||
form.append('passwords', JSON.stringify(entries.map((e) => e.password)));
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user