feat: The Other Dude v9.0.1 — full-featured email system

ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-08 17:46:37 -05:00
commit b840047e19
511 changed files with 106948 additions and 0 deletions

View File

@@ -0,0 +1,421 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Key, Plus, Copy, Trash2, AlertTriangle, Check } from 'lucide-react'
import { EmptyState } from '@/components/ui/empty-state'
import {
apiKeysApi,
type ApiKeyResponse,
type ApiKeyCreateResponse,
} from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
const AVAILABLE_SCOPES = [
{ id: 'devices:read', label: 'Devices: Read' },
{ id: 'devices:write', label: 'Devices: Write' },
{ id: 'config:read', label: 'Config: Read' },
{ id: 'config:write', label: 'Config: Write' },
{ id: 'alerts:read', label: 'Alerts: Read' },
{ id: 'firmware:write', label: 'Firmware: Write' },
] as const
function formatRelativeTime(iso: string | null): string {
if (!iso) return 'Never'
const date = new Date(iso)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 30) return `${diffDays}d ago`
return date.toLocaleDateString()
}
function formatDate(iso: string | null): string {
if (!iso) return 'Never'
return new Date(iso).toLocaleDateString()
}
function getKeyStatus(key: ApiKeyResponse): {
label: string
className: string
} {
if (key.revoked_at) {
return { label: 'Revoked', className: 'border-border bg-elevated/50 text-text-muted' }
}
if (key.expires_at && new Date(key.expires_at) <= new Date()) {
return { label: 'Expired', className: 'border-error/30 bg-error/10 text-error' }
}
return { label: 'Active', className: 'border-success/30 bg-success/10 text-success' }
}
interface ApiKeysPageProps {
tenantId: string
}
export function ApiKeysPage({ tenantId }: ApiKeysPageProps) {
const queryClient = useQueryClient()
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [showKeyDialog, setShowKeyDialog] = useState(false)
const [showRevokeDialog, setShowRevokeDialog] = useState(false)
const [revokeTarget, setRevokeTarget] = useState<ApiKeyResponse | null>(null)
const [newKey, setNewKey] = useState<ApiKeyCreateResponse | null>(null)
const [copied, setCopied] = useState(false)
// Create form state
const [name, setName] = useState('')
const [selectedScopes, setSelectedScopes] = useState<string[]>([])
const [expiresAt, setExpiresAt] = useState('')
const keysQuery = useQuery({
queryKey: ['api-keys', tenantId],
queryFn: () => apiKeysApi.list(tenantId),
enabled: !!tenantId,
})
const createMutation = useMutation({
mutationFn: (data: { name: string; scopes: string[]; expires_at?: string }) =>
apiKeysApi.create(tenantId, data),
onSuccess: (data) => {
setNewKey(data)
setShowCreateDialog(false)
setShowKeyDialog(true)
resetForm()
queryClient.invalidateQueries({ queryKey: ['api-keys', tenantId] })
},
})
const revokeMutation = useMutation({
mutationFn: (keyId: string) => apiKeysApi.revoke(tenantId, keyId),
onSuccess: () => {
setShowRevokeDialog(false)
setRevokeTarget(null)
queryClient.invalidateQueries({ queryKey: ['api-keys', tenantId] })
},
})
function resetForm() {
setName('')
setSelectedScopes([])
setExpiresAt('')
}
function handleCreate() {
const data: { name: string; scopes: string[]; expires_at?: string } = {
name,
scopes: selectedScopes,
}
if (expiresAt) {
data.expires_at = new Date(expiresAt).toISOString()
}
createMutation.mutate(data)
}
function toggleScope(scope: string) {
setSelectedScopes((prev) =>
prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope],
)
}
async function copyKey() {
if (!newKey) return
await navigator.clipboard.writeText(newKey.key)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const keys = keysQuery.data ?? []
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<Key className="h-5 w-5 text-text-muted" />
<h1 className="text-lg font-semibold">API Keys</h1>
</div>
<p className="text-sm text-text-muted mt-0.5">
Create and manage API keys for programmatic access
</p>
</div>
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Create API Key
</Button>
</div>
{/* Key list */}
{keys.length === 0 && !keysQuery.isLoading ? (
<EmptyState
icon={Key}
title="No API keys"
description="Create API keys for programmatic access to the portal."
action={{ label: 'Create API Key', onClick: () => setShowCreateDialog(true) }}
/>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-elevated/30">
<th className="text-left px-4 py-2.5 font-medium text-text-secondary">Name</th>
<th className="text-left px-4 py-2.5 font-medium text-text-secondary">Key</th>
<th className="text-left px-4 py-2.5 font-medium text-text-secondary">Scopes</th>
<th className="text-left px-4 py-2.5 font-medium text-text-secondary">Last Used</th>
<th className="text-left px-4 py-2.5 font-medium text-text-secondary">Expires</th>
<th className="text-left px-4 py-2.5 font-medium text-text-secondary">Status</th>
<th className="text-right px-4 py-2.5 font-medium text-text-secondary">Actions</th>
</tr>
</thead>
<tbody>
{keys.map((key) => {
const status = getKeyStatus(key)
const isInactive = !!key.revoked_at
return (
<tr
key={key.id}
className={`border-b border-border/50 last:border-0 ${isInactive ? 'opacity-50' : ''}`}
>
<td className="px-4 py-3 font-medium">{key.name}</td>
<td className="px-4 py-3">
<code className="text-xs font-mono bg-elevated/50 px-1.5 py-0.5 rounded">
{key.key_prefix}...
</code>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{key.scopes.map((scope) => (
<Badge key={scope} className="text-[10px]">
{scope}
</Badge>
))}
</div>
</td>
<td className="px-4 py-3 text-text-muted text-xs">
{formatRelativeTime(key.last_used_at)}
</td>
<td className="px-4 py-3 text-text-muted text-xs">
{formatDate(key.expires_at)}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium border ${status.className}`}
>
{status.label}
</span>
</td>
<td className="px-4 py-3 text-right">
{!key.revoked_at && (
<Button
variant="ghost"
size="sm"
className="text-error hover:text-error hover:bg-error/10"
onClick={() => {
setRevokeTarget(key)
setShowRevokeDialog(true)
}}
>
<Trash2 className="h-3.5 w-3.5" />
Revoke
</Button>
)}
{key.revoked_at && (
<span className="text-xs text-text-muted">
Revoked {formatDate(key.revoked_at)}
</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* Create API Key Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
<DialogDescription>
Create a new API key for programmatic access. Choose which scopes the key should have
access to.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Name */}
<div>
<label className="text-sm font-medium text-text-secondary block mb-1.5">
Key Name
</label>
<input
type="text"
className="w-full rounded-md border border-border-bright bg-elevated/50 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="e.g. Monitoring Integration"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
{/* Scopes */}
<div>
<label className="text-sm font-medium text-text-secondary block mb-2">
Permissions
</label>
<div className="grid grid-cols-2 gap-2">
{AVAILABLE_SCOPES.map((scope) => (
<label
key={scope.id}
className="flex items-center gap-2 rounded-md border border-border/50 px-3 py-2 hover:bg-elevated/30 cursor-pointer"
>
<Checkbox
checked={selectedScopes.includes(scope.id)}
onCheckedChange={() => toggleScope(scope.id)}
/>
<span className="text-sm">{scope.label}</span>
</label>
))}
</div>
</div>
{/* Expiry */}
<div>
<label className="text-sm font-medium text-text-secondary block mb-1.5">
Expiry Date{' '}
<span className="text-text-muted font-normal">(optional)</span>
</label>
<input
type="date"
className="w-full rounded-md border border-border-bright bg-elevated/50 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
min={new Date().toISOString().split('T')[0]}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!name.trim() || selectedScopes.length === 0 || createMutation.isPending}
>
{createMutation.isPending ? 'Creating...' : 'Create Key'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* One-Time Key Display Dialog */}
<Dialog
open={showKeyDialog}
onOpenChange={(open) => {
if (!open) {
setShowKeyDialog(false)
setNewKey(null)
setCopied(false)
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>API Key Created</DialogTitle>
<DialogDescription>
Copy your API key now. You will not be able to see it again.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="rounded-lg border border-warning/30 bg-warning/5 p-3">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 flex-shrink-0" />
<p className="text-xs text-warning">
This key will not be shown again. Store it securely.
</p>
</div>
</div>
<div className="relative">
<pre className="rounded-lg border border-border bg-elevated/50 p-4 font-mono text-sm break-all whitespace-pre-wrap">
{newKey?.key}
</pre>
<Button
variant="outline"
size="sm"
className="absolute top-2 right-2"
onClick={copyKey}
>
{copied ? (
<>
<Check className="h-3.5 w-3.5 text-success" />
Copied
</>
) : (
<>
<Copy className="h-3.5 w-3.5" />
Copy
</>
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
onClick={() => {
setShowKeyDialog(false)
setNewKey(null)
setCopied(false)
}}
>
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Revoke Confirmation Dialog */}
<Dialog open={showRevokeDialog} onOpenChange={setShowRevokeDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Revoke API Key</DialogTitle>
<DialogDescription>
Are you sure you want to revoke{' '}
<span className="font-medium text-text-primary">{revokeTarget?.name}</span>? This
action cannot be undone and will immediately prevent any requests using this key.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowRevokeDialog(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => revokeTarget && revokeMutation.mutate(revokeTarget.id)}
disabled={revokeMutation.isPending}
>
{revokeMutation.isPending ? 'Revoking...' : 'Revoke Key'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,222 @@
import { useState, useCallback } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Loader2, Lock } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useAuth } from '@/lib/auth'
import { authApi } from '@/lib/api'
import { keyStore } from '@/lib/crypto/keyStore'
import { deriveKeysInWorker } from '@/lib/crypto/keys'
import { computeVerifier } from '@/lib/crypto/srp'
import { getErrorMessage } from '@/lib/errors'
import {
PasswordStrengthMeter,
getPasswordScore,
} from '@/components/auth/PasswordStrengthMeter'
function toHex(bytes: Uint8Array): string {
let hex = ''
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i]!.toString(16).padStart(2, '0')
}
return hex
}
function toBase64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf)
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]!)
}
return btoa(binary)
}
export function ChangePasswordForm() {
const { user, logout } = useAuth()
const navigate = useNavigate()
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const isSrpUser = user?.auth_version === 2
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (newPassword.length < 8) {
setError('New password must be at least 8 characters')
return
}
if (getPasswordScore(newPassword) < 3) {
setError('Password is too weak. Please choose a stronger password.')
return
}
if (newPassword !== confirmPassword) {
setError('New passwords do not match')
return
}
if (currentPassword === newPassword) {
setError('New password must be different from current password')
return
}
setIsSubmitting(true)
try {
if (isSrpUser) {
// SRP user: re-derive verifier with new password
const email = user!.email
const secretKeyBytes = await keyStore.getSecretKey(email)
if (!secretKeyBytes) {
setError('Secret Key not found on this device. Cannot change password.')
setIsSubmitting(false)
return
}
// Generate new salts for the new key derivation
const pbkdf2Salt = crypto.getRandomValues(new Uint8Array(32))
const hkdfSalt = crypto.getRandomValues(new Uint8Array(32))
// Derive new keys with new password
const { auk, srpX } = await deriveKeysInWorker({
masterPassword: newPassword,
secretKeyBytes,
email,
accountId: email,
pbkdf2Salt,
hkdfSalt,
})
// Compute new SRP verifier
const srpSalt = crypto.getRandomValues(new Uint8Array(32))
const srpSaltHex = toHex(srpSalt)
const srpXHex = toHex(srpX)
const verifierHex = computeVerifier(srpXHex)
// Generate new RSA keypair and wrap with new AUK
const keyPair = await crypto.subtle.generateKey(
{ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
true,
['encrypt', 'decrypt'],
)
const publicKeyBuffer = await crypto.subtle.exportKey('spki', keyPair.publicKey)
const privateKeyNonce = crypto.getRandomValues(new Uint8Array(12))
const wrappedPrivateKey = await crypto.subtle.wrapKey('pkcs8', keyPair.privateKey, auk, { name: 'AES-GCM', iv: privateKeyNonce })
// Generate and wrap new vault key
const vaultKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
const vaultKeyNonce = crypto.getRandomValues(new Uint8Array(12))
const wrappedVaultKey = await crypto.subtle.wrapKey('raw', vaultKey, auk, { name: 'AES-GCM', iv: vaultKeyNonce })
await authApi.changePassword({
current_password: currentPassword,
new_password: newPassword,
new_srp_salt: srpSaltHex,
new_srp_verifier: verifierHex,
encrypted_private_key: toBase64(wrappedPrivateKey),
private_key_nonce: toBase64(privateKeyNonce),
encrypted_vault_key: toBase64(wrappedVaultKey),
vault_key_nonce: toBase64(vaultKeyNonce),
public_key: toBase64(publicKeyBuffer),
pbkdf2_salt: toBase64(pbkdf2Salt),
hkdf_salt: toBase64(hkdfSalt),
})
} else {
// Legacy bcrypt user
await authApi.changePassword({
current_password: currentPassword,
new_password: newPassword,
})
}
toast.success('Password changed. Please sign in again.')
await logout()
void navigate({ to: '/login' })
} catch (err) {
setError(getErrorMessage(err, 'Failed to change password'))
} finally {
setIsSubmitting(false)
}
}, [currentPassword, newPassword, confirmPassword, isSrpUser, user, logout, navigate])
return (
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="current-password">Current Password</Label>
<Input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => { setCurrentPassword(e.target.value); setError(null) }}
placeholder="Enter current password"
autoComplete="current-password"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setError(null) }}
placeholder="Enter new password (min 8 characters)"
autoComplete="new-password"
minLength={8}
required
/>
<PasswordStrengthMeter password={newPassword} />
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setError(null) }}
placeholder="Re-enter new password"
autoComplete="new-password"
minLength={8}
required
/>
</div>
{isSrpUser && (
<p className="text-xs text-text-muted">
Your Secret Key will remain unchanged. Only your master password changes.
</p>
)}
{error && (
<div className="rounded-md bg-error/10 border border-error/30 px-3 py-2">
<p className="text-xs text-error">{error}</p>
</div>
)}
<Button
type="submit"
disabled={isSubmitting || !currentPassword || !newPassword || !confirmPassword || (newPassword.length > 0 && getPasswordScore(newPassword) < 3)}
className="w-full"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{isSrpUser ? 'Re-deriving keys...' : 'Changing password...'}
</>
) : (
<>
<Lock className="mr-2 h-4 w-4" />
Change Password
</>
)}
</Button>
</form>
)
}

View File

@@ -0,0 +1,454 @@
import { useState, useEffect } from 'react'
import { Link, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth, isSuperAdmin, isTenantAdmin } from '@/lib/auth'
import { authApi } from '@/lib/api'
import { getSMTPSettings, updateSMTPSettings, testSMTPSettings } from '@/lib/settingsApi'
import { SMTP_PRESETS } from '@/lib/smtpPresets'
import { Settings, User, Shield, Info, Key, Lock, ChevronRight, Download, Trash2, AlertTriangle, Mail } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ChangePasswordForm } from './ChangePasswordForm'
import { toast } from 'sonner'
function SectionHeader({ icon: Icon, title }: { icon: React.FC<{ className?: string }>; title: string }) {
return (
<div className="flex items-center gap-2 mb-3">
<Icon className="h-4 w-4 text-text-muted" />
<h2 className="text-sm font-medium text-text-secondary">{title}</h2>
</div>
)
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start gap-4 py-2 border-b border-border/50 last:border-0">
<span className="text-xs text-text-muted w-32 flex-shrink-0 pt-0.5">{label}</span>
<span className="text-sm text-text-primary flex-1">{value ?? '—'}</span>
</div>
)
}
export function SettingsPage() {
const { user, logout } = useAuth()
const navigate = useNavigate()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [deleteConfirmation, setDeleteConfirmation] = useState('')
const [isDeleting, setIsDeleting] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const handleExportData = async () => {
setIsExporting(true)
try {
await authApi.exportMyData()
toast.success('Data export downloaded successfully')
} catch {
toast.error('Failed to export data. Please try again.')
} finally {
setIsExporting(false)
}
}
const handleDeleteAccount = async () => {
if (deleteConfirmation !== 'DELETE') return
setIsDeleting(true)
try {
await authApi.deleteMyAccount('DELETE')
toast.success('Account deleted successfully')
await logout()
navigate({ to: '/login' })
} catch (err: unknown) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
toast.error(axiosErr?.response?.data?.detail || 'Failed to delete account')
} finally {
setIsDeleting(false)
setShowDeleteDialog(false)
setDeleteConfirmation('')
}
}
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-lg font-semibold">Settings</h1>
<p className="text-sm text-text-muted mt-0.5">Account and system information</p>
</div>
{/* Account section */}
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
<SectionHeader icon={User} title="Account" />
<InfoRow label="Email" value={user?.email} />
<InfoRow label="Role" value={
<span className="capitalize">{user?.role?.replace(/_/g, ' ')}</span>
} />
<InfoRow label="Tenant ID" value={
user?.tenant_id ? (
<span className="font-mono text-xs">{user.tenant_id}</span>
) : (
<span className="text-text-muted">Global (super admin)</span>
)
} />
</div>
{/* Password & Security section */}
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
<SectionHeader icon={Lock} title="Password & Security" />
<ChangePasswordForm />
</div>
{/* Permissions section */}
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
<SectionHeader icon={Shield} title="Permissions" />
<InfoRow label="Read devices" value="Yes" />
<InfoRow
label="Modify devices"
value={user?.role === 'operator' || user?.role === 'tenant_admin' || user?.role === 'super_admin' ? 'Yes' : 'No'}
/>
<InfoRow
label="Delete devices"
value={user?.role === 'tenant_admin' || user?.role === 'super_admin' ? 'Yes' : 'No'}
/>
<InfoRow
label="Manage organizations"
value={isSuperAdmin(user) ? 'Yes (super admin)' : 'No'}
/>
</div>
{/* System info section */}
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
<SectionHeader icon={Info} title="System" />
<InfoRow label="API" value={
<a
href="/api/docs"
target="_blank"
rel="noopener noreferrer"
className="text-info hover:text-accent"
>
/api/docs (OpenAPI)
</a>
} />
<InfoRow label="Version" value="v8.0" />
</div>
{/* Quick links */}
{isTenantAdmin(user) && (
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
<SectionHeader icon={Key} title="Integrations" />
<Link
to="/settings/api-keys"
className="flex items-center justify-between py-2 px-1 rounded hover:bg-elevated/30 transition-colors group"
>
<div>
<span className="text-sm text-text-primary">API Keys</span>
<p className="text-xs text-text-muted">Manage keys for programmatic access</p>
</div>
<ChevronRight className="h-4 w-4 text-text-muted group-hover:text-text-primary transition-colors" />
</Link>
</div>
)}
{/* System Email (SMTP) — super_admin only */}
{isSuperAdmin(user) && <SMTPSettingsSection />}
{/* Data & Privacy section */}
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-3">
<SectionHeader icon={Shield} title="Data & Privacy" />
{/* Export Data */}
<div className="flex items-center justify-between py-2">
<div>
<span className="text-sm text-text-primary">Export My Data</span>
<p className="text-xs text-text-muted">Download all your personal data as JSON (GDPR Art. 20)</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleExportData}
disabled={isExporting}
>
<Download className="h-3.5 w-3.5 mr-1.5" />
{isExporting ? 'Exporting...' : 'Export'}
</Button>
</div>
{/* Privacy Policy link */}
<div className="flex items-center justify-between py-2 border-t border-border/50">
<div>
<span className="text-sm text-text-primary">Privacy Policy</span>
<p className="text-xs text-text-muted">View our data practices and your rights</p>
</div>
<Link to="/privacy" className="text-sm text-accent hover:underline">
View
</Link>
</div>
{/* Delete Account */}
<div className="flex items-center justify-between py-2 border-t border-border/50">
<div>
<span className="text-sm text-destructive">Delete Account</span>
<p className="text-xs text-text-muted">Permanently delete your account and all personal data</p>
</div>
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/30 hover:bg-destructive/10"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
Delete
</Button>
</div>
</div>
{/* Delete Account Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={(open) => {
setShowDeleteDialog(open)
if (!open) setDeleteConfirmation('')
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
Delete Account
</DialogTitle>
<DialogDescription>
This action is permanent and cannot be undone. All your personal data will be erased.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3">
<p className="text-sm text-destructive font-medium">This will permanently:</p>
<ul className="text-sm text-text-secondary mt-1 space-y-1 list-disc pl-4">
<li>Delete your user account</li>
<li>Remove all your API keys</li>
<li>Erase your encryption keys</li>
<li>Anonymize your audit log entries</li>
</ul>
</div>
<div>
<Label htmlFor="delete-confirm" className="text-sm text-text-secondary">
Type <span className="font-mono font-bold text-text-primary">DELETE</span> to confirm
</Label>
<Input
id="delete-confirm"
value={deleteConfirmation}
onChange={(e) => setDeleteConfirmation(e.target.value)}
placeholder="DELETE"
className="mt-1.5 font-mono"
autoComplete="off"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowDeleteDialog(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteAccount}
disabled={deleteConfirmation !== 'DELETE' || isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete My Account'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function SMTPSettingsSection() {
const queryClient = useQueryClient()
const { data: smtp, isLoading } = useQuery({
queryKey: ['smtp-settings'],
queryFn: getSMTPSettings,
})
const [provider, setProvider] = useState('custom')
const [host, setHost] = useState('')
const [port, setPort] = useState('587')
const [smtpUser, setSmtpUser] = useState('')
const [password, setPassword] = useState('')
const [useTls, setUseTls] = useState(false)
const [fromAddress, setFromAddress] = useState('')
const [testTo, setTestTo] = useState('')
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
const [testing, setTesting] = useState(false)
useEffect(() => {
if (smtp) {
setProvider(smtp.smtp_provider || 'custom')
setHost(smtp.smtp_host || '')
setPort(String(smtp.smtp_port || 587))
setSmtpUser(smtp.smtp_user || '')
setUseTls(smtp.smtp_use_tls)
setFromAddress(smtp.smtp_from_address || '')
}
}, [smtp])
const saveMutation = useMutation({
mutationFn: () =>
updateSMTPSettings({
smtp_host: host,
smtp_port: Number(port),
smtp_user: smtpUser || undefined,
smtp_password: password || undefined,
smtp_use_tls: useTls,
smtp_from_address: fromAddress,
smtp_provider: provider,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['smtp-settings'] })
toast.success('SMTP settings saved')
setPassword('')
},
onError: () => toast.error('Failed to save SMTP settings'),
})
const handleProviderChange = (providerId: string) => {
setProvider(providerId)
const preset = SMTP_PRESETS.find((p) => p.id === providerId)
if (preset && providerId !== 'custom') {
setHost(preset.host)
setPort(String(preset.port))
setUseTls(preset.useTls)
}
}
const handleTestAndSave = async () => {
setTesting(true)
setTestResult(null)
try {
const result = await testSMTPSettings({
to: testTo,
smtp_host: host,
smtp_port: Number(port),
smtp_user: smtpUser || undefined,
smtp_password: password || undefined,
smtp_use_tls: useTls,
smtp_from_address: fromAddress,
})
setTestResult(result)
if (result.success) {
saveMutation.mutate()
}
} catch (e: any) {
setTestResult({ success: false, message: e.response?.data?.message || e.message })
} finally {
setTesting(false)
}
}
if (isLoading) return null
return (
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-3">
<div className="flex items-center justify-between">
<SectionHeader icon={Mail} title="System Email (SMTP)" />
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${
smtp?.source === 'database'
? 'text-success bg-success/10'
: 'text-text-muted bg-elevated'
}`}>
{smtp?.source === 'database' ? 'Database' : 'Environment'}
</span>
</div>
<div>
<Label className="text-xs">Email Provider</Label>
<select
value={provider}
onChange={(e) => handleProviderChange(e.target.value)}
className="w-full rounded-md bg-slate-700 border border-slate-600 text-white px-3 py-2 text-sm mt-1"
>
{SMTP_PRESETS.map((p) => (
<option key={p.id} value={p.id}>{p.label}</option>
))}
</select>
<p className="mt-1 text-xs text-text-muted">
{SMTP_PRESETS.find((p) => p.id === provider)?.helpText}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">SMTP Host</Label>
<Input value={host} onChange={(e) => setHost(e.target.value)} readOnly={provider !== 'custom'} />
</div>
<div>
<Label className="text-xs">Port</Label>
<Input type="number" value={port} onChange={(e) => setPort(e.target.value)} readOnly={provider !== 'custom'} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">Username</Label>
<Input value={smtpUser} onChange={(e) => setSmtpUser(e.target.value)} placeholder="user@example.com" />
</div>
<div>
<Label className="text-xs">Password</Label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={smtp?.smtp_password_set ? '(unchanged)' : ''}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">From Address</Label>
<Input value={fromAddress} onChange={(e) => setFromAddress(e.target.value)} placeholder="noreply@example.com" />
</div>
<div>
<Label className="text-xs">Test Recipient</Label>
<Input value={testTo} onChange={(e) => setTestTo(e.target.value)} placeholder="you@example.com" />
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={useTls}
onChange={(e) => setUseTls(e.target.checked)}
disabled={provider !== 'custom'}
id="smtp-tls-settings"
className="rounded"
/>
<Label htmlFor="smtp-tls-settings" className="text-xs">Use TLS (port 465)</Label>
</div>
{testResult && (
<p className={`text-sm ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
{testResult.message}
</p>
)}
<div className="flex gap-2 pt-1">
<Button
size="sm"
onClick={handleTestAndSave}
disabled={testing || !host || !testTo}
>
{testing ? 'Testing...' : 'Test & Save'}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !host}
>
Save without Testing
</Button>
</div>
</div>
)
}