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:
421
frontend/src/components/settings/ApiKeysPage.tsx
Normal file
421
frontend/src/components/settings/ApiKeysPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
222
frontend/src/components/settings/ChangePasswordForm.tsx
Normal file
222
frontend/src/components/settings/ChangePasswordForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
454
frontend/src/components/settings/SettingsPage.tsx
Normal file
454
frontend/src/components/settings/SettingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user