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:
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
226
frontend/src/pages/settings/SSOSection.tsx
Normal file
226
frontend/src/pages/settings/SSOSection.tsx
Normal 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 & 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>
|
||||
)
|
||||
}
|
||||
100
frontend/src/pages/settings/SessionsSection.tsx
Normal file
100
frontend/src/pages/settings/SessionsSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user