For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the single-pane SecuritySummaryContent.tsx with a full tab-based layout — Overview + 6 category tabs — matching the Admin Security Dashboard's visual structure.
Architecture: SecurityTabLayout.tsx owns activeTab local state and renders a TabList + lazy TabPanels. SecurityOverviewTab assembles existing hook data into the overview layout. Six focused tab components handle individual categories. All new components use Sheet (not Card), theme.palette.security.* tokens, and theme.palette.mode (never useColorScheme).
Tech Stack: React, MUI Joy (Tabs, TabList, TabPanel, Sheet, Chip, Stack, Typography), @mui/icons-material, @tanstack/react-query, Vitest + React Testing Library
Spec: docs/superpowers/specs/2026-03-31-profile-security-dashboard-tabs-design.md
| File | Action | Responsibility |
|---|---|---|
SecurityTab/index.tsx |
Modify | Swap SecuritySummaryContent → SecurityTabLayout |
SecurityTab/SecuritySummaryContent.tsx |
Delete | Replaced by new components |
SecurityTab/SecurityTabLayout.tsx |
Create | Tab shell: TabList + lazy TabPanels + Refresh button |
SecurityTab/overview/SecurityOverviewTab.tsx |
Create | Full overview layout assembly |
SecurityTab/overview/SecurityStatusCard.tsx |
Create | Score circle + Last detection + Security Checks |
SecurityTab/overview/SecurityMetricCard.tsx |
Create | Reusable clickable overview metric card |
SecurityTab/tabs/SuspiciousLoginsTab.tsx |
Create | Suspicious login patterns list |
SecurityTab/tabs/FailedLoginsTab.tsx |
Create | Failed login attempts list |
SecurityTab/tabs/ApiKeyStatusTab.tsx |
Create | API key status + alerts list |
SecurityTab/tabs/PhishingTestTab.tsx |
Create | Coming-soon placeholder panel |
SecurityTab/tabs/RecentActivityTab.tsx |
Create | All security events list (limit 50) |
SecurityTab/tabs/BlockedIPsTab.tsx |
Create | Blocked IPs list + unblock |
apps/client/app/hooks/data/admin.ts |
Modify | Add useGetAllRecentSecurityEvents (limit=50) |
Files:
- Modify:
apps/client/app/hooks/data/admin.ts - Test:
apps/client/app/hooks/data/__tests__/admin.security.test.ts
The RecentActivityTab needs events with limit=50. The existing useGetRecentSecurityEvents is hardcoded to limit=5. Add a new hook.
- Step 1: Add the hook to
apps/client/app/hooks/data/admin.tsafter the existinguseGetRecentSecurityEventsdefinition:
export const useGetAllRecentSecurityEvents = () => {
return useQuery({
queryKey: ['admin', 'security', 'user-recent', 'all'],
queryFn: async () => {
const response = await api.get<{
items: UserSecurityEvent[];
since: Date;
user: { email: string; username: string };
}>('/api/security/user-recent?limit=50&hours=168'); // 7 days for full tab
return { items: response.data.items, since: response.data.since };
},
});
};- Step 2: Run typecheck
cd /Users/allangargar/lumina5 && pnpm -r typecheckExpected: Exit 0, no errors.
- Step 3: Commit
git add apps/client/app/hooks/data/admin.ts
git commit -m "feat(secops): add useGetAllRecentSecurityEvents hook with limit=50"Files:
- Create:
apps/client/app/components/ProfileModal/SecurityTab/overview/SecurityMetricCard.tsx - Create:
apps/client/app/components/ProfileModal/SecurityTab/overview/__tests__/SecurityMetricCard.test.tsx
This is a purely presentational card. Export its props interface for use by SecurityOverviewTab.
- Step 1: Create the component
// apps/client/app/components/ProfileModal/SecurityTab/overview/SecurityMetricCard.tsx
import React from 'react';
import { Box, Sheet, Typography, useTheme } from '@mui/joy';
export interface SecurityMetricCardProps {
icon: React.ReactNode;
label: string;
value: string | number;
status: 'good' | 'high' | 'critical';
description?: string;
isLoading?: boolean;
onTabSelect?: () => void;
'data-testid'?: string;
}
const SecurityMetricCard: React.FC<SecurityMetricCardProps> = ({
icon,
label,
value,
status,
description,
isLoading = false,
onTabSelect,
'data-testid': testId,
}) => {
const theme = useTheme();
const borderColor = theme.palette.security[status].outlinedBorder;
const shadowColor = theme.palette.security[status].shadow;
return (
<Sheet
variant="outlined"
data-testid={testId}
onClick={onTabSelect}
sx={{
borderRadius: 'md',
p: 2,
border: `1px solid ${borderColor}`,
boxShadow: `0 0 16px ${shadowColor}`,
cursor: onTabSelect ? 'pointer' : 'default',
transition: 'opacity 0.15s',
'&:hover': onTabSelect ? { opacity: 0.85 } : {},
backgroundColor: theme.palette.background.surface,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Box sx={{ color: theme.palette.security[status].plainColor, display: 'flex' }}>
{icon}
</Box>
<Typography level="title-sm" sx={{ color: theme.palette.text.secondary }}>
{label}
</Typography>
</Box>
<Typography
level="h3"
data-testid={testId ? `${testId}-value` : undefined}
sx={{ color: theme.palette.security[status].plainColor, mb: 0.5 }}
>
{isLoading ? '—' : value}
</Typography>
{description && (
<Typography level="body-xs" sx={{ color: theme.palette.text.tertiary }}>
{description}
</Typography>
)}
</Sheet>
);
};
export default SecurityMetricCard;- Step 2: Write tests
// apps/client/app/components/ProfileModal/SecurityTab/overview/__tests__/SecurityMetricCard.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { CssVarsProvider } from '@mui/joy/styles';
import SecurityMetricCard from '../SecurityMetricCard';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<CssVarsProvider>{children}</CssVarsProvider>
);
describe('SecurityMetricCard', () => {
it('renders label and value', () => {
render(
<Wrapper>
<SecurityMetricCard icon={<span />} label="Failed Logins" value={3} status="high" data-testid="test-card" />
</Wrapper>
);
expect(screen.getByTestId('test-card')).toBeTruthy();
expect(screen.getByTestId('test-card-value').textContent).toBe('3');
expect(screen.getByText('Failed Logins')).toBeTruthy();
});
it('shows — when isLoading', () => {
render(
<Wrapper>
<SecurityMetricCard icon={<span />} label="Test" value={5} status="good" isLoading data-testid="loading-card" />
</Wrapper>
);
expect(screen.getByTestId('loading-card-value').textContent).toBe('—');
});
it('calls onTabSelect when clicked', () => {
const onTabSelect = vi.fn();
render(
<Wrapper>
<SecurityMetricCard icon={<span />} label="Test" value={0} status="good" onTabSelect={onTabSelect} data-testid="click-card" />
</Wrapper>
);
fireEvent.click(screen.getByTestId('click-card'));
expect(onTabSelect).toHaveBeenCalledOnce();
});
it('renders description when provided', () => {
render(
<Wrapper>
<SecurityMetricCard icon={<span />} label="Test" value={0} status="good" description="Last 24 hours" />
</Wrapper>
);
expect(screen.getByText('Last 24 hours')).toBeTruthy();
});
});- Step 3: Run tests
cd /Users/allangargar/lumina5 && pnpm -r test -- --reporter=verbose 2>&1 | grep -A 5 "SecurityMetricCard"Expected: 4 passing tests.
- Step 4: Run typecheck
pnpm -r typecheckExpected: Exit 0.
- Step 5: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/overview/
git commit -m "feat(secops): add SecurityMetricCard component"Files:
- Create:
apps/client/app/components/ProfileModal/SecurityTab/overview/SecurityStatusCard.tsx - Create:
apps/client/app/components/ProfileModal/SecurityTab/overview/__tests__/SecurityStatusCard.test.ts
Export two pure functions (getUserBadgeColors, getUserStatusLabel) for testability.
- Step 1: Create the component
// apps/client/app/components/ProfileModal/SecurityTab/overview/SecurityStatusCard.tsx
import React from 'react';
import { Box, Sheet, Typography, useTheme } from '@mui/joy';
export interface SecurityStatusCardProps {
riskScore: number;
riskLevel: 'low' | 'medium' | 'high';
passedChecks: number;
totalChecks: number;
lastDetection: Date | null;
isLoading: boolean;
}
interface BadgeColors {
gradient: string;
shadow: string;
textShadow: string;
}
export const getUserBadgeColors = (
riskScore: number,
riskLevel: 'low' | 'medium' | 'high',
palette: ReturnType<typeof useTheme>['palette']
): BadgeColors => {
if (riskLevel === 'high') {
return {
gradient: `linear-gradient(135deg, ${palette.security.critical.gradientStart}, ${palette.security.critical.gradientEnd})`,
shadow: `0 8px 24px ${palette.security.critical.shadow}`,
textShadow: '0 2px 4px rgba(0,0,0,0.2)',
};
}
if (riskLevel === 'medium' || riskScore < 50) {
return {
gradient: `linear-gradient(135deg, ${palette.security.high.gradientStart}, ${palette.security.high.gradientEnd})`,
shadow: `0 8px 24px ${palette.security.high.shadow}`,
textShadow: '0 2px 4px rgba(0,0,0,0.2)',
};
}
if (riskScore < 70) {
return {
gradient: `linear-gradient(135deg, ${palette.security.moderate.gradientStart}, ${palette.security.moderate.gradientEnd})`,
shadow: `0 8px 24px ${palette.security.moderate.shadow}`,
textShadow: '0 2px 4px rgba(0,0,0,0.15)',
};
}
if (riskScore < 85) {
return {
gradient: `linear-gradient(135deg, ${palette.security.good.gradientStart}, ${palette.security.good.gradientEnd})`,
shadow: `0 8px 24px ${palette.security.good.shadow}`,
textShadow: '0 2px 4px rgba(0,0,0,0.1)',
};
}
return {
gradient: `linear-gradient(135deg, ${palette.security.excellent.gradientStart}, ${palette.security.excellent.gradientEnd})`,
shadow: `0 8px 24px ${palette.security.excellent.shadow}`,
textShadow: '0 2px 4px rgba(0,0,0,0.1)',
};
};
export const getUserStatusLabel = (riskScore: number, riskLevel: 'low' | 'medium' | 'high'): string => {
if (riskLevel === 'high') return 'High Risk';
if (riskLevel === 'medium') return 'Moderate Risk';
if (riskScore >= 85) return 'Excellent';
if (riskScore >= 70) return 'Good';
return 'At Risk';
};
const SecurityStatusCard: React.FC<SecurityStatusCardProps> = ({
riskScore,
riskLevel,
passedChecks,
totalChecks,
lastDetection,
isLoading,
}) => {
const theme = useTheme();
const badgeColors = isLoading
? {
gradient: `linear-gradient(135deg, ${theme.palette.security.neutral.gradientStart}, ${theme.palette.security.neutral.gradientEnd})`,
shadow: `0 8px 24px ${theme.palette.security.neutral.shadow}`,
textShadow: 'none',
}
: getUserBadgeColors(riskScore, riskLevel, theme.palette);
const statusLabel = isLoading ? 'Loading…' : getUserStatusLabel(riskScore, riskLevel);
const allPassed = passedChecks === totalChecks;
const lastDetectionText = lastDetection
? lastDetection.toLocaleString('en-US', { timeZone: 'UTC', dateStyle: 'short', timeStyle: 'medium' }) + ' UTC'
: 'No events';
return (
<Sheet
variant="outlined"
data-testid="security-status-card"
sx={{
borderRadius: 'lg',
p: 3,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
minWidth: 220,
}}
>
{/* Score circle */}
<Box
data-testid="security-status-card-score"
sx={{
width: 148,
height: 148,
borderRadius: '50%',
background: badgeColors.gradient,
boxShadow: badgeColors.shadow,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
}}
>
<Typography
level="h3"
sx={{ color: 'white', textShadow: badgeColors.textShadow, lineHeight: 1 }}
>
{isLoading ? '…' : statusLabel}
</Typography>
</Box>
<Typography level="body-sm" sx={{ color: theme.palette.text.secondary }}>
Security Status
</Typography>
{/* Stats below circle */}
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography level="body-sm" sx={{ color: theme.palette.text.secondary }}>
Last detection
</Typography>
<Typography
level="body-sm"
data-testid="security-status-card-last-detection"
sx={{ color: theme.palette.text.primary, fontWeight: 'md' }}
>
{isLoading ? '…' : lastDetectionText}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography level="body-sm" sx={{ color: theme.palette.text.secondary }}>
Security Checks
</Typography>
<Typography
level="body-sm"
data-testid="security-status-card-checks"
sx={{
color: allPassed
? theme.palette.security.good.plainColor
: theme.palette.security.high.plainColor,
fontWeight: 'md',
}}
>
{isLoading ? '…' : `${passedChecks}/${totalChecks} Passed`}
</Typography>
</Box>
</Box>
</Sheet>
);
};
export default SecurityStatusCard;- Step 2: Write tests for the pure functions
// apps/client/app/components/ProfileModal/SecurityTab/overview/__tests__/SecurityStatusCard.test.ts
import { describe, it, expect } from 'vitest';
import { getUserStatusLabel } from '../SecurityStatusCard';
describe('getUserStatusLabel', () => {
it('returns "High Risk" for riskLevel high regardless of score', () => {
expect(getUserStatusLabel(90, 'high')).toBe('High Risk');
expect(getUserStatusLabel(10, 'high')).toBe('High Risk');
});
it('returns "Moderate Risk" for riskLevel medium', () => {
expect(getUserStatusLabel(60, 'medium')).toBe('Moderate Risk');
});
it('returns "Excellent" when score >= 85 and riskLevel low', () => {
expect(getUserStatusLabel(85, 'low')).toBe('Excellent');
expect(getUserStatusLabel(100, 'low')).toBe('Excellent');
});
it('returns "Good" when score >= 70 and < 85 and riskLevel low', () => {
expect(getUserStatusLabel(70, 'low')).toBe('Good');
expect(getUserStatusLabel(84, 'low')).toBe('Good');
});
it('returns "At Risk" when score < 70 and riskLevel low', () => {
expect(getUserStatusLabel(0, 'low')).toBe('At Risk');
expect(getUserStatusLabel(69, 'low')).toBe('At Risk');
});
});- Step 3: Run tests
cd /Users/allangargar/lumina5 && pnpm -r test -- --reporter=verbose 2>&1 | grep -A 10 "getUserStatusLabel"Expected: 5 passing tests.
- Step 4: Run typecheck
pnpm -r typecheckExpected: Exit 0.
- Step 5: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/overview/SecurityStatusCard.tsx \
apps/client/app/components/ProfileModal/SecurityTab/overview/__tests__/SecurityStatusCard.test.ts
git commit -m "feat(secops): add SecurityStatusCard with getUserBadgeColors/getUserStatusLabel"Files:
- Create:
apps/client/app/components/ProfileModal/SecurityTab/overview/SecurityOverviewTab.tsx
This component assembles all overview data and renders the full overview tab. It calls all hooks and passes computed props down to SecurityStatusCard, SecurityMetricCard, and the inline AI Assessment, Recent Activity, and Blocked IPs sections (migrated from SecuritySummaryContent.tsx).
Note: Read SecuritySummaryContent.tsx to copy the exact AI Assessment JSX block, the Recent Activity item renderer, and the Blocked IPs item renderer — these are migrated verbatim, only their wrapper context changes.
- Step 1: Create the component
// apps/client/app/components/ProfileModal/SecurityTab/overview/SecurityOverviewTab.tsx
import React from 'react';
import {
Box,
Chip,
Sheet,
Stack,
Typography,
useTheme,
CircularProgress,
Button,
} from '@mui/joy';
import {
Lock,
Key,
Mail,
Warning,
CheckCircle,
PersonSearch as PersonSearchIcon,
Block as BlockIcon,
MemoryOutlined as MemoryOutlinedIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import Bike4MindIcon from '@client/app/components/svgs/icons/Bike4MindIcon';
import {
useGetFailedLoginCount,
useGetSuspiciousSummary,
useGetBlockedIPs,
useGetApiUsage,
useGetRecentSecurityEvents,
useGetSecurityBehavioralSummary,
} from '@client/app/hooks/data/admin';
import { useQueryClient } from '@tanstack/react-query';
import SecurityStatusCard from './SecurityStatusCard';
import SecurityMetricCard from './SecurityMetricCard';
interface SecurityOverviewTabProps {
onTabSelect: (tab: string) => void;
}
const SecurityOverviewTab: React.FC<SecurityOverviewTabProps> = ({ onTabSelect }) => {
const theme = useTheme();
const mode = theme.palette.mode;
const queryClient = useQueryClient();
const failedLogins = useGetFailedLoginCount();
const suspiciousSummary = useGetSuspiciousSummary();
const blockedIPs = useGetBlockedIPs();
const apiUsage = useGetApiUsage();
const recentEvents = useGetRecentSecurityEvents();
const behavioralSummary = useGetSecurityBehavioralSummary();
// --- Derived values ---
const suspiciousCount = suspiciousSummary.data?.total ?? 0;
const failedCount = failedLogins.data?.total ?? 0;
const apiKeys = apiUsage.data ?? [];
const apiKeysWithAlerts = apiKeys.filter(k => k.alerts && k.alerts.length > 0);
const hasApiKeyIssues = apiKeysWithAlerts.length > 0;
const hasSuspiciousLogins = suspiciousCount > 0;
const hasFailedLogins = failedCount > 0;
// Security Checks pass/fail (4 checks; phishing always passes — no real data)
const passedChecks = [!hasSuspiciousLogins, !hasFailedLogins, !hasApiKeyIssues, true].filter(Boolean).length;
const totalChecks = 4;
// Last detection: most recent event timestamp
const events = recentEvents.data?.items ?? [];
const lastDetection = events.length > 0 ? new Date(events[0].timestamp) : null;
// Behavioral summary
const riskScore = Math.max(0, Math.min(100, Math.round(behavioralSummary.data?.riskScore ?? 0)));
const riskLevel = behavioralSummary.data?.riskLevel ?? 'low';
const isOverviewLoading =
failedLogins.isLoading || suspiciousSummary.isLoading || apiUsage.isLoading || behavioralSummary.isLoading;
// AI Assessment gradient
const aiGradient =
mode === 'dark'
? `linear-gradient(135deg, ${theme.palette.primary[600]}, ${theme.palette.primary[500]}, ${theme.palette.primary[400]})`
: `linear-gradient(135deg, ${theme.palette.primary.softBg}, ${theme.palette.primary.softHoverBg}, ${theme.palette.primary.softBg})`;
const handleRefresh = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin', 'security', 'user-summary', 'failed'] }),
queryClient.invalidateQueries({ queryKey: ['admin', 'security', 'user-summary', 'suspicious'] }),
queryClient.invalidateQueries({ queryKey: ['admin', 'security', 'user-recent', 'combined'] }),
queryClient.invalidateQueries({ queryKey: ['admin', 'security', 'blocked-ips'] }),
queryClient.invalidateQueries({ queryKey: ['admin', 'security', 'api-usage'] }),
queryClient.invalidateQueries({ queryKey: ['admin', 'security', 'behavioral-summary'] }),
]);
};
return (
<Box data-testid="security-overview-tab" sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Row 1: Status Card + AI Assessment */}
<Stack direction={{ xs: 'column', md: 'row' }} gap={2} alignItems="flex-start">
<SecurityStatusCard
riskScore={riskScore}
riskLevel={riskLevel}
passedChecks={passedChecks}
totalChecks={totalChecks}
lastDetection={lastDetection}
isLoading={isOverviewLoading}
/>
{/* AI Assessment card */}
<Sheet
variant="soft"
data-testid="security-summary-ai-card"
sx={{
flex: 1,
borderRadius: 'lg',
p: 3,
background: aiGradient,
border: mode === 'light' ? `1px solid ${theme.palette.primary.outlinedBorder}` : 'none',
boxShadow: mode === 'dark' ? `0 8px 24px ${theme.palette.primary[600]}33` : theme.shadow.sm,
}}
>
{/* AI card header */}
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" gap={1} mb={1}>
<Stack direction="row" alignItems="center" gap={1}>
<Sheet
variant="soft"
sx={{ p: 0.75, borderRadius: 'lg', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<MemoryOutlinedIcon fontSize="small" />
</Sheet>
<Box>
<Typography level="title-md">AI Security Assessment</Typography>
<Typography level="body-xs" sx={{ color: theme.palette.text.secondary }}>
{behavioralSummary.isLoading || behavioralSummary.isFetching
? 'Analyzing your security posture…'
: (behavioralSummary.data?.summary ?? 'No summary available.')}
</Typography>
</Box>
</Stack>
<Chip
size="sm"
variant="soft"
startDecorator={<Bike4MindIcon fill={theme.palette.primary.plainColor} size="14" />}
>
Powered by Bike4Mind
</Chip>
</Stack>
{/* Risk score bar */}
{behavioralSummary.data && (
<Box data-testid="ai-assessment-risk-score-bar" sx={{ mb: 1.5 }}>
<Stack direction="row" justifyContent="space-between" mb={0.5}>
<Typography level="body-xs">Risk Score</Typography>
<Typography level="body-xs">{riskScore}/100</Typography>
</Stack>
<Box
sx={{
height: 6,
borderRadius: 'sm',
background: mode === 'dark' ? 'rgba(255,255,255,0.1)' : theme.palette.neutral.softBg,
overflow: 'hidden',
}}
>
<Box
sx={{
height: '100%',
width: `${riskScore}%`,
background:
riskLevel === 'high'
? theme.palette.security.critical.solidBg
: riskLevel === 'medium'
? theme.palette.security.high.solidBg
: theme.palette.security.good.solidBg,
transition: 'width 0.5s ease',
}}
/>
</Box>
</Box>
)}
{/* Recommendation cards */}
{/* Profile behavioral summary returns string[] recommendations — Admin pattern uses structured objects */}
<Stack direction={{ xs: 'column', md: 'row' }} gap={1}>
{(behavioralSummary.data?.recommendations ?? [null, null, null]).map((rec, idx) => (
<Sheet
key={idx}
variant="soft"
sx={{ flex: 1, borderRadius: 'md', p: 1.5, minHeight: '80px' }}
>
{rec !== null && (
<Typography level="body-xs">{rec}</Typography>
)}
</Sheet>
))}
</Stack>
</Sheet>
</Stack>
{/* Row 2: 4 metric cards */}
<Box
data-testid="security-summary-metrics-grid"
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(4, 1fr)' },
gap: 2,
}}
>
<SecurityMetricCard
icon={<PersonSearchIcon />}
label="Suspicious Logins"
value={suspiciousCount}
status={hasSuspiciousLogins ? 'high' : 'good'}
description="Last 24 hours"
isLoading={suspiciousSummary.isLoading}
onTabSelect={() => onTabSelect('suspicious-logins')}
data-testid="suspicious-logins-card"
/>
<SecurityMetricCard
icon={<Lock />}
label="Failed Login Attempts"
value={failedCount}
status={hasFailedLogins ? 'high' : 'good'}
description="Last 24 hours"
isLoading={failedLogins.isLoading}
onTabSelect={() => onTabSelect('failed-logins')}
data-testid="failed-logins-card"
/>
<SecurityMetricCard
icon={<Key />}
label="API Key Status"
value={apiKeysWithAlerts.length > 0 ? `${apiKeysWithAlerts.length} Alert${apiKeysWithAlerts.length > 1 ? 's' : ''}` : 'All Clear'}
status={hasApiKeyIssues ? 'high' : 'good'}
description={`${apiKeys.length} key${apiKeys.length !== 1 ? 's' : ''} total`}
isLoading={apiUsage.isLoading}
onTabSelect={() => onTabSelect('api-keys')}
data-testid="api-key-status-card"
/>
<SecurityMetricCard
icon={<Mail />}
label="Last Phishing Test"
value="N/A"
status="good"
description="No data available"
onTabSelect={() => onTabSelect('phishing')}
data-testid="last-phishing-test-card"
/>
</Box>
{/* Row 3: Recent Activity + Blocked IPs */}
<Stack direction={{ xs: 'column', lg: 'row' }} gap={2}>
{/* Recent Activity */}
<Sheet
variant="outlined"
data-testid="security-summary-recent-activity-card"
sx={{ flex: 1, borderRadius: 'lg', p: 2 }}
>
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={1.5}>
<Typography level="title-sm">Recent Activity</Typography>
<Button
size="sm"
variant="plain"
color="neutral"
startDecorator={<RefreshIcon fontSize="small" />}
onClick={handleRefresh}
loading={recentEvents.isFetching}
data-testid="security-summary-refresh-btn"
>
Refresh
</Button>
</Stack>
{recentEvents.isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
<CircularProgress size="sm" />
</Box>
) : events.length === 0 ? (
<Typography level="body-sm" sx={{ color: theme.palette.text.tertiary, textAlign: 'center', py: 3 }}>
No recent security events
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{events.slice(0, 5).map((event, idx) => (
<Box
key={idx}
data-testid={`recent-activity-${event.type}-${idx}`}
sx={{
p: 1.5,
borderRadius: 'sm',
border: `1px solid ${event.type === 'suspicious_pattern' ? theme.palette.security.high.outlinedBorder : theme.palette.security.critical.outlinedBorder}`,
backgroundColor: theme.palette.background.surface,
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Stack direction="row" gap={0.75} alignItems="center">
{event.type === 'suspicious_pattern' ? (
<Warning fontSize="small" sx={{ color: theme.palette.security.high.plainColor }} />
) : (
<Lock fontSize="small" sx={{ color: theme.palette.security.critical.plainColor }} />
)}
<Typography level="body-xs">
{event.type === 'suspicious_pattern'
? `Suspicious: ${(event.data as { ip?: string }).ip ?? 'Unknown IP'}`
: `Failed login: ${(event.data as { username?: string }).username ?? 'Unknown user'}`}
</Typography>
</Stack>
<Typography level="body-xs" sx={{ color: theme.palette.text.tertiary, whiteSpace: 'nowrap' }}>
{new Date(event.timestamp).toLocaleTimeString()}
</Typography>
</Stack>
</Box>
))}
</Box>
)}
</Sheet>
{/* Blocked IPs */}
<Sheet
variant="outlined"
data-testid="security-summary-blocked-ips-card"
sx={{ flex: 1, borderRadius: 'lg', p: 2 }}
>
<Typography level="title-sm" mb={1.5}>
Blocked IP Addresses
</Typography>
{blockedIPs.isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
<CircularProgress size="sm" />
</Box>
) : !blockedIPs.data || blockedIPs.data.length === 0 ? (
<Stack direction="row" gap={1} alignItems="center" justifyContent="center" sx={{ py: 3 }}>
<CheckCircle sx={{ color: theme.palette.security.good.plainColor }} />
<Typography level="body-sm" sx={{ color: theme.palette.text.tertiary }}>
No IPs are currently blocked
</Typography>
</Stack>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{blockedIPs.data.slice(0, 5).map((item, idx) => (
<Box
key={idx}
data-testid={`blocked-ip-${idx}`}
sx={{
p: 1.5,
borderRadius: 'sm',
border: `1px solid ${theme.palette.security.high.outlinedBorder}`,
backgroundColor: theme.palette.background.surface,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Box>
<Typography level="body-xs" fontWeight="md">{item.ip}</Typography>
{item.reason && (
<Typography level="body-xs" sx={{ color: theme.palette.text.tertiary }}>
{item.reason}
</Typography>
)}
</Box>
<Button
size="sm"
variant="outlined"
color="neutral"
data-testid={`blocked-ip-unblock-btn-${idx}`}
>
Unblock
</Button>
</Box>
))}
</Box>
)}
</Sheet>
</Stack>
</Box>
);
};
export default SecurityOverviewTab;- Step 2: Run typecheck
cd /Users/allangargar/lumina5 && pnpm -r typecheckExpected: Exit 0.
- Step 3: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/overview/SecurityOverviewTab.tsx
git commit -m "feat(secops): add SecurityOverviewTab component"Files:
- Create:
apps/client/app/components/ProfileModal/SecurityTab/tabs/SuspiciousLoginsTab.tsx
Uses useGetRecentSuspiciousLogins (already exists in admin.ts) which returns { items: SuspiciousPatternSummary[], since: Date }.
- Step 1: Create the component
// apps/client/app/components/ProfileModal/SecurityTab/tabs/SuspiciousLoginsTab.tsx
import React from 'react';
import { Box, Chip, CircularProgress, Sheet, Stack, Typography, useTheme } from '@mui/joy';
import { PersonSearch as PersonSearchIcon, Warning } from '@mui/icons-material';
import { useGetSuspiciousSummary, useGetRecentSuspiciousLogins } from '@client/app/hooks/data/admin';
import type { SuspiciousPatternSummary } from '@client/app/hooks/data/admin';
const SuspiciousLoginsTab: React.FC = () => {
const theme = useTheme();
const summary = useGetSuspiciousSummary();
const details = useGetRecentSuspiciousLogins();
const total = summary.data?.total ?? 0;
const items: SuspiciousPatternSummary[] = details.data?.items ?? [];
const isLoading = summary.isLoading || details.isLoading;
return (
<Box data-testid="suspicious-logins-tab" sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Header */}
<Sheet variant="outlined" sx={{ borderRadius: 'md', p: 2 }}>
<Stack direction="row" alignItems="center" gap={1.5}>
<PersonSearchIcon sx={{ color: theme.palette.security[total > 0 ? 'high' : 'good'].plainColor }} />
<Typography level="title-md">Suspicious Logins</Typography>
<Chip
size="sm"
variant="soft"
data-testid="suspicious-logins-tab-count"
sx={{
backgroundColor: total > 0 ? theme.palette.security.high.softBg : theme.palette.security.good.softBg,
color: total > 0 ? theme.palette.security.high.softColor : theme.palette.security.good.softColor,
}}
>
{total} detected
</Chip>
</Stack>
<Typography level="body-xs" sx={{ color: theme.palette.text.secondary, mt: 0.5 }}>
Suspicious login patterns detected in the last 24 hours
</Typography>
</Sheet>
{/* List */}
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size="sm" />
</Box>
) : items.length === 0 ? (
<Sheet variant="soft" sx={{ borderRadius: 'md', p: 4, textAlign: 'center' }}>
<Typography level="body-sm" sx={{ color: theme.palette.text.tertiary }}>
No suspicious login patterns detected
</Typography>
</Sheet>
) : (
<Box data-testid="suspicious-logins-tab-list" sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{items.map((item, idx) => (
<Sheet
key={idx}
variant="soft"
sx={{
borderRadius: 'md',
p: 2,
borderLeft: `3px solid ${theme.palette.security.high.outlinedBorder}`,
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={1}>
<Stack direction="row" gap={1} alignItems="center">
<Warning fontSize="small" sx={{ color: theme.palette.security.high.plainColor }} />
<Typography level="body-sm" fontWeight="md">
{item.ip ?? 'Unknown IP'}
</Typography>
<Chip size="sm" variant="soft" sx={{ backgroundColor: theme.palette.security.high.softBg, color: theme.palette.security.high.softColor }}>
{item.riskLevel}
</Chip>
</Stack>
<Typography level="body-xs" sx={{ color: theme.palette.text.tertiary }}>
Last: {new Date(item.lastAttempt).toLocaleString('en-US', { timeZone: 'UTC' })} UTC
</Typography>
</Stack>
<Typography level="body-xs" sx={{ mt: 0.5, color: theme.palette.text.secondary }}>
{item.attempts} attempt{item.attempts !== 1 ? 's' : ''} · Usernames: {item.usernames.join(', ') || 'N/A'}
</Typography>
<Typography level="body-xs" sx={{ color: theme.palette.text.tertiary }}>
First seen: {new Date(item.firstAttempt).toLocaleString('en-US', { timeZone: 'UTC' })} UTC
</Typography>
</Sheet>
))}
</Box>
)}
{details.data && (
<Typography level="body-xs" sx={{ color: theme.palette.text.tertiary, textAlign: 'right' }}>
Last updated: {new Date(details.data.since).toLocaleString('en-US', { timeZone: 'UTC' })} UTC
</Typography>
)}
</Box>
);
};
export default SuspiciousLoginsTab;- Step 2: Run typecheck
cd /Users/allangargar/lumina5 && pnpm -r typecheckExpected: Exit 0.
- Step 3: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/tabs/SuspiciousLoginsTab.tsx
git commit -m "feat(secops): add SuspiciousLoginsTab component"Files:
- Create:
apps/client/app/components/ProfileModal/SecurityTab/tabs/FailedLoginsTab.tsx
Uses useGetFailedLoginCount (count) + useGetRecentFailedLogins (details, already in admin.ts) which returns { items: IAuthFailLogDocument[], since: Date }.
- Step 1: Create the component
// apps/client/app/components/ProfileModal/SecurityTab/tabs/FailedLoginsTab.tsx
import React from 'react';
import { Box, Chip, CircularProgress, Sheet, Stack, Typography, useTheme } from '@mui/joy';
import { Lock } from '@mui/icons-material';
import { useGetFailedLoginCount, useGetRecentFailedLogins } from '@client/app/hooks/data/admin';
import type { IAuthFailLogDocument } from '@b4m/database';
const FailedLoginsTab: React.FC = () => {
const theme = useTheme();
const countQuery = useGetFailedLoginCount();
const details = useGetRecentFailedLogins();
const total = countQuery.data?.total ?? 0;
const items: IAuthFailLogDocument[] = details.data?.items ?? [];
const isLoading = countQuery.isLoading || details.isLoading;
return (
<Box data-testid="failed-logins-tab" sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Header */}
<Sheet variant="outlined" sx={{ borderRadius: 'md', p: 2 }}>
<Stack direction="row" alignItems="center" gap={1.5}>
<Lock sx={{ color: theme.palette.security[total > 0 ? 'critical' : 'good'].plainColor }} />
<Typography level="title-md">Failed Login Attempts</Typography>
<Chip
size="sm"
variant="soft"
data-testid="failed-logins-tab-count"
sx={{
backgroundColor: total > 0 ? theme.palette.security.critical.softBg : theme.palette.security.good.softBg,
color: total > 0 ? theme.palette.security.critical.softColor : theme.palette.security.good.softColor,
}}
>
{total} in last 24h
</Chip>
</Stack>
<Typography level="body-xs" sx={{ color: theme.palette.text.secondary, mt: 0.5 }}>
Failed authentication attempts on your account
</Typography>
</Sheet>
{/* List */}
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size="sm" />
</Box>
) : items.length === 0 ? (
<Sheet variant="soft" sx={{ borderRadius: 'md', p: 4, textAlign: 'center' }}>
<Typography level="body-sm" sx={{ color: theme.palette.text.tertiary }}>
No failed login attempts in the last 24 hours
</Typography>
</Sheet>
) : (
<Box data-testid="failed-logins-tab-list" sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{items.map((item, idx) => (
<Sheet
key={idx}
variant="soft"
sx={{
borderRadius: 'md',
p: 2,
borderLeft: `3px solid ${theme.palette.security.critical.outlinedBorder}`,
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={1}>
<Stack direction="row" gap={1} alignItems="center">
<Lock fontSize="small" sx={{ color: theme.palette.security.critical.plainColor }} />
<Typography level="body-sm" fontWeight="md">
{item.username ?? 'Unknown user'}
</Typography>
{item.strategy && (
<Chip size="sm" variant="soft">{item.strategy}</Chip>
)}
</Stack>
<Typography level="body-xs" sx={{ color: theme.palette.text.tertiary }}>
{new Date(item.createdAt).toLocaleString('en-US', { timeZone: 'UTC' })} UTC
</Typography>
</Stack>
<Typography level="body-xs" sx={{ mt: 0.5, color: theme.palette.text.secondary }}>
IP: {item.ip ?? 'Unknown'} · Reason: {item.reason ?? 'N/A'}
</Typography>
</Sheet>
))}
</Box>
)}
{details.data && (
<Typography level="body-xs" sx={{ color: theme.palette.text.tertiary, textAlign: 'right' }}>
Last updated: {new Date(details.data.since).toLocaleString('en-US', { timeZone: 'UTC' })} UTC
</Typography>
)}
</Box>
);
};
export default FailedLoginsTab;- Step 2: Run typecheck
cd /Users/allangargar/lumina5 && pnpm -r typecheckExpected: Exit 0.
- Step 3: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/tabs/FailedLoginsTab.tsx
git commit -m "feat(secops): add FailedLoginsTab component"Files:
- Create:
apps/client/app/components/ProfileModal/SecurityTab/tabs/ApiKeyStatusTab.tsx
Uses useGetApiUsage which returns ApiKeyUsageItem[] (imported from admin.ts).
- Step 1: Create the component
// apps/client/app/components/ProfileModal/SecurityTab/tabs/ApiKeyStatusTab.tsx
import React from 'react';
import { Box, Chip, CircularProgress, Sheet, Stack, Typography, useTheme } from '@mui/joy';
import { Key, Warning } from '@mui/icons-material';
import { useGetApiUsage } from '@client/app/hooks/data/admin';
import type { ApiKeyUsageItem } from '@client/app/hooks/data/admin';
const ApiKeyStatusTab: React.FC = () => {
const theme = useTheme();
const apiUsage = useGetApiUsage();
const keys: ApiKeyUsageItem[] = apiUsage.data ?? [];
const keysWithAlerts = keys.filter(k => k.alerts && k.alerts.length > 0);
return (
<Box data-testid="api-key-status-tab" sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Header */}
<Sheet variant="outlined" sx={{ borderRadius: 'md', p: 2 }}>
<Stack direction="row" alignItems="center" gap={1.5}>
<Key sx={{ color: theme.palette.security[keysWithAlerts.length > 0 ? 'high' : 'good'].plainColor }} />
<Typography level="title-md">API Key Status</Typography>
<Chip
size="sm"
variant="soft"
data-testid="api-key-status-tab-count"
sx={{
backgroundColor: keysWithAlerts.length > 0 ? theme.palette.security.high.softBg : theme.palette.security.good.softBg,
color: keysWithAlerts.length > 0 ? theme.palette.security.high.softColor : theme.palette.security.good.softColor,
}}
>
{keys.length} key{keys.length !== 1 ? 's' : ''} · {keysWithAlerts.length} alert{keysWithAlerts.length !== 1 ? 's' : ''}
</Chip>
</Stack>
<Typography level="body-xs" sx={{ color: theme.palette.text.secondary, mt: 0.5 }}>
API key activity and security alerts on your account
</Typography>
</Sheet>
{/* List */}
{apiUsage.isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size="sm" />
</Box>
) : keys.length === 0 ? (
<Sheet variant="soft" sx={{ borderRadius: 'md', p: 4, textAlign: 'center' }}>
<Typography level="body-sm" sx={{ color: theme.palette.text.tertiary }}>
No API keys found
</Typography>
</Sheet>
) : (
<Box data-testid="api-key-status-tab-list" sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{keys.map((key, idx) => {
const hasAlerts = key.alerts && key.alerts.length > 0;
const statusColor = hasAlerts ? 'high' : key.status === 'active' ? 'good' : 'neutral';
return (
<Sheet
key={idx}
variant="outlined"
data-testid={`api-key-status-item-${idx}`}
sx={{
borderRadius: 'md',
p: 2,
borderLeft: `3px solid ${theme.palette.security[hasAlerts ? 'high' : 'good'].outlinedBorder}`,
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={1}>
<Stack direction="row" gap={1} alignItems="center">
<Key fontSize="small" sx={{ color: theme.palette.security[statusColor as 'good' | 'high' | 'critical'].plainColor }} />
<Typography level="body-sm" fontWeight="md">{key.name || 'Unnamed Key'}</Typography>
<Chip size="sm" variant="soft">{key.status}</Chip>
</Stack>
<Typography level="body-xs" sx={{ color: theme.palette.text.tertiary }}>
{key.lastUsedAt
? `Last used: ${new Date(key.lastUsedAt).toLocaleDateString()}`
: 'Never used'}
</Typography>
</Stack>
<Typography level="body-xs" sx={{ mt: 0.5, color: theme.palette.text.secondary }}>
Today: {key.usage.requestsToday} requests · Total: {key.usage.totalRequests}
</Typography>
{hasAlerts && (
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{key.alerts.map((alert, aIdx) => (
<Stack key={aIdx} direction="row" gap={0.75} alignItems="flex-start">
<Warning fontSize="small" sx={{ color: theme.palette.security.high.plainColor, mt: '1px' }} />
<Typography level="body-xs" sx={{ color: theme.palette.security.high.plainColor }}>
{alert.message}
</Typography>
</Stack>
))}
</Box>
)}
</Sheet>
);
})}
</Box>
)}
</Box>
);
};
export default ApiKeyStatusTab;- Step 2: Run typecheck
cd /Users/allangargar/lumina5 && pnpm -r typecheckExpected: Exit 0.
- Step 3: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/tabs/ApiKeyStatusTab.tsx
git commit -m "feat(secops): add ApiKeyStatusTab component"Files:
- Create:
apps/client/app/components/ProfileModal/SecurityTab/tabs/PhishingTestTab.tsx
No data hooks. Pure static coming-soon state.
- Step 1: Create the component
// apps/client/app/components/ProfileModal/SecurityTab/tabs/PhishingTestTab.tsx
import React from 'react';
import { Box, Sheet, Typography, useTheme } from '@mui/joy';
import { MailOutlined } from '@mui/icons-material';
const PhishingTestTab: React.FC = () => {
const theme = useTheme();
return (
<Box data-testid="phishing-test-tab" sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Header */}
<Sheet variant="outlined" sx={{ borderRadius: 'md', p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<MailOutlined sx={{ color: theme.palette.security.good.plainColor }} />
<Typography level="title-md">Last Phishing Test</Typography>
</Box>
<Typography level="body-xs" sx={{ color: theme.palette.text.secondary, mt: 0.5 }}>
Phishing simulation results for your account
</Typography>
</Sheet>
{/* Coming soon */}
<Sheet
variant="soft"
sx={{ borderRadius: 'lg', p: 6, textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}
>
<MailOutlined sx={{ fontSize: 48, color: theme.palette.neutral.plainColor, opacity: 0.4 }} />
<Typography level="title-md" sx={{ color: theme.palette.text.secondary }}>
Last Phishing Test
</Typography>
<Typography level="body-sm" sx={{ color: theme.palette.text.tertiary, maxWidth: 400 }}>
Phishing simulation data is not yet available. This feature is coming soon.
</Typography>
</Sheet>
</Box>
);
};
export default PhishingTestTab;- Step 2: Run typecheck
cd /Users/allangargar/lumina5 && pnpm -r typecheckExpected: Exit 0.
- Step 3: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/tabs/PhishingTestTab.tsx
git commit -m "feat(secops): add PhishingTestTab placeholder component"Files:
- Create:
apps/client/app/components/ProfileModal/SecurityTab/tabs/RecentActivityTab.tsx
Uses useGetAllRecentSecurityEvents (added in Task 1, limit=50).
- Step 1: Create the component
// apps/client/app/components/ProfileModal/SecurityTab/tabs/RecentActivityTab.tsx
import React from 'react';
import { Box, Chip, CircularProgress, Sheet, Stack, Typography, useTheme } from '@mui/joy';
import { History as HistoryIcon, Lock, Warning } from '@mui/icons-material';
import { useGetAllRecentSecurityEvents } from '@client/app/hooks/data/admin';
import type { UserSecurityEvent } from '@client/app/hooks/data/admin';
import type { SuspiciousPatternSummary } from '@client/app/hooks/data/admin';
import type { IAuthFailLogDocument } from '@b4m/database';
const RecentActivityTab: React.FC = () => {
const theme = useTheme();
const events = useGetAllRecentSecurityEvents();
const items: UserSecurityEvent[] = events.data?.items ?? [];
return (
<Box data-testid="recent-activity-tab" sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Header */}
<Sheet variant="outlined" sx={{ borderRadius: 'md', p: 2 }}>
<Stack direction="row" alignItems="center" gap={1.5}>
<HistoryIcon sx={{ color: theme.palette.security.neutral.plainColor }} />
<Typography level="title-md">Recent Activity</Typography>
<Chip size="sm" variant="soft" data-testid="recent-activity-tab-count">
{items.length} event{items.length !== 1 ? 's' : ''}
</Chip>
</Stack>
<Typography level="body-xs" sx={{ color: theme.palette.text.secondary, mt: 0.5 }}>
Security events on your account in the last 7 days
</Typography>
</Sheet>
{/* List */}
{events.isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size="sm" />
</Box>
) : items.length === 0 ? (
<Sheet variant="soft" sx={{ borderRadius: 'md', p: 4, textAlign: 'center' }}>
<Typography level="body-sm" sx={{ color: theme.palette.text.tertiary }}>
No recent security events
</Typography>
</Sheet>
) : (
<Box data-testid="recent-activity-tab-list" sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{items.map((event, idx) => {
const isSuspicious = event.type === 'suspicious_pattern';
const suspiciousData = isSuspicious ? (event.data as SuspiciousPatternSummary) : null;
const failedData = !isSuspicious ? (event.data as IAuthFailLogDocument) : null;
return (
<Sheet
key={idx}
variant="soft"
data-testid={`recent-activity-${event.type}-${idx}`}
sx={{
borderRadius: 'md',
p: 2,
borderLeft: `3px solid ${isSuspicious ? theme.palette.security.high.outlinedBorder : theme.palette.security.critical.outlinedBorder}`,
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={1}>
<Stack direction="row" gap={1} alignItems="center">
{isSuspicious ? (
<Warning fontSize="small" sx={{ color: theme.palette.security.high.plainColor }} />
) : (
<Lock fontSize="small" sx={{ color: theme.palette.security.critical.plainColor }} />
)}
<Chip
size="sm"
variant="soft"
sx={{
backgroundColor: isSuspicious ? theme.palette.security.high.softBg : theme.palette.security.critical.softBg,
color: isSuspicious ? theme.palette.security.high.softColor : theme.palette.security.critical.softColor,
}}
>
{isSuspicious ? 'Suspicious pattern' : 'Failed login'}
</Chip>
<Typography level="body-sm" fontWeight="md">
{isSuspicious
? (suspiciousData?.ip ?? 'Unknown IP')
: (failedData?.username ?? 'Unknown user')}
</Typography>
</Stack>
<Typography level="body-xs" sx={{ color: theme.palette.text.tertiary, whiteSpace: 'nowrap' }}>
{new Date(event.timestamp).toLocaleString('en-US', { timeZone: 'UTC' })} UTC
</Typography>
</Stack>
{isSuspicious && suspiciousData && (
<Typography level="body-xs" sx={{ mt: 0.5, color: theme.palette.text.secondary }}>
{suspiciousData.attempts} attempt{suspiciousData.attempts !== 1 ? 's' : ''} · Risk: {suspiciousData.riskLevel}
</Typography>
)}
{!isSuspicious && failedData && (
<Typography level="body-xs" sx={{ mt: 0.5, color: theme.palette.text.secondary }}>
IP: {failedData.ip ?? 'Unknown'} · {failedData.reason ?? ''}
</Typography>
)}
</Sheet>
);
})}
</Box>
)}
{events.data && (
<Typography level="body-xs" sx={{ color: theme.palette.text.tertiary, textAlign: 'right' }}>
Last updated: {new Date(events.data.since).toLocaleString('en-US', { timeZone: 'UTC' })} UTC
</Typography>
)}
</Box>
);
};
export default RecentActivityTab;- Step 2: Run typecheck
cd /Users/allangargar/lumina5 && pnpm -r typecheckExpected: Exit 0.
- Step 3: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/tabs/RecentActivityTab.tsx
git commit -m "feat(secops): add RecentActivityTab component"Files:
- Create:
apps/client/app/components/ProfileModal/SecurityTab/tabs/BlockedIPsTab.tsx
Uses useGetBlockedIPs. Unblock button preserved from SecuritySummaryContent.tsx.
- Step 1: Create the component
// apps/client/app/components/ProfileModal/SecurityTab/tabs/BlockedIPsTab.tsx
import React from 'react';
import { Box, Button, Chip, CircularProgress, Sheet, Stack, Typography, useTheme } from '@mui/joy';
import { Block as BlockIcon, CheckCircle } from '@mui/icons-material';
import { useGetBlockedIPs } from '@client/app/hooks/data/admin';
const BlockedIPsTab: React.FC = () => {
const theme = useTheme();
const blockedIPs = useGetBlockedIPs();
const items = blockedIPs.data ?? [];
return (
<Box data-testid="blocked-ips-tab" sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Header */}
<Sheet variant="outlined" sx={{ borderRadius: 'md', p: 2 }}>
<Stack direction="row" alignItems="center" gap={1.5}>
<BlockIcon sx={{ color: theme.palette.security[items.length > 0 ? 'high' : 'good'].plainColor }} />
<Typography level="title-md">Blocked IP Addresses</Typography>
<Chip
size="sm"
variant="soft"
data-testid="blocked-ips-tab-count"
sx={{
backgroundColor: items.length > 0 ? theme.palette.security.high.softBg : theme.palette.security.good.softBg,
color: items.length > 0 ? theme.palette.security.high.softColor : theme.palette.security.good.softColor,
}}
>
{items.length} blocked
</Chip>
</Stack>
<Typography level="body-xs" sx={{ color: theme.palette.text.secondary, mt: 0.5 }}>
IP addresses currently blocked from accessing your account
</Typography>
</Sheet>
{/* List */}
{blockedIPs.isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size="sm" />
</Box>
) : items.length === 0 ? (
<Sheet variant="soft" sx={{ borderRadius: 'md', p: 4, textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<CheckCircle sx={{ color: theme.palette.security.good.plainColor, fontSize: 32 }} />
<Typography level="body-sm" sx={{ color: theme.palette.text.tertiary }}>
No IPs are currently blocked
</Typography>
</Sheet>
) : (
<Box data-testid="blocked-ips-tab-list" sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{items.map((item, idx) => (
<Sheet
key={idx}
variant="outlined"
data-testid={`blocked-ip-${idx}`}
sx={{
borderRadius: 'md',
p: 2,
borderLeft: `3px solid ${theme.palette.security.high.outlinedBorder}`,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 2,
}}
>
<Box>
<Typography level="body-sm" fontWeight="md">{item.ip}</Typography>
{item.reason && (
<Typography level="body-xs" sx={{ color: theme.palette.text.secondary }}>
Reason: {item.reason}
</Typography>
)}
<Typography level="body-xs" sx={{ color: theme.palette.text.tertiary }}>
Blocked: {new Date(item.blockedAt).toLocaleString('en-US', { timeZone: 'UTC' })} UTC
</Typography>
</Box>
<Button
size="sm"
variant="outlined"
color="neutral"
data-testid={`blocked-ip-unblock-btn-${idx}`}
>
Unblock
</Button>
</Sheet>
))}
</Box>
)}
</Box>
);
};
export default BlockedIPsTab;- Step 2: Run typecheck
cd /Users/allangargar/lumina5 && pnpm -r typecheckExpected: Exit 0.
- Step 3: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/tabs/BlockedIPsTab.tsx
git commit -m "feat(secops): add BlockedIPsTab component"Files:
- Create:
apps/client/app/components/ProfileModal/SecurityTab/SecurityTabLayout.tsx
This is the top-level component that assembles all tabs. It renders the TabList, manages activeTab state, and lazy-renders each TabPanel.
- Step 1: Create the component
// apps/client/app/components/ProfileModal/SecurityTab/SecurityTabLayout.tsx
import React, { useState } from 'react';
import { Box, Button, Sheet, Stack, Tab, TabList, TabPanel, Tabs, Typography, useTheme } from '@mui/joy';
import {
HomeRounded,
PersonSearch as PersonSearchIcon,
Lock,
Key,
MailOutlined,
History as HistoryIcon,
Block as BlockIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import { useQueryClient } from '@tanstack/react-query';
import SecurityOverviewTab from './overview/SecurityOverviewTab';
import SuspiciousLoginsTab from './tabs/SuspiciousLoginsTab';
import FailedLoginsTab from './tabs/FailedLoginsTab';
import ApiKeyStatusTab from './tabs/ApiKeyStatusTab';
import PhishingTestTab from './tabs/PhishingTestTab';
import RecentActivityTab from './tabs/RecentActivityTab';
import BlockedIPsTab from './tabs/BlockedIPsTab';
type SecurityTabId =
| 'overview'
| 'suspicious-logins'
| 'failed-logins'
| 'api-keys'
| 'phishing'
| 'activity'
| 'blocked-ips';
const SECURITY_QUERY_KEYS = [
['admin', 'security', 'user-summary', 'failed'],
['admin', 'security', 'user-summary', 'suspicious'],
['admin', 'security', 'user-recent', 'combined'],
['admin', 'security', 'user-recent', 'all'],
['admin', 'security', 'user-recent', 'failed'],
['admin', 'security', 'user-recent', 'suspicious'],
['admin', 'security', 'blocked-ips'],
['admin', 'security', 'api-usage'],
['admin', 'security', 'behavioral-summary'],
] as const;
const SecurityTabLayout: React.FC = () => {
const [activeTab, setActiveTab] = useState<SecurityTabId>('overview');
const queryClient = useQueryClient();
const theme = useTheme();
const handleRefresh = async () => {
await Promise.all(
SECURITY_QUERY_KEYS.map(key => queryClient.invalidateQueries({ queryKey: key }))
);
};
return (
<Box data-testid="security-tab-layout" sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Page header */}
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box>
<Typography level="h3">Security Dashboard</Typography>
<Typography level="body-sm" sx={{ color: theme.palette.text.secondary }}>
Monitor your account security, login events, API key activity, and AI-powered behavioral risk assessment.
</Typography>
</Box>
<Button
size="sm"
variant="outlined"
color="neutral"
startDecorator={<RefreshIcon fontSize="small" />}
onClick={handleRefresh}
data-testid="security-summary-refresh-btn"
>
Refresh
</Button>
</Stack>
{/* Tabs */}
<Tabs
value={activeTab}
onChange={(_, val) => setActiveTab(val as SecurityTabId)}
sx={{ backgroundColor: 'transparent' }}
>
<Sheet
variant="outlined"
sx={{ borderRadius: 'md', mb: 2, overflow: 'hidden' }}
>
<TabList
sx={{
overflowX: 'auto',
flexWrap: 'nowrap',
'&::-webkit-scrollbar': { height: 4 },
}}
>
<Tab value="overview" data-testid="security-tab-overview">
<Stack direction="row" gap={0.75} alignItems="center">
<HomeRounded fontSize="small" />
<span>Overview</span>
</Stack>
</Tab>
<Tab value="suspicious-logins" data-testid="security-tab-suspicious-logins">
<Stack direction="row" gap={0.75} alignItems="center">
<PersonSearchIcon fontSize="small" />
<span>Suspicious Logins</span>
</Stack>
</Tab>
<Tab value="failed-logins" data-testid="security-tab-failed-logins">
<Stack direction="row" gap={0.75} alignItems="center">
<Lock fontSize="small" />
<span>Failed Login Attempts</span>
</Stack>
</Tab>
<Tab value="api-keys" data-testid="security-tab-api-keys">
<Stack direction="row" gap={0.75} alignItems="center">
<Key fontSize="small" />
<span>API Key Status</span>
</Stack>
</Tab>
<Tab value="phishing" data-testid="security-tab-phishing">
<Stack direction="row" gap={0.75} alignItems="center">
<MailOutlined fontSize="small" />
<span>Last Phishing Test</span>
</Stack>
</Tab>
<Tab value="activity" data-testid="security-tab-activity">
<Stack direction="row" gap={0.75} alignItems="center">
<HistoryIcon fontSize="small" />
<span>Recent Activity</span>
</Stack>
</Tab>
<Tab value="blocked-ips" data-testid="security-tab-blocked-ips">
<Stack direction="row" gap={0.75} alignItems="center">
<BlockIcon fontSize="small" />
<span>Blocked IPs</span>
</Stack>
</Tab>
</TabList>
</Sheet>
{/* Lazy panels — only active panel renders */}
<TabPanel value="overview" sx={{ p: 0 }}>
{activeTab === 'overview' && <SecurityOverviewTab onTabSelect={setActiveTab} />}
</TabPanel>
<TabPanel value="suspicious-logins" sx={{ p: 0 }}>
{activeTab === 'suspicious-logins' && <SuspiciousLoginsTab />}
</TabPanel>
<TabPanel value="failed-logins" sx={{ p: 0 }}>
{activeTab === 'failed-logins' && <FailedLoginsTab />}
</TabPanel>
<TabPanel value="api-keys" sx={{ p: 0 }}>
{activeTab === 'api-keys' && <ApiKeyStatusTab />}
</TabPanel>
<TabPanel value="phishing" sx={{ p: 0 }}>
{activeTab === 'phishing' && <PhishingTestTab />}
</TabPanel>
<TabPanel value="activity" sx={{ p: 0 }}>
{activeTab === 'activity' && <RecentActivityTab />}
</TabPanel>
<TabPanel value="blocked-ips" sx={{ p: 0 }}>
{activeTab === 'blocked-ips' && <BlockedIPsTab />}
</TabPanel>
</Tabs>
</Box>
);
};
export default SecurityTabLayout;- Step 2: Run typecheck
cd /Users/allangargar/lumina5 && pnpm -r typecheckExpected: Exit 0.
- Step 3: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/SecurityTabLayout.tsx
git commit -m "feat(secops): add SecurityTabLayout tab shell component"Files:
-
Modify:
apps/client/app/components/ProfileModal/SecurityTab/index.tsx -
Delete:
apps/client/app/components/ProfileModal/SecurityTab/SecuritySummaryContent.tsx -
Step 1: Read
index.tsxto understand current structure
cat apps/client/app/components/ProfileModal/SecurityTab/index.tsx- Step 2: Replace
SecuritySummaryContentimport withSecurityTabLayout
Current index.tsx imports SecuritySummaryContent and wraps it in a Box p={2}. Replace with SecurityTabLayout. The Box padding wrapper is no longer needed — SecurityTabLayout manages its own spacing.
Replace the entire file content with:
import React from 'react';
import { Box, Typography } from '@mui/joy';
import { useUser } from '@client/app/contexts/UserContext';
import SecurityTabLayout from './SecurityTabLayout';
const SecurityTab: React.FC = () => {
const { user } = useUser();
if (!user?.isAdmin && !user?.isBeta) {
return (
<Box sx={{ p: 3 }}>
<Typography level="h4">Security dashboard unavailable</Typography>
<Typography level="body-sm">
The security dashboard is currently available to admin and beta users only.
</Typography>
</Box>
);
}
return (
<Box sx={{ p: 2 }} data-testid="security-tab-root">
<SecurityTabLayout />
</Box>
);
};
export default SecurityTab;Note: Before making this change, read the actual index.tsx to verify the exact guard condition (isAdmin, isBeta, or a different check). Match it exactly — do not assume.
- Step 3: Delete
SecuritySummaryContent.tsx
rm apps/client/app/components/ProfileModal/SecurityTab/SecuritySummaryContent.tsx- Step 4: Run typecheck
cd /Users/allangargar/lumina5 && pnpm -r typecheckExpected: Exit 0. If there are errors, they will be import references to SecuritySummaryContent — find and remove them.
- Step 5: Run all tests
cd /Users/allangargar/lumina5 && pnpm -r testExpected: All passing (no regressions).
- Step 6: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/index.tsx
git rm apps/client/app/components/ProfileModal/SecurityTab/SecuritySummaryContent.tsx
git commit -m "feat(secops): wire SecurityTabLayout into index, delete SecuritySummaryContent"After all tasks are complete, verify:
-
pnpm -r typecheckpasses with 0 errors -
pnpm -r testpasses with 0 failures - Overview tab renders: status circle, AI card, 4 metric cards, recent activity, blocked IPs
- All 7 tabs navigate correctly from the TabList
- Clicking a metric card on Overview navigates to its detail tab
-
SecuritySummaryContent.tsxno longer exists - All
data-testidattributes from the original component are preserved:security-tab-root,security-summary-refresh-btn,security-summary-status-card(now inside overview),security-summary-ai-card,security-summary-metrics-grid,security-summary-recent-activity-card,security-summary-blocked-ips-card,api-key-status-card,blocked-ip-{idx},blocked-ip-unblock-btn-{idx},recent-activity-{type}-{idx},ai-assessment-risk-score-bar