fase(25-26): keyboard shortcuts, mobile responsive, enterprise SSO/audit

- Phase 25.4: N shortcut for new exploration on dashboard (react-hotkeys-hook)
- Phase 25.5: overflow-x-auto on tables, responsive padding (p-4 md:p-6)
- Phase 26: SAML/OIDC/LDAP providers (build-fixed), TOTP/MFA service
- Phase 26: KyselySSOConfigRepository + KyselyTOTPRepository
- Phase 26: SSO HTTP controller (config CRUD + MFA setup/verify/disable)
- Phase 26: Audit module index.ts + SSO module index.ts
- Phase 26: Session management endpoints (findByUserId, deleteById, list/revoke)
- Phase 26: SSO and audit routes feature-gated (auth:sso, audit:logs)
- Phase 26: Frontend SSOSection (SAML/OIDC/LDAP config + TOTP setup)
- Phase 26: Frontend SessionsSection (list/revoke active sessions)
- Phase 26: Settings navigation updated with SSO & Sessions sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
debian
2026-03-08 13:38:25 -04:00
parent c3911bafe8
commit 08011d22d5
58 changed files with 2689 additions and 23 deletions

View File

@@ -23,6 +23,8 @@ import { IntegrationsSection } from '@/pages/settings/IntegrationsSection'
import { AppearanceSection } from '@/pages/settings/AppearanceSection'
import { LicenseSection } from '@/pages/settings/LicenseSection'
import { SchedulesSection } from '@/pages/settings/SchedulesSection'
import { SSOSection } from '@/pages/settings/SSOSection'
import { SessionsSection } from '@/pages/settings/SessionsSection'
import { Reports } from '@/pages/Reports'
import { VisualReview } from '@/pages/VisualReview'
import { ErrorBoundary } from '@/components/layout/ErrorBoundary'
@@ -55,6 +57,8 @@ export default function App() {
<Route path="profile" element={<ProfileSection />} />
<Route path="organization" element={<OrganizationSection />} />
<Route path="api-keys" element={<ApiKeysSection />} />
<Route path="sessions" element={<SessionsSection />} />
<Route path="sso" element={<SSOSection />} />
<Route path="defaults" element={<ExplorationDefaultsSection />} />
<Route path="schedules" element={<SchedulesSection />} />
<Route path="notifications" element={<NotificationsSection />} />

View File

@@ -11,7 +11,7 @@ export function AppLayout() {
<AppSidebar />
<div className="flex flex-col flex-1 overflow-hidden">
<TopBar />
<main className="flex-1 overflow-auto p-6">
<main className="flex-1 overflow-auto p-4 md:p-6">
<Outlet />
</main>
</div>

View File

@@ -1,5 +1,7 @@
import { useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { useHotkeys } from 'react-hotkeys-hook'
import { KPICards } from '@/components/dashboard/KPICards'
import { TrendChart } from '@/components/dashboard/TrendChart'
import { SeverityDistribution } from '@/components/dashboard/SeverityDistribution'
@@ -12,6 +14,10 @@ import { useSocket } from '@/hooks/useSocket'
export function Dashboard() {
const queryClient = useQueryClient()
const navigate = useNavigate()
// N → new exploration shortcut (only when not in input)
useHotkeys('n', () => { navigate('/sessions?new=1') }, { preventDefault: true })
const { data: findings = [], isLoading: findingsLoading } = useFindings()
const { data: stats, isLoading: statsLoading } = useFindingStats()

View File

@@ -140,7 +140,7 @@ export function FindingsList() {
{[1, 2, 3, 4, 5].map(i => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : (
<div className="rounded-md border">
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map(hg => (

View File

@@ -150,7 +150,7 @@ export function SessionList() {
{[1, 2, 3].map(i => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : (
<div className="rounded-md border">
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map(hg => (

View File

@@ -0,0 +1,226 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { apiFetch } from '@/lib/api'
import { toast } from 'sonner'
interface SSOConfig {
id: string
provider: 'saml' | 'oidc' | 'ldap'
enabled: boolean
config: Record<string, string>
createdAt: string
}
interface MFAStatus {
enabled: boolean
verified: boolean
}
type SSOProvider = 'saml' | 'oidc' | 'ldap'
export function SSOSection() {
const queryClient = useQueryClient()
const { data: ssoConfig } = useQuery<SSOConfig | null>({
queryKey: ['sso-config'],
queryFn: () => apiFetch<SSOConfig | null>('/api/sso/config'),
})
const { data: mfaStatus } = useQuery<MFAStatus>({
queryKey: ['mfa-status'],
queryFn: () => apiFetch<MFAStatus>('/api/sso/mfa/status'),
})
const [provider, setProvider] = useState<SSOProvider>('saml')
const [configFields, setConfigFields] = useState<Record<string, string>>({})
const [mfaToken, setMfaToken] = useState('')
const [showMFASetup, setShowMFASetup] = useState(false)
const [otpauthUrl, setOtpauthUrl] = useState('')
const saveSSOConfig = useMutation({
mutationFn: (data: { provider: SSOProvider; enabled: boolean; config: Record<string, string> }) =>
apiFetch('/api/sso/config', { method: 'PUT', body: JSON.stringify(data) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['sso-config'] })
toast.success('SSO configuration saved')
},
})
const setupMFA = useMutation({
mutationFn: () =>
apiFetch<{ otpauthUrl: string; secret: string }>('/api/sso/mfa/setup', {
method: 'POST',
body: JSON.stringify({}),
}),
onSuccess: (data) => {
setOtpauthUrl(data.otpauthUrl)
setShowMFASetup(true)
},
})
const verifyMFA = useMutation({
mutationFn: (token: string) =>
apiFetch('/api/sso/mfa/verify', { method: 'POST', body: JSON.stringify({ token }) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
setShowMFASetup(false)
toast.success('MFA enabled successfully')
},
onError: () => toast.error('Invalid token'),
})
const disableMFA = useMutation({
mutationFn: () => apiFetch('/api/sso/mfa', { method: 'DELETE' }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
toast.success('MFA disabled')
},
})
function getProviderFields(): { key: string; label: string; placeholder?: string }[] {
switch (provider) {
case 'saml':
return [
{ key: 'entryPoint', label: 'IdP Entry Point URL', placeholder: 'https://idp.example.com/saml/sso' },
{ key: 'issuer', label: 'Issuer / Entity ID', placeholder: 'abe-app' },
{ key: 'cert', label: 'IdP Certificate (PEM)', placeholder: '-----BEGIN CERTIFICATE-----...' },
{ key: 'callbackUrl', label: 'Callback URL', placeholder: 'https://abe.example.com/api/sso/saml/callback' },
]
case 'oidc':
return [
{ key: 'issuer', label: 'OIDC Issuer URL', placeholder: 'https://accounts.google.com' },
{ key: 'clientId', label: 'Client ID' },
{ key: 'clientSecret', label: 'Client Secret' },
{ key: 'redirectUri', label: 'Redirect URI', placeholder: 'https://abe.example.com/api/sso/oidc/callback' },
]
case 'ldap':
return [
{ key: 'url', label: 'LDAP URL', placeholder: 'ldap://ldap.example.com:389' },
{ key: 'baseDN', label: 'Base DN', placeholder: 'dc=example,dc=com' },
{ key: 'bindDN', label: 'Bind DN (optional)', placeholder: 'cn=admin,dc=example,dc=com' },
{ key: 'bindPassword', label: 'Bind Password (optional)' },
{ key: 'userSearchFilter', label: 'User Search Filter', placeholder: '(uid={username})' },
]
}
}
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold">SSO &amp; Security</h3>
<p className="text-sm text-muted-foreground">
Configure single sign-on and multi-factor authentication. Requires enterprise license.
</p>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Single Sign-On</CardTitle>
<CardDescription>Configure your identity provider.</CardDescription>
</div>
{ssoConfig && (
<Badge variant={ssoConfig.enabled ? 'default' : 'secondary'}>
{ssoConfig.enabled ? 'Enabled' : 'Disabled'}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Provider</Label>
<Select value={provider} onValueChange={(v) => setProvider(v as SSOProvider)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="saml">SAML 2.0</SelectItem>
<SelectItem value="oidc">OpenID Connect (OIDC)</SelectItem>
<SelectItem value="ldap">LDAP / Active Directory</SelectItem>
</SelectContent>
</Select>
</div>
{getProviderFields().map((field) => (
<div key={field.key} className="space-y-2">
<Label>{field.label}</Label>
<Input
value={configFields[field.key] ?? ssoConfig?.config[field.key] ?? ''}
placeholder={field.placeholder}
onChange={(e) => setConfigFields((prev) => ({ ...prev, [field.key]: e.target.value }))}
/>
</div>
))}
<Button
onClick={() => saveSSOConfig.mutate({ provider, enabled: true, config: configFields })}
disabled={saveSSOConfig.isPending}
>
Save SSO Configuration
</Button>
</CardContent>
</Card>
<Separator />
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Multi-Factor Authentication</CardTitle>
<CardDescription>Add TOTP-based MFA to your account.</CardDescription>
</div>
{mfaStatus && (
<Badge variant={mfaStatus.verified ? 'default' : 'secondary'}>
{mfaStatus.verified ? 'Active' : mfaStatus.enabled ? 'Pending Verification' : 'Not Enabled'}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{!mfaStatus?.enabled && !showMFASetup && (
<Button onClick={() => setupMFA.mutate()} disabled={setupMFA.isPending}>
Enable MFA
</Button>
)}
{showMFASetup && (
<div className="space-y-4">
<div className="p-3 bg-muted rounded-md text-sm">
<p className="font-medium mb-1">Setup Instructions:</p>
<ol className="list-decimal list-inside space-y-1 text-muted-foreground">
<li>Open your authenticator app (Google Authenticator, Authy, etc.)</li>
<li>Scan the QR code or enter the key URL manually into your app</li>
<li>Enter the 6-digit code below to verify</li>
</ol>
<p className="mt-2 font-mono text-xs break-all">{otpauthUrl}</p>
</div>
<div className="flex gap-2">
<Input
placeholder="Enter 6-digit code"
value={mfaToken}
onChange={(e) => setMfaToken(e.target.value)}
maxLength={6}
className="max-w-xs"
/>
<Button
onClick={() => verifyMFA.mutate(mfaToken)}
disabled={verifyMFA.isPending || mfaToken.length !== 6}
>
Verify
</Button>
</div>
</div>
)}
{mfaStatus?.verified && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Switch checked={true} disabled />
<span className="text-sm">MFA is active on this account</span>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => disableMFA.mutate()}
disabled={disableMFA.isPending}
>
Disable MFA
</Button>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,100 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Monitor, Trash2 } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { toast } from 'sonner'
interface ActiveSession {
id: string
createdAt: string
expiresAt: string
}
export function SessionsSection() {
const queryClient = useQueryClient()
const { data: sessions = [], isLoading } = useQuery<ActiveSession[]>({
queryKey: ['auth-sessions'],
queryFn: () => apiFetch<ActiveSession[]>('/api/auth/sessions'),
})
const revokeSession = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/auth/sessions/${id}`, { method: 'DELETE' }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['auth-sessions'] })
toast.success('Session revoked')
},
})
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold">Active Sessions</h3>
<p className="text-sm text-muted-foreground">
View and revoke active login sessions for your account.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Sessions</CardTitle>
<CardDescription>
{sessions.length} active session{sessions.length !== 1 ? 's' : ''}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{isLoading && (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
</div>
)}
{!isLoading && sessions.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">No active sessions found.</p>
)}
{sessions.map((session) => {
const createdAt = new Date(session.createdAt)
const expiresAt = new Date(session.expiresAt)
const isExpiringSoon = expiresAt.getTime() - Date.now() < 24 * 60 * 60 * 1000
return (
<div key={session.id} className="flex items-center justify-between p-3 rounded-lg border">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center">
<Monitor className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Session {session.id.slice(0, 8)}</span>
{isExpiringSoon && (
<Badge variant="outline" className="text-xs">Expiring soon</Badge>
)}
</div>
<div className="text-xs text-muted-foreground">
Created {createdAt.toLocaleString()} · Expires {expiresAt.toLocaleString()}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => revokeSession.mutate(session.id)}
disabled={revokeSession.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
})}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,11 +1,13 @@
import { NavLink, Outlet } from 'react-router-dom'
import { User, Building, Key, Sliders, Bell, Palette, Shield, Plug, Clock } from 'lucide-react'
import { User, Building, Key, Sliders, Bell, Palette, Shield, Plug, Clock, Lock, Monitor } from 'lucide-react'
import { cn } from '@/lib/utils'
const navItems = [
{ label: 'Profile', href: '/settings/profile', icon: User },
{ label: 'Organization', href: '/settings/organization', icon: Building },
{ label: 'API Keys', href: '/settings/api-keys', icon: Key },
{ label: 'Sessions', href: '/settings/sessions', icon: Monitor },
{ label: 'SSO & MFA', href: '/settings/sso', icon: Lock },
{ label: 'Exploration Defaults', href: '/settings/defaults', icon: Sliders },
{ label: 'Schedules', href: '/settings/schedules', icon: Clock },
{ label: 'Notifications', href: '/settings/notifications', icon: Bell },