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:
@@ -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'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'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>
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user