Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,28 @@ For multi-step tasks, state a brief plan:

Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.

## 5. Learn From Corrections

**When corrected, capture the pattern. Don't repeat the mistake.**

After any user correction or rewrite:
- Identify the rule that would have prevented it.
- Log it in `tasks/lessons.md` as a short, actionable rule.
- Review relevant lessons at the start of related tasks.

The goal: mistake rate drops over time, not just within a session.

## 6. Autonomous Execution

**When the problem is clear, just solve it. Don't ask to be led.**

If you have enough signal (error message, failing test, logs, reproduction steps):
- Fix it. Don't ask for step-by-step hand-holding.
- Point at the evidence, state what you're doing, then do it.
- Over-asking when the answer is visible wastes the user's time.

The line: ask when genuinely ambiguous. Execute when it's not.

---

**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
34 changes: 34 additions & 0 deletions app/admin/activity-logs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@
'poc_uploaded',
'report_viewed',
'report_decrypted',
'comment_posted',
'scope_created',
'scope_updated',
'scope_archived',
'template_used',
'role_changed',
'user_suspended',
'user_unsuspended',
];

const ACTION_ICONS: Record<string, string> = {
Expand All @@ -39,6 +47,14 @@
poc_uploaded: '📎',
report_viewed: '👁️',
report_decrypted: '🔓',
comment_posted: '💬',
scope_created: '🎯',
scope_updated: '✏️',
scope_archived: '📦',
template_used: '📄',
role_changed: '🎭',
user_suspended: '🚫',
user_unsuspended: '✅',
};

const ACTION_COLORS: Record<string, string> = {
Expand All @@ -49,12 +65,21 @@
poc_uploaded: 'bg-indigo-100 text-indigo-700',
report_viewed: 'bg-gray-100 text-gray-700',
report_decrypted: 'bg-yellow-100 text-yellow-700',
comment_posted: 'bg-sky-100 text-sky-700',
scope_created: 'bg-teal-100 text-teal-700',
scope_updated: 'bg-amber-100 text-amber-700',
scope_archived: 'bg-stone-100 text-stone-600',
template_used: 'bg-cyan-100 text-cyan-700',
role_changed: 'bg-violet-100 text-violet-700',
user_suspended: 'bg-red-100 text-red-700',
user_unsuspended: 'bg-emerald-100 text-emerald-700',
};

