refactor(decnet_web/Webhooks): extract FormRow + SecretModal
This commit is contained in:
105
decnet_web/src/components/Webhooks/FormRow.tsx
Normal file
105
decnet_web/src/components/Webhooks/FormRow.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Save, X } from '../../icons';
|
||||||
|
import type { FormState, SimpleEvent } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
form: FormState;
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<FormState>>;
|
||||||
|
onSave: (e: React.FormEvent) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
saving: boolean;
|
||||||
|
isEdit: boolean;
|
||||||
|
onToggleSimple: (n: SimpleEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormRow: React.FC<Props> = ({
|
||||||
|
title, form, setForm, onSave, onCancel, saving, isEdit, onToggleSimple,
|
||||||
|
}) => (
|
||||||
|
<tr className="wh-form-row">
|
||||||
|
<td colSpan={7}>
|
||||||
|
<form className="wh-form-grid" onSubmit={onSave}>
|
||||||
|
<label className="wh-form-title">{title}</label>
|
||||||
|
|
||||||
|
<label>NAME</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="shuffle-prod"
|
||||||
|
required
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={form.url}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
|
||||||
|
placeholder="https://shuffle.example.com/api/v1/hooks/webhook_xxx"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
SECRET {isEdit && <span className="wh-form-hint">(blank = keep existing)</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.secret}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, secret: e.target.value }))}
|
||||||
|
placeholder={isEdit ? '—' : 'leave blank to auto-generate'}
|
||||||
|
minLength={16}
|
||||||
|
maxLength={256}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>SIMPLE EVENTS</label>
|
||||||
|
<div className="wh-checkbox-group">
|
||||||
|
{(['AttackerDetail', 'DeckyStatus', 'SystemStatus'] as const).map((name) => (
|
||||||
|
<label key={name}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.simple_events.includes(name)}
|
||||||
|
onChange={() => onToggleSimple(name)}
|
||||||
|
/>
|
||||||
|
{name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
ADVANCED PATTERNS
|
||||||
|
<br />
|
||||||
|
<span className="wh-form-hint">(one per line, NATS-style)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={form.topic_patterns}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, topic_patterns: e.target.value }))}
|
||||||
|
placeholder={'attacker.>\ndecky.*.state'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>ENABLED</label>
|
||||||
|
<div className="wh-checkbox-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.enabled}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Receive events
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wh-form-buttons">
|
||||||
|
<button type="button" className="btn ghost" onClick={onCancel} disabled={saving}>
|
||||||
|
<X size={12} /> CANCEL
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn violet" disabled={saving}>
|
||||||
|
<Save size={12} /> {saving ? 'SAVING…' : isEdit ? 'SAVE CHANGES' : 'CREATE'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default FormRow;
|
||||||
28
decnet_web/src/components/Webhooks/SecretModal.test.tsx
Normal file
28
decnet_web/src/components/Webhooks/SecretModal.test.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import SecretModal from './SecretModal';
|
||||||
|
|
||||||
|
describe('SecretModal', () => {
|
||||||
|
it('renders the secret and fires onClose for DONE', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<SecretModal name="shuffle" secret="abc123def456ghi7" onClose={onClose} />);
|
||||||
|
expect(screen.getByText('abc123def456ghi7')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/SHUFFLE/)).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByText('DONE'));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes when backdrop is clicked but not when the inner modal is clicked', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<SecretModal name="x" secret="s" onClose={onClose} />,
|
||||||
|
);
|
||||||
|
fireEvent.click(container.querySelector('.wh-secret-modal-backdrop')!);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
fireEvent.click(container.querySelector('.wh-secret-modal')!);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
decnet_web/src/components/Webhooks/SecretModal.tsx
Normal file
46
decnet_web/src/components/Webhooks/SecretModal.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { AlertTriangle, Check, Copy } from '../../icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
secret: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SecretModal: React.FC<Props> = ({ name, secret, onClose }) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(secret);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
/* no-op — browsers without clipboard perms will just see no feedback */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="wh-secret-modal-backdrop"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
>
|
||||||
|
<div className="wh-secret-modal">
|
||||||
|
<h3>WEBHOOK SECRET · {name.toUpperCase()}</h3>
|
||||||
|
<div className="wh-secret-warn">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
<span>COPY THIS NOW — IT WILL NOT BE SHOWN AGAIN. THE HMAC ON EVERY DELIVERY IS SIGNED WITH THIS VALUE.</span>
|
||||||
|
</div>
|
||||||
|
<div className="wh-secret-value">{secret}</div>
|
||||||
|
<div className="wh-secret-actions">
|
||||||
|
<button className="btn ghost" onClick={copy}>
|
||||||
|
<Copy size={12} /> {copied ? 'COPIED' : 'COPY'}
|
||||||
|
</button>
|
||||||
|
<button className="btn violet" onClick={onClose}>
|
||||||
|
<Check size={12} /> DONE
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SecretModal;
|
||||||
Reference in New Issue
Block a user