bu1 ad email config
This commit is contained in:
@@ -10,6 +10,14 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Sheet,
|
||||
@@ -19,7 +27,6 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import { formatDateTime } from '@/lib/date-format';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -120,7 +127,6 @@ function SecurityNote({ title, description }: { title: string; description: stri
|
||||
|
||||
function ConfigCard({
|
||||
configuration,
|
||||
sendRecipientEmail,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTestConnection,
|
||||
@@ -130,7 +136,6 @@ function ConfigCard({
|
||||
isSending,
|
||||
}: {
|
||||
configuration: EmailConfigurationSummary;
|
||||
sendRecipientEmail: string;
|
||||
onEdit: (configuration: EmailConfigurationSummary) => void;
|
||||
onDelete: (configuration: EmailConfigurationSummary) => void;
|
||||
onTestConnection: (configuration: EmailConfigurationSummary) => void;
|
||||
@@ -218,7 +223,6 @@ function ConfigCard({
|
||||
type='button'
|
||||
onClick={() => onSendTest(configuration)}
|
||||
isLoading={isSending}
|
||||
disabled={!sendRecipientEmail.trim()}
|
||||
>
|
||||
<Icons.send className='mr-2 h-4 w-4' />
|
||||
Send test email
|
||||
@@ -238,10 +242,93 @@ function ConfigCard({
|
||||
);
|
||||
}
|
||||
|
||||
function QuickVerificationDialog({
|
||||
configuration,
|
||||
recipientEmail,
|
||||
onRecipientEmailChange,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isSending,
|
||||
}: {
|
||||
configuration: EmailConfigurationSummary | null;
|
||||
recipientEmail: string;
|
||||
onRecipientEmailChange: (value: string) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: () => void;
|
||||
isSending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={configuration !== null} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Quick verification</DialogTitle>
|
||||
<DialogDescription>
|
||||
{configuration
|
||||
? `Send a test email through ${configuration.name} to verify the active SMTP route.`
|
||||
: 'Send a test email to verify the active SMTP route.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{configuration ? (
|
||||
<div className='rounded-xl border border-border/60 bg-muted/30 p-3 text-sm'>
|
||||
<div className='font-medium text-foreground'>{configuration.name}</div>
|
||||
<div className='mt-1 text-muted-foreground'>
|
||||
{configuration.host}:{configuration.port} via{' '}
|
||||
{configuration.secure ? 'TLS/SSL' : 'STARTTLS / plain'}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium' htmlFor='email-test-recipient'>
|
||||
Test recipient
|
||||
</label>
|
||||
<Input
|
||||
id='email-test-recipient'
|
||||
type='email'
|
||||
value={recipientEmail}
|
||||
onChange={(event) => onRecipientEmailChange(event.target.value)}
|
||||
placeholder='ops@example.com'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3 text-sm text-muted-foreground'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icons.check className='mt-0.5 h-4 w-4 text-primary' />
|
||||
<span>Connection test updates health state without exposing the stored password.</span>
|
||||
</div>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icons.lock className='mt-0.5 h-4 w-4 text-primary' />
|
||||
<span>Stored passwords can be replaced or cleared, but are never returned from the API.</span>
|
||||
</div>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icons.settings className='mt-0.5 h-4 w-4 text-primary' />
|
||||
<span>Setup wizard and notification runtime use the same organization-level SMTP source.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)} disabled={isSending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='button' onClick={onSubmit} isLoading={isSending} disabled={!recipientEmail.trim()}>
|
||||
<Icons.send className={cn('mr-2 h-4 w-4', isSending && 'hidden')} />
|
||||
Send test email
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmailConfigurationPage() {
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [editingConfiguration, setEditingConfiguration] = useState<EmailConfigurationSummary | null>(null);
|
||||
const [sendRecipientEmail, setSendRecipientEmail] = useState('');
|
||||
const [quickVerificationConfiguration, setQuickVerificationConfiguration] =
|
||||
useState<EmailConfigurationSummary | null>(null);
|
||||
const [quickVerificationRecipientEmail, setQuickVerificationRecipientEmail] = useState('');
|
||||
|
||||
const emailConfigurationsQuery = useQuery(emailConfigurationsQueryOptions());
|
||||
const createMutation = useMutation(createEmailConfigurationMutation);
|
||||
@@ -336,12 +423,18 @@ export function EmailConfigurationPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendTest(configuration: EmailConfigurationSummary) {
|
||||
async function handleSendTest() {
|
||||
if (!quickVerificationConfiguration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedRecipientEmail = quickVerificationRecipientEmail.trim().toLowerCase();
|
||||
|
||||
try {
|
||||
const result = await sendTestEmailMutation.mutateAsync({
|
||||
id: configuration.id,
|
||||
id: quickVerificationConfiguration.id,
|
||||
payload: {
|
||||
recipientEmail: sendRecipientEmail.trim().toLowerCase(),
|
||||
recipientEmail: normalizedRecipientEmail,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -350,7 +443,8 @@ export function EmailConfigurationPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(`Test email queued to ${sendRecipientEmail.trim().toLowerCase()}.`);
|
||||
toast.success(`Test email queued to ${normalizedRecipientEmail}.`);
|
||||
setQuickVerificationConfiguration(null);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to send test email.');
|
||||
}
|
||||
@@ -402,29 +496,16 @@ export function EmailConfigurationPage() {
|
||||
|
||||
<Card className='border-border/60 bg-background/80 shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick verification</CardTitle>
|
||||
<CardTitle>Operator actions</CardTitle>
|
||||
<CardDescription>
|
||||
Use one test recipient while reviewing multiple SMTP profiles.
|
||||
Create and maintain SMTP routes for setup, notifications, and approvals.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium' htmlFor='email-test-recipient'>
|
||||
Test recipient
|
||||
</label>
|
||||
<Input
|
||||
id='email-test-recipient'
|
||||
type='email'
|
||||
value={sendRecipientEmail}
|
||||
onChange={(event) => setSendRecipientEmail(event.target.value)}
|
||||
placeholder='ops@example.com'
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className='space-y-3 text-sm text-muted-foreground'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icons.check className='mt-0.5 h-4 w-4 text-primary' />
|
||||
<span>Connection test updates the configuration health state without exposing the password.</span>
|
||||
<span>Add a route, then use each row's quick verification modal to send a test email.</span>
|
||||
</div>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icons.lock className='mt-0.5 h-4 w-4 text-primary' />
|
||||
@@ -508,14 +589,15 @@ export function EmailConfigurationPage() {
|
||||
<ConfigCard
|
||||
key={configuration.id}
|
||||
configuration={configuration}
|
||||
sendRecipientEmail={sendRecipientEmail}
|
||||
onEdit={(item) => {
|
||||
setEditingConfiguration(item);
|
||||
setSheetOpen(true);
|
||||
}}
|
||||
onDelete={handleDelete}
|
||||
onTestConnection={handleTestConnection}
|
||||
onSendTest={handleSendTest}
|
||||
onSendTest={(item) => {
|
||||
setQuickVerificationConfiguration(item);
|
||||
}}
|
||||
isDeleting={deleteMutation.isPending && deleteMutation.variables === configuration.id}
|
||||
isTesting={
|
||||
testConnectionMutation.isPending &&
|
||||
@@ -672,6 +754,19 @@ export function EmailConfigurationPage() {
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<QuickVerificationDialog
|
||||
configuration={quickVerificationConfiguration}
|
||||
recipientEmail={quickVerificationRecipientEmail}
|
||||
onRecipientEmailChange={setQuickVerificationRecipientEmail}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setQuickVerificationConfiguration(null);
|
||||
}
|
||||
}}
|
||||
onSubmit={handleSendTest}
|
||||
isSending={sendTestEmailMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,53 @@
|
||||
import 'server-only';
|
||||
import "server-only";
|
||||
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from "node:crypto";
|
||||
|
||||
const ENCRYPTION_PREFIX = 'enc:v1';
|
||||
const ENCRYPTION_ENV_KEY = 'EMAIL_CONFIG_ENCRYPTION_KEY';
|
||||
const ENCRYPTION_PREFIX = "enc:v1";
|
||||
const ENCRYPTION_ENV_KEY = "EMAIL_CONFIG_ENCRYPTION_KEY";
|
||||
const MIN_KEY_BYTES = 32;
|
||||
|
||||
function decodeConfiguredKey(rawValue: string): Buffer {
|
||||
const value = rawValue.trim();
|
||||
|
||||
if (/^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0) {
|
||||
return Buffer.from(value, 'hex');
|
||||
return Buffer.from(value, "hex");
|
||||
}
|
||||
|
||||
try {
|
||||
const base64Buffer = Buffer.from(value, 'base64');
|
||||
if (base64Buffer.length > 0 && base64Buffer.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')) {
|
||||
const base64Buffer = Buffer.from(value, "base64");
|
||||
if (
|
||||
base64Buffer.length > 0 &&
|
||||
base64Buffer.toString("base64").replace(/=+$/, "") ===
|
||||
value.replace(/=+$/, "")
|
||||
) {
|
||||
return base64Buffer;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to UTF-8 below.
|
||||
}
|
||||
|
||||
return Buffer.from(value, 'utf8');
|
||||
return Buffer.from(value, "utf8");
|
||||
}
|
||||
|
||||
function getEncryptionKeyMaterial(): Buffer {
|
||||
const configured = process.env[ENCRYPTION_ENV_KEY]?.trim() ?? '';
|
||||
const configured = process.env[ENCRYPTION_ENV_KEY]?.trim() ?? "";
|
||||
if (!configured) {
|
||||
throw new Error(`${ENCRYPTION_ENV_KEY} is not configured.`);
|
||||
}
|
||||
|
||||
const keyMaterial = decodeConfiguredKey(configured);
|
||||
if (keyMaterial.byteLength < MIN_KEY_BYTES) {
|
||||
throw new Error(`${ENCRYPTION_ENV_KEY} must provide at least ${MIN_KEY_BYTES} bytes of key material.`);
|
||||
throw new Error(
|
||||
`${ENCRYPTION_ENV_KEY} must provide at least ${MIN_KEY_BYTES} bytes of key material.`,
|
||||
);
|
||||
}
|
||||
|
||||
return createHash('sha256').update(keyMaterial).digest();
|
||||
return createHash("sha256").update(keyMaterial).digest();
|
||||
}
|
||||
|
||||
export function assertSecretsEncryptionReady(): void {
|
||||
@@ -46,37 +57,49 @@ export function assertSecretsEncryptionReady(): void {
|
||||
export function encryptSecret(plainText: string): string {
|
||||
const normalized = plainText.trim();
|
||||
if (!normalized) {
|
||||
throw new Error('Secret value is required for encryption.');
|
||||
throw new Error("Secret value is required for encryption.");
|
||||
}
|
||||
|
||||
const key = getEncryptionKeyMaterial();
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(normalized, 'utf8'), cipher.final()]);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(normalized, "utf8"),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return [
|
||||
ENCRYPTION_PREFIX,
|
||||
iv.toString('base64'),
|
||||
tag.toString('base64'),
|
||||
encrypted.toString('base64'),
|
||||
].join(':');
|
||||
iv.toString("base64"),
|
||||
tag.toString("base64"),
|
||||
encrypted.toString("base64"),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function decryptSecret(cipherText: string): string {
|
||||
const [prefix, ivPart, tagPart, encryptedPart] = cipherText.split(':');
|
||||
if (prefix !== ENCRYPTION_PREFIX || !ivPart || !tagPart || !encryptedPart) {
|
||||
throw new Error('Encrypted secret format is invalid.');
|
||||
if (!cipherText.startsWith(`${ENCRYPTION_PREFIX}:`)) {
|
||||
throw new Error("Encrypted secret format is invalid.");
|
||||
}
|
||||
|
||||
const payload = cipherText.slice(ENCRYPTION_PREFIX.length + 1);
|
||||
const [ivPart, tagPart, encryptedPart, ...extraParts] = payload.split(":");
|
||||
if (!ivPart || !tagPart || !encryptedPart || extraParts.length > 0) {
|
||||
throw new Error("Encrypted secret format is invalid.");
|
||||
}
|
||||
|
||||
const key = getEncryptionKeyMaterial();
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivPart, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(tagPart, 'base64'));
|
||||
const decipher = createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
key,
|
||||
Buffer.from(ivPart, "base64"),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(tagPart, "base64"));
|
||||
|
||||
const plain = Buffer.concat([
|
||||
decipher.update(Buffer.from(encryptedPart, 'base64')),
|
||||
decipher.update(Buffer.from(encryptedPart, "base64")),
|
||||
decipher.final(),
|
||||
]);
|
||||
|
||||
return plain.toString('utf8');
|
||||
return plain.toString("utf8");
|
||||
}
|
||||
|
||||
37
src/tests/setup/secret-crypto.test.ts
Normal file
37
src/tests/setup/secret-crypto.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
decryptSecret,
|
||||
encryptSecret,
|
||||
} from '../../features/foundation/secrets/server/crypto.ts';
|
||||
|
||||
const previousKey = process.env.EMAIL_CONFIG_ENCRYPTION_KEY;
|
||||
|
||||
test.before(() => {
|
||||
process.env.EMAIL_CONFIG_ENCRYPTION_KEY =
|
||||
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
if (typeof previousKey === 'string') {
|
||||
process.env.EMAIL_CONFIG_ENCRYPTION_KEY = previousKey;
|
||||
return;
|
||||
}
|
||||
|
||||
delete process.env.EMAIL_CONFIG_ENCRYPTION_KEY;
|
||||
});
|
||||
|
||||
test('encryptSecret output decrypts back to the original value', () => {
|
||||
const encrypted = encryptSecret('smtp-password');
|
||||
|
||||
assert.match(encrypted, /^enc:v1:/);
|
||||
assert.equal(decryptSecret(encrypted), 'smtp-password');
|
||||
});
|
||||
|
||||
test('decryptSecret rejects malformed ciphertext', () => {
|
||||
assert.throws(
|
||||
() => decryptSecret('enc:v1:missing-parts'),
|
||||
/Encrypted secret format is invalid\./,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user