audit: Tier 0 quick wins — EMAIL_REDIRECT_TO prod guard + storage routing + metadata masking

Tier 0.2: src/lib/env.ts now refuses boot when NODE_ENV=production AND
EMAIL_REDIRECT_TO is set. Sendmail logs the rewrite at warn (was debug)
so dev/staging windows where someone forgets to unset are immediately
visible.

Tier 0.6: backup_jobs.storage_path added to TABLES_WITH_STORAGE_KEYS in
src/lib/storage/migrate.ts. Flipping the storage backend used to
silently orphan every pg_dump artefact — last-resort recovery path is
now actually portable.

Tier 1.7: createAuditLog now runs metadata through maskSensitiveFields
(was only applied to old/new value diffs). Portal-auth, crm-invite,
hard-delete and email-accounts services were writing raw emails into
this column unbounded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 17:02:10 +02:00
parent a7b72801be
commit 0baca41693
13 changed files with 297 additions and 249 deletions

View File

@@ -212,150 +212,150 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
</TabsContent>
<TabsContent value="profile" className="mt-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="user-first-name">First name</Label>
<Input
id="user-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Jane"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-last-name">Last name</Label>
<Input
id="user-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Doe"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="user-display-name">Display name</Label>
<Input
id="user-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={fullName || 'Jane Doe'}
required
/>
<p className="text-xs text-muted-foreground">
How this user appears across the app usually their full name, but they can pick a
nickname.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="user-email">Email</Label>
<Input
id="user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
required
/>
{isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase() ? (
<p className="text-xs text-amber-600">
You&apos;ll be asked to confirm the original address will receive an automated
notice that you, the admin, changed their sign-in email.
</p>
) : isEdit ? (
<p className="text-xs text-muted-foreground">
Changing this address is an admin-only override; the user will be notified at the
old address.
</p>
) : null}
</div>
{!isEdit && (
<div className="space-y-2">
<Label htmlFor="user-password">Password</Label>
<Input
id="user-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min 12 characters"
minLength={12}
required
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="user-phone">Phone</Label>
<PhoneInput
id="user-phone"
value={phoneValue}
onChange={setPhoneValue}
placeholder="Phone number"
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-role">Role</Label>
<Select value={roleId} onValueChange={setRoleId} required>
<SelectTrigger id="user-role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((r) => (
<SelectItem key={r.id} value={r.id}>
{formatRole(r.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="user-residential">Residential access</Label>
<p className="text-xs text-muted-foreground">
Grant this user access to residential clients and interests in addition to their
primary role.
</p>
</div>
<Switch
id="user-residential"
checked={residentialAccess}
onCheckedChange={setResidentialAccess}
/>
</div>
{isEdit && (
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="user-active">Account active</Label>
<p className="text-xs text-muted-foreground">Disabled users cannot sign in.</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="user-first-name">First name</Label>
<Input
id="user-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Jane"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-last-name">Last name</Label>
<Input
id="user-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Doe"
required
/>
</div>
</div>
<Switch id="user-active" checked={isActive} onCheckedChange={setIsActive} />
</div>
)}
{error && <p className="whitespace-pre-line text-sm text-destructive">{error}</p>}
<div className="space-y-2">
<Label htmlFor="user-display-name">Display name</Label>
<Input
id="user-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={fullName || 'Jane Doe'}
required
/>
<p className="text-xs text-muted-foreground">
How this user appears across the app usually their full name, but they can pick
a nickname.
</p>
</div>
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !displayName.trim() || !roleId}>
{loading ? 'Saving...' : isEdit ? 'Save changes' : 'Create user'}
</Button>
</SheetFooter>
</form>
<div className="space-y-2">
<Label htmlFor="user-email">Email</Label>
<Input
id="user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
required
/>
{isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase() ? (
<p className="text-xs text-amber-600">
You&apos;ll be asked to confirm the original address will receive an automated
notice that you, the admin, changed their sign-in email.
</p>
) : isEdit ? (
<p className="text-xs text-muted-foreground">
Changing this address is an admin-only override; the user will be notified at
the old address.
</p>
) : null}
</div>
{!isEdit && (
<div className="space-y-2">
<Label htmlFor="user-password">Password</Label>
<Input
id="user-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min 12 characters"
minLength={12}
required
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="user-phone">Phone</Label>
<PhoneInput
id="user-phone"
value={phoneValue}
onChange={setPhoneValue}
placeholder="Phone number"
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-role">Role</Label>
<Select value={roleId} onValueChange={setRoleId} required>
<SelectTrigger id="user-role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((r) => (
<SelectItem key={r.id} value={r.id}>
{formatRole(r.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="user-residential">Residential access</Label>
<p className="text-xs text-muted-foreground">
Grant this user access to residential clients and interests in addition to their
primary role.
</p>
</div>
<Switch
id="user-residential"
checked={residentialAccess}
onCheckedChange={setResidentialAccess}
/>
</div>
{isEdit && (
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="user-active">Account active</Label>
<p className="text-xs text-muted-foreground">Disabled users cannot sign in.</p>
</div>
<Switch id="user-active" checked={isActive} onCheckedChange={setIsActive} />
</div>
)}
{error && <p className="whitespace-pre-line text-sm text-destructive">{error}</p>}
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !displayName.trim() || !roleId}>
{loading ? 'Saving...' : isEdit ? 'Save changes' : 'Create user'}
</Button>
</SheetFooter>
</form>
</TabsContent>
</Tabs>

View File

@@ -2,7 +2,12 @@
import { useEffect, useState } from 'react';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';