export default function ActivityLogsPage() {
const [logs, setLogs] = useState<ActivityLog[]>([]);
const [loading, setLoading] = useState(true);
const [toast, setToast] = useState<Toast | null>(null);
const [scoped, setScoped] = useState(false);

// Filters
const [actionFilter, setActionFilter] = useState<string>('');
Expand All @@ -79,7 +104,7 @@

useEffect(() => {
fetchLogs();
}, [currentPage, actionFilter, startDate, endDate]);

Check warning on line 107 in app/admin/activity-logs/page.tsx

View workflow job for this annotation

GitHub Actions / Lint & Type Check

React Hook useEffect has a missing dependency: 'fetchLogs'. Either include it or remove the dependency array

async function fetchLogs() {
try {
Expand All @@ -99,6 +124,7 @@
setLogs(data.logs || []);
setTotalPages(data.pagination.totalPages);
setTotal(data.pagination.total);
setScoped(data.scoped || false);
} catch (error) {
console.error('[fetchLogs]', error);
setToast({ message: 'Failed to load activity logs', type: 'error' });
Expand Down Expand Up @@ -130,7 +156,7 @@
document.body.removeChild(a);

setToast({ message: 'Logs exported successfully!', type: 'success' });
} catch (error: any) {

Check failure on line 159 in app/admin/activity-logs/page.tsx

View workflow job for this annotation

GitHub Actions / Lint & Type Check

Unexpected any. Specify a different type
setToast({ message: error.message, type: 'error' });
} finally {
setExporting(false);
Expand Down Expand Up @@ -191,6 +217,14 @@
</button>
</div>

{/* Scoped notice for triagers */}
{scoped && (
<div className="bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 mb-6 flex items-center gap-3 text-sm text-blue-700">
<span>🔍</span>
<span>Showing your own actions only. Admins can view all platform activity.</span>
</div>
)}

{/* Stats */}
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="flex items-center gap-6 text-sm">
Expand Down
4 changes: 2 additions & 2 deletions app/api/admin/activity-logs/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@

export async function GET(request: NextRequest) {
try {
await requireRole('ADMIN');
const { userId, role } = await requireRole('TRIAGER');

const sp = request.nextUrl.searchParams;
const action = sp.get('action');
const actorId = sp.get('actor_id');
const actorId = role === 'ADMIN' ? sp.get('actor_id') : userId;
const reportId = sp.get('report_id');
const startDate = sp.get('start_date');
const endDate = sp.get('end_date');
Expand All @@ -21,7 +21,7 @@

// Build WHERE conditions (same as main endpoint)
const conditions: string[] = [];
const params: any[] = [];

Check failure on line 24 in app/api/admin/activity-logs/export/route.ts

View workflow job for this annotation

GitHub Actions / Lint & Type Check

Unexpected any. Specify a different type

if (action) {
conditions.push('action = ?');
Expand Down
7 changes: 5 additions & 2 deletions app/api/admin/activity-logs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@

export async function GET(request: NextRequest) {
try {
await requireRole('ADMIN'); // Only admins can view activity logs
// TRIAGERs can view their own logs; ADMINs can view all logs
const { userId, role } = await requireRole('TRIAGER');

const sp = request.nextUrl.searchParams;
const action = sp.get('action');
const actorId = sp.get('actor_id');
// TRIAGERs are automatically scoped to their own actor_id
const actorId = role === 'ADMIN' ? sp.get('actor_id') : userId;
const reportId = sp.get('report_id');
const startDate = sp.get('start_date');
const endDate = sp.get('end_date');
Expand All @@ -26,7 +28,7 @@

// Build WHERE conditions
const conditions: string[] = [];
const params: any[] = [];

Check failure on line 31 in app/api/admin/activity-logs/route.ts

View workflow job for this annotation

GitHub Actions / Lint & Type Check

Unexpected any. Specify a different type

if (action) {
conditions.push('action = ?');
Expand Down Expand Up @@ -74,7 +76,7 @@

// Enrich logs with actor names from Clerk
const enrichedLogs = await Promise.all(
logs.map(async (log: any) => {

Check failure on line 79 in app/api/admin/activity-logs/route.ts

View workflow job for this annotation

GitHub Actions / Lint & Type Check

Unexpected any. Specify a different type
let actorName = 'System';
if (log.actor_id && log.actor_id.startsWith('user_')) {
try {
Expand All @@ -100,6 +102,7 @@
total,
totalPages: Math.ceil(total / limit),
},
scoped: role !== 'ADMIN',
});
} catch (err) {
if (err instanceof Response) return err;
Expand Down
24 changes: 22 additions & 2 deletions app/api/admin/scopes/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getDb, getCfEnv } from '@/lib/db';
import { scopes } from '@/lib/db/schema';
import { requireRole } from '@/lib/auth';
import { logAudit } from '@/lib/audit';
import { z } from 'zod';
import { eq, isNull, and } from 'drizzle-orm';
import { VULN_TYPES, SEVERITIES } from '@/lib/validation';
Expand All @@ -28,7 +29,7 @@ export async function PATCH(
context: { params: Promise<{ id: string }> },
) {
try {
await requireRole('ADMIN');
const { userId } = await requireRole('ADMIN');
const { id } = await context.params;

let body: unknown;
Expand Down Expand Up @@ -78,6 +79,16 @@ export async function PATCH(
await db.update(scopes).set(updateData).where(eq(scopes.id, id));
const updated = await db.select().from(scopes).where(eq(scopes.id, id)).get();

await logAudit({
db,
entityType: 'system',
entityId: id,
actorId: userId,
action: data.restore ? 'scope_updated' : 'scope_updated',
oldValue: existing.domain,
newValue: data.domain ?? existing.domain,
});

return NextResponse.json({ success: true, scope: updated });
} catch (err) {
if (err instanceof Response) return err;
Expand All @@ -92,7 +103,7 @@ export async function DELETE(
context: { params: Promise<{ id: string }> },
) {
try {
await requireRole('ADMIN');
const { userId } = await requireRole('ADMIN');
const { id } = await context.params;

const db = getDb(getCfEnv().DB);
Expand All @@ -108,6 +119,15 @@ export async function DELETE(
.set({ deletedAt: Date.now(), updatedAt: Date.now() })
.where(eq(scopes.id, id));

await logAudit({
db,
entityType: 'system',
entityId: id,
actorId: userId,
action: 'scope_archived',
oldValue: existing.domain,
});

return NextResponse.json({ success: true, message: 'Scope archived successfully' });
} catch (err) {
if (err instanceof Response) return err;
Expand Down
10 changes: 10 additions & 0 deletions app/api/admin/scopes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getDb, getCfEnv } from '@/lib/db';
import { scopes } from '@/lib/db/schema';
import { requireRole } from '@/lib/auth';
import { logAudit } from '@/lib/audit';
import { z } from 'zod';
import { eq, isNull } from 'drizzle-orm';
import { VULN_TYPES, SEVERITIES } from '@/lib/validation';
Expand Down Expand Up @@ -87,6 +88,15 @@ export async function POST(request: NextRequest) {

const newScope = await db.select().from(scopes).where(eq(scopes.id, id)).get();

await logAudit({
db,
entityType: 'system',
entityId: id,
actorId: userId,
action: 'scope_created',
newValue: data.domain,
});

return NextResponse.json({ success: true, scope: newScope }, { status: 201 });
} catch (err) {
if (err instanceof Response) return err;
Expand Down
31 changes: 30 additions & 1 deletion app/api/reports/[id]/comments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import { auth, clerkClient } from '@clerk/nextjs/server';
import { eq, desc } from 'drizzle-orm';
import { z } from 'zod';
import { getDisplayName } from '@/lib/redact';
import { logAudit } from '@/lib/audit';

const CreateCommentSchema = z.object({
message: z.string().min(1).max(5000),
isInternal: z.boolean().optional(),
templateId: z.string().min(1).max(100).optional(),
});

export async function GET(
Expand Down Expand Up @@ -75,7 +77,8 @@ export async function POST(
}

const { id: reportId } = await params;
const db = getDb(getCfEnv().DB);
const env = getCfEnv();
const db = getDb(env.DB);

// Verify user has access to this report
const report = await db.select().from(reports).where(eq(reports.id, reportId)).get();
Expand Down Expand Up @@ -133,6 +136,32 @@ export async function POST(
.where(eq(comments.id, commentId))
.get();

if (data.templateId && isStaff) {
const template = await env.DB
.prepare('SELECT name FROM response_templates WHERE id = ? AND is_active = 1')
.bind(data.templateId)
.first<{ name?: string }>();

if (template) {
await logAudit({
db,
reportId,
actorId: userId,
action: 'template_used',
oldValue: data.templateId,
newValue: template.name || data.templateId,
});
}
}

await logAudit({
db,
reportId,
actorId: userId,
action: 'comment_posted',
newValue: isInternal ? 'internal' : 'public',
});

return NextResponse.json({ success: true, comment: newComment }, { status: 201 });
} catch (err) {
console.error('[POST /api/reports/[id]/comments]', err);
Expand Down
1 change: 1 addition & 0 deletions app/triage/reports/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@
if (r.ok) {
const data = await r.json();
const triagerList = data.users
.filter((u: any) => u.role === 'TRIAGER' || u.role === 'ADMIN')

Check failure on line 143 in app/triage/reports/[id]/page.tsx

View workflow job for this annotation

GitHub Actions / Lint & Type Check

Unexpected any. Specify a different type
.map((u: any) => ({

Check failure on line 144 in app/triage/reports/[id]/page.tsx

View workflow job for this annotation

GitHub Actions / Lint & Type Check

Unexpected any. Specify a different type
email: u.email,
name: `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email,
}));
Expand Down Expand Up @@ -288,6 +288,7 @@
body: JSON.stringify({
message: newComment,
isInternal: isInternalComment,
templateId: selectedTemplate || undefined,
}),
});

Expand Down
7 changes: 6 additions & 1 deletion lib/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ export type AuditAction =
| 'report_decrypted'
| 'role_changed'
| 'user_suspended'
| 'user_unsuspended';
| 'user_unsuspended'
| 'comment_posted'
| 'scope_created'
| 'scope_updated'
| 'scope_archived'
| 'template_used';

// Internal actions that should only be visible to triagers/admins
const INTERNAL_ACTIONS: AuditAction[] = [
Expand Down
6 changes: 4 additions & 2 deletions middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { NextResponse } from 'next/server';

const isTriageRoute = createRouteMatcher(['/triage(.*)']);
const isAdminPageRoute = createRouteMatcher(['/admin(.*)']); // Page routes only, not API
const isActivityLogsPageRoute = createRouteMatcher(['/admin/activity-logs(.*)']);
const isDashboardRoute = createRouteMatcher(['/dashboard(.*)']);
const isSubmitRoute = createRouteMatcher(['/submit(.*)']);

Expand All @@ -25,12 +26,13 @@ export default clerkMiddleware(async (auth, request) => {
}
}
if (isAdminPageRoute(request) && !isApiRoute) {
// Admin page route - ADMIN only
// Admin page route - ADMIN only, except triagers can view their own activity logs.
const { userId } = await auth.protect();
const client = await clerkClient();
const user = await client.users.getUser(userId);
const role = (user.publicMetadata as { role?: string })?.role;
if (role !== 'ADMIN') {
const canViewScopedActivityLogs = isActivityLogsPageRoute(request) && role === 'TRIAGER';
if (role !== 'ADMIN' && !canViewScopedActivityLogs) {
return NextResponse.redirect(new URL('/', request.url));
}
}
Expand Down
90 changes: 90 additions & 0 deletions playwright-report/index.html

Large diffs are not rendered by default.

Loading
Loading