Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save allan-gar2x/123022e54d99f41dd65726a9b38e5c71 to your computer and use it in GitHub Desktop.

Select an option

Save allan-gar2x/123022e54d99f41dd65726a9b38e5c71 to your computer and use it in GitHub Desktop.
Profile Security Dashboard Tabs — Implementation Plan

Profile Security Dashboard — Tab-Based Layout Implementation Plan

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 Map

File Action Responsibility
SecurityTab/index.tsx Modify Swap SecuritySummaryContentSecurityTabLayout
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)

Task 1: Add useGetAllRecentSecurityEvents hook

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.ts after the existing useGetRecentSecurityEvents definition:
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 typecheck

Expected: 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"

Task 2: SecurityMetricCard — reusable overview metric card

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 typecheck

Expected: Exit 0.

  • Step 5: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/overview/
git commit -m "feat(secops): add SecurityMetricCard component"

Task 3: SecurityStatusCard — score circle + checks

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 typecheck

Expected: 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"

Task 4: SecurityOverviewTab — full overview layout

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 typecheck

Expected: Exit 0.

  • Step 3: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/overview/SecurityOverviewTab.tsx
git commit -m "feat(secops): add SecurityOverviewTab component"

Task 5: SuspiciousLoginsTab

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 typecheck

Expected: Exit 0.

  • Step 3: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/tabs/SuspiciousLoginsTab.tsx
git commit -m "feat(secops): add SuspiciousLoginsTab component"

Task 6: FailedLoginsTab

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 typecheck

Expected: Exit 0.

  • Step 3: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/tabs/FailedLoginsTab.tsx
git commit -m "feat(secops): add FailedLoginsTab component"

Task 7: ApiKeyStatusTab

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 typecheck

Expected: Exit 0.

  • Step 3: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/tabs/ApiKeyStatusTab.tsx
git commit -m "feat(secops): add ApiKeyStatusTab component"

Task 8: PhishingTestTab — coming-soon placeholder

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 typecheck

Expected: 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"

Task 9: RecentActivityTab

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 typecheck

Expected: Exit 0.

  • Step 3: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/tabs/RecentActivityTab.tsx
git commit -m "feat(secops): add RecentActivityTab component"

Task 10: BlockedIPsTab

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 typecheck

Expected: Exit 0.

  • Step 3: Commit
git add apps/client/app/components/ProfileModal/SecurityTab/tabs/BlockedIPsTab.tsx
git commit -m "feat(secops): add BlockedIPsTab component"

Task 11: SecurityTabLayout — tab shell

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 typecheck

Expected: 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"

Task 12: Wire into index.tsx + delete SecuritySummaryContent.tsx

Files:

  • Modify: apps/client/app/components/ProfileModal/SecurityTab/index.tsx

  • Delete: apps/client/app/components/ProfileModal/SecurityTab/SecuritySummaryContent.tsx

  • Step 1: Read index.tsx to understand current structure

cat apps/client/app/components/ProfileModal/SecurityTab/index.tsx
  • Step 2: Replace SecuritySummaryContent import with SecurityTabLayout

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 typecheck

Expected: 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 test

Expected: 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"

Self-Review Checklist

After all tasks are complete, verify:

  • pnpm -r typecheck passes with 0 errors
  • pnpm -r test passes 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.tsx no longer exists
  • All data-testid attributes 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment