From 80d097547af7fa3650f8d5a68768d13c5b54dcdb Mon Sep 17 00:00:00 2001 From: Akpolo Ogagaoghene Prince Date: Mon, 30 Mar 2026 11:38:47 +0100 Subject: [PATCH 1/4] feat(#352): add skeleton loaders for employee table Replace blank table body with animated pulse skeleton rows while data is loading. Add `isLoading` prop to `EmployeeList` and a separate `EmployeeSkeletonRow` component that mirrors the column layout of the real rows using Tailwind's `animate-pulse`. Adds two new tests: - skeleton rows are rendered (not employee data) when isLoading=true - empty-state message appears when not loading and list is empty --- frontend/src/components/EmployeeList.tsx | 48 ++++++++++++++++++- .../__tests__/EmployeeList.test.tsx | 20 ++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/EmployeeList.tsx b/frontend/src/components/EmployeeList.tsx index 9167749..2b92767 100644 --- a/frontend/src/components/EmployeeList.tsx +++ b/frontend/src/components/EmployeeList.tsx @@ -20,6 +20,7 @@ interface Employee { interface EmployeeListProps { employees: Employee[]; + isLoading?: boolean; onEmployeeClick?: (employee: Employee) => void; onAddEmployee: (employee: Employee) => void; onEditEmployee?: (employee: Employee) => void; @@ -27,8 +28,49 @@ interface EmployeeListProps { onUpdateEmployeeImage?: (id: string, imageUrl: string) => void; } +const SKELETON_ROW_COUNT = 5; + +const EmployeeSkeletonRow: React.FC = () => ( + + {/* Name column */} + +
+
+
+
+
+
+
+ + {/* Role */} + +
+ + {/* Wallet */} + +
+ + {/* Salary */} + +
+ + {/* Status */} + +
+ + {/* Actions */} + +
+
+
+
+ + +); + export const EmployeeList: React.FC = ({ employees, + isLoading = false, onAddEmployee, onEditEmployee, onRemoveEmployee, @@ -212,7 +254,11 @@ export const EmployeeList: React.FC = ({ - {sortedEmployees.length === 0 ? ( + {isLoading ? ( + Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => ( + + )) + ) : sortedEmployees.length === 0 ? ( {debouncedSearch ? `No employees match "${debouncedSearch}"` : 'No employees found'} diff --git a/frontend/src/components/__tests__/EmployeeList.test.tsx b/frontend/src/components/__tests__/EmployeeList.test.tsx index 022464a..ad7a294 100644 --- a/frontend/src/components/__tests__/EmployeeList.test.tsx +++ b/frontend/src/components/__tests__/EmployeeList.test.tsx @@ -40,4 +40,24 @@ describe('EmployeeList', () => { expect(email).toHaveAttribute('title', employee.email); expect(email.className).toContain('truncate'); }); + + test('renders skeleton rows and hides employee data while loading', () => { + render(); + + // Employee data must not be visible during loading + expect(screen.queryByLabelText(`Employee name: ${employee.name}`)).toBeNull(); + expect(screen.queryByLabelText(`Employee email: ${employee.email}`)).toBeNull(); + + // Skeleton rows are rendered with pulse animation + const rows = document.querySelectorAll('tbody tr'); + expect(rows.length).toBe(5); + rows.forEach((row) => { + expect(row.className).toContain('animate-pulse'); + }); + }); + + test('renders empty state message when not loading and no employees exist', () => { + render(); + expect(screen.getByText('No employees found')).toBeTruthy(); + }); }); From 8ba307447ffd8825769f0034ff40ab2f4f53e475 Mon Sep 17 00:00:00 2001 From: Akpolo Ogagaoghene Prince Date: Mon, 30 Mar 2026 11:42:24 +0100 Subject: [PATCH 2/4] feat(#354): add information tooltips for financial terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a reusable InfoTooltip component (ⓘ button, hover/focus activated, keyboard accessible, themed) and wire it up at every occurrence of the three key financial terms in the dashboard: - ORGUSD – explains the org's custom Stellar payroll asset - Trustline – explains the Stellar trustline concept (requirement, 0.5 XLM reserve) - Ledger Sequence – explains what a Stellar ledger number is Locations: - AdminPanel.tsx: all three "Asset Code" inputs + "Trustline Status" heading - FeeEstimationPanel.tsx: "Last Ledger" stat row Adds 5 unit tests covering hover, focus, blur, click, and default label. --- .../src/components/FeeEstimationPanel.tsx | 9 ++- frontend/src/components/InfoTooltip.tsx | 68 +++++++++++++++++++ .../components/__tests__/InfoTooltip.test.tsx | 40 +++++++++++ frontend/src/pages/AdminPanel.tsx | 29 +++++++- 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/InfoTooltip.tsx create mode 100644 frontend/src/components/__tests__/InfoTooltip.test.tsx diff --git a/frontend/src/components/FeeEstimationPanel.tsx b/frontend/src/components/FeeEstimationPanel.tsx index 94ceb6f..aad42b3 100644 --- a/frontend/src/components/FeeEstimationPanel.tsx +++ b/frontend/src/components/FeeEstimationPanel.tsx @@ -12,6 +12,7 @@ import { useFeeEstimation } from '../hooks/useFeeEstimation'; import type { BatchBudgetEstimate } from '../services/feeEstimation'; import styles from './FeeEstimationPanel.module.css'; import { useTranslation } from 'react-i18next'; +import { InfoTooltip } from './InfoTooltip'; // --------------------------------------------------------------------------- // Sub‑components @@ -141,7 +142,13 @@ export const FeeEstimationPanel: React.FC = () => {
- {t('feeEstimation.lastLedger')} + + {t('feeEstimation.lastLedger')} + + #{feeRecommendation.lastLedger.toLocaleString()} diff --git a/frontend/src/components/InfoTooltip.tsx b/frontend/src/components/InfoTooltip.tsx new file mode 100644 index 0000000..2f7c85e --- /dev/null +++ b/frontend/src/components/InfoTooltip.tsx @@ -0,0 +1,68 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Info } from 'lucide-react'; + +interface InfoTooltipProps { + /** The explanation text shown in the tooltip. */ + content: string; + /** Optional accessible label for the trigger button. Defaults to "More information". */ + label?: string; +} + +/** + * A small ⓘ button that shows a descriptive tooltip when focused or hovered. + * Keyboard-accessible and screen-reader friendly. + */ +export const InfoTooltip: React.FC = ({ + content, + label = 'More information', +}) => { + const [visible, setVisible] = useState(false); + const tooltipRef = useRef(null); + const triggerRef = useRef(null); + + // Close tooltip on outside click + useEffect(() => { + if (!visible) return; + const handleClick = (e: MouseEvent) => { + if ( + tooltipRef.current && + !tooltipRef.current.contains(e.target as Node) && + !triggerRef.current?.contains(e.target as Node) + ) { + setVisible(false); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [visible]); + + return ( + + + + {visible && ( +
+ {content} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/__tests__/InfoTooltip.test.tsx b/frontend/src/components/__tests__/InfoTooltip.test.tsx new file mode 100644 index 0000000..4ac580d --- /dev/null +++ b/frontend/src/components/__tests__/InfoTooltip.test.tsx @@ -0,0 +1,40 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { InfoTooltip } from '../InfoTooltip'; + +describe('InfoTooltip', () => { + test('does not show tooltip content initially', () => { + render(); + expect(screen.queryByRole('tooltip')).toBeNull(); + }); + + test('shows tooltip content on mouse enter', () => { + render(); + const button = screen.getByRole('button', { name: 'What is ORGUSD?' }); + fireEvent.mouseEnter(button); + expect(screen.getByRole('tooltip')).toBeTruthy(); + expect(screen.getByText('ORGUSD is the org asset')).toBeTruthy(); + }); + + test('hides tooltip content on mouse leave', () => { + render(); + const button = screen.getByRole('button', { name: 'What is ORGUSD?' }); + fireEvent.mouseEnter(button); + fireEvent.mouseLeave(button); + expect(screen.queryByRole('tooltip')).toBeNull(); + }); + + test('shows tooltip on focus and hides on blur', () => { + render(); + const button = screen.getByRole('button', { name: 'What is a Ledger?' }); + fireEvent.focus(button); + expect(screen.getByRole('tooltip')).toBeTruthy(); + fireEvent.blur(button); + expect(screen.queryByRole('tooltip')).toBeNull(); + }); + + test('uses default label when none provided', () => { + render(); + expect(screen.getByRole('button', { name: 'More information' })).toBeTruthy(); + }); +}); diff --git a/frontend/src/pages/AdminPanel.tsx b/frontend/src/pages/AdminPanel.tsx index 07ca481..c643b20 100644 --- a/frontend/src/pages/AdminPanel.tsx +++ b/frontend/src/pages/AdminPanel.tsx @@ -13,6 +13,7 @@ import { useNotification } from '../hooks/useNotification'; import { useWallet } from '../hooks/useWallet'; import ContractUpgradeTab from '../components/ContractUpgradeTab'; import MultisigDetector from '../components/MultisigDetector'; +import { InfoTooltip } from '../components/InfoTooltip'; /** Centralized API base so URL changes happen in one place. */ const API_BASE = '/api/v1'; @@ -293,7 +294,13 @@ export default function AdminPanel() {
- +
- +

Trustline Status +

Verify whether an account's trustline is currently frozen for a given asset. @@ -440,7 +457,13 @@ export default function AdminPanel() {

- + Date: Mon, 30 Mar 2026 11:47:22 +0100 Subject: [PATCH 3/4] feat(#355): add detailed audit log for organization changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dedicated org_audit_log table (migration 027) that records every change to an organization's name, tenant settings, or issuer account. Changes: - backend/src/db/migrations/027_create_org_audit_log.sql Append-only table with NO UPDATE / NO DELETE rules, org FK, JSONB old_value/new_value snapshots, actor info, and three B-tree indexes. - backend/src/services/orgAuditService.ts OrgAuditService with log() (fire-and-forget safe) and list() (paginated). - backend/src/controllers/tenantConfigController.ts Hook log() into upsert() and remove() — captures old value before the write so the diff is always available. - backend/src/routes/orgAuditRoutes.ts GET /api/org-audit — paginated audit trail (EMPLOYER role required). - backend/src/__tests__/orgAuditService.test.ts 5 unit tests (mock pool) covering insert params, DB error resilience, null config_key, list pagination, and limit capping. --- backend/src/__tests__/orgAuditService.test.ts | 106 +++++++++++++++++ .../src/controllers/tenantConfigController.ts | 33 ++++++ .../migrations/027_create_org_audit_log.sql | 50 ++++++++ backend/src/routes/orgAuditRoutes.ts | 38 ++++++ backend/src/services/orgAuditService.ts | 112 ++++++++++++++++++ 5 files changed, 339 insertions(+) create mode 100644 backend/src/__tests__/orgAuditService.test.ts create mode 100644 backend/src/db/migrations/027_create_org_audit_log.sql create mode 100644 backend/src/routes/orgAuditRoutes.ts create mode 100644 backend/src/services/orgAuditService.ts diff --git a/backend/src/__tests__/orgAuditService.test.ts b/backend/src/__tests__/orgAuditService.test.ts new file mode 100644 index 0000000..13fe3ff --- /dev/null +++ b/backend/src/__tests__/orgAuditService.test.ts @@ -0,0 +1,106 @@ +/** + * Unit tests for OrgAuditService + * + * Uses a mock pg Pool so no real database connection is required. + */ + +import { OrgAuditService } from '../services/orgAuditService.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makePool(queryResult: { rows: unknown[]; rowCount?: number }) { + return { + query: jest.fn().mockResolvedValue(queryResult), + } as unknown as import('pg').Pool; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('OrgAuditService', () => { + describe('log()', () => { + it('inserts a row with the correct parameters', async () => { + const mockRow = { id: '1', organization_id: 42, change_type: 'setting_upserted' }; + const mockPool = makePool({ rows: [mockRow] }); + const service = new OrgAuditService(mockPool); + + const result = await service.log({ + organizationId: 42, + changeType: 'setting_upserted', + configKey: 'payment_settings', + oldValue: { currency: 'USD' }, + newValue: { currency: 'EUR' }, + actorId: 7, + actorEmail: 'admin@example.com', + actorIp: '127.0.0.1', + }); + + expect(mockPool.query).toHaveBeenCalledTimes(1); + const [sql, params] = (mockPool.query as jest.Mock).mock.calls[0]; + expect(sql).toContain('INSERT INTO org_audit_log'); + expect(params[0]).toBe(42); // organizationId + expect(params[1]).toBe('setting_upserted'); // changeType + expect(params[2]).toBe('payment_settings'); // configKey + expect(JSON.parse(params[3])).toEqual({ currency: 'USD' }); // oldValue + expect(JSON.parse(params[4])).toEqual({ currency: 'EUR' }); // newValue + expect(params[5]).toBe(7); // actorId + expect(params[6]).toBe('admin@example.com'); // actorEmail + expect(result).toEqual(mockRow); + }); + + it('returns null and does not throw when the insert fails', async () => { + const mockPool = { + query: jest.fn().mockRejectedValue(new Error('DB error')), + } as unknown as import('pg').Pool; + const service = new OrgAuditService(mockPool); + + const result = await service.log({ organizationId: 1, changeType: 'name_updated' }); + expect(result).toBeNull(); + }); + + it('stores null config_key when not provided', async () => { + const mockPool = makePool({ rows: [{ id: '2' }] }); + const service = new OrgAuditService(mockPool); + + await service.log({ organizationId: 1, changeType: 'name_updated', newValue: 'Acme' }); + + const params = (mockPool.query as jest.Mock).mock.calls[0][1]; + expect(params[2]).toBeNull(); // configKey + }); + }); + + describe('list()', () => { + it('returns rows and total count', async () => { + const mockRows = [ + { id: '1', organization_id: 10, change_type: 'setting_upserted', created_at: '2025-01-01' }, + ]; + const mockPool = { + query: jest + .fn() + .mockResolvedValueOnce({ rows: mockRows }) // data query + .mockResolvedValueOnce({ rows: [{ count: '1' }] }), // count query + } as unknown as import('pg').Pool; + const service = new OrgAuditService(mockPool); + + const { rows, total } = await service.list(10); + expect(rows).toEqual(mockRows); + expect(total).toBe(1); + }); + + it('caps limit at 200', async () => { + const mockPool = { + query: jest + .fn() + .mockResolvedValue({ rows: [] }), + } as unknown as import('pg').Pool; + const service = new OrgAuditService(mockPool); + + await service.list(1, { limit: 9999 }); + const params = (mockPool.query as jest.Mock).mock.calls[0][1]; + expect(params[1]).toBe(200); + }); + }); +}); diff --git a/backend/src/controllers/tenantConfigController.ts b/backend/src/controllers/tenantConfigController.ts index 6d7c130..09ef967 100644 --- a/backend/src/controllers/tenantConfigController.ts +++ b/backend/src/controllers/tenantConfigController.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import { z } from 'zod'; import tenantConfigService from '../services/tenantConfigService.js'; +import { orgAuditService } from '../services/orgAuditService.js'; const upsertSchema = z.object({ configKey: z.string().min(1).max(100), @@ -46,12 +47,29 @@ export class TenantConfigController { return res.status(403).json({ error: 'User is not associated with an organization' }); } const { configKey, configValue, description } = upsertSchema.parse(req.body); + + // Capture old value for audit diff + const oldValue = await tenantConfigService.getConfig(organizationId, configKey); + const updated = await tenantConfigService.setConfig( organizationId, configKey, configValue, description ); + + // Fire-and-forget audit log (errors must not break the response) + void orgAuditService.log({ + organizationId, + changeType: 'setting_upserted', + configKey, + oldValue: oldValue ?? undefined, + newValue: configValue, + actorId: req.user?.id, + actorEmail: req.user?.email, + actorIp: req.ip, + }); + return res.status(200).json({ success: true, data: updated }); } catch (error) { if (error instanceof z.ZodError) { @@ -69,8 +87,23 @@ export class TenantConfigController { return res.status(403).json({ error: 'User is not associated with an organization' }); } const configKey = req.params.configKey as string; + + // Capture old value for audit diff before deleting + const oldValue = await tenantConfigService.getConfig(organizationId, configKey); + const deleted = await tenantConfigService.deleteConfig(organizationId, configKey); if (!deleted) return res.status(404).json({ error: 'Configuration not found' }); + + void orgAuditService.log({ + organizationId, + changeType: 'setting_deleted', + configKey, + oldValue: oldValue ?? undefined, + actorId: req.user?.id, + actorEmail: req.user?.email, + actorIp: req.ip, + }); + return res.status(204).send(); } catch (error) { console.error('delete tenant config error:', error); diff --git a/backend/src/db/migrations/027_create_org_audit_log.sql b/backend/src/db/migrations/027_create_org_audit_log.sql new file mode 100644 index 0000000..0c4eb5c --- /dev/null +++ b/backend/src/db/migrations/027_create_org_audit_log.sql @@ -0,0 +1,50 @@ +-- ============================================================================= +-- Migration 027: Organization Audit Log +-- Purpose : Dedicated, append-only table that records every change made to an +-- organization's name, settings (tenant_configurations), or issuer +-- account. Kept separate from the generic audit_logs table so that +-- compliance queries targeting org-level data stay fast and clear. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS org_audit_log ( + id BIGSERIAL PRIMARY KEY, + + -- Which organization was changed + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + + -- What kind of change occurred + -- Allowed values: 'name_updated' | 'setting_upserted' | 'setting_deleted' + -- | 'issuer_updated' | 'org_created' | 'org_deleted' + change_type VARCHAR(50) NOT NULL, + + -- The config key that was changed (NULL for name / issuer changes) + config_key VARCHAR(100), + + -- JSON snapshots of the value before and after the change + old_value JSONB, + new_value JSONB, + + -- Who made the change + actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + actor_email VARCHAR(255), + actor_ip INET, + + -- When the change happened (immutable) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Enforce append-only semantics: no UPDATE or DELETE on existing rows +CREATE RULE org_audit_log_no_update AS ON UPDATE TO org_audit_log DO INSTEAD NOTHING; +CREATE RULE org_audit_log_no_delete AS ON DELETE TO org_audit_log DO INSTEAD NOTHING; + +-- Index for the most common query patterns +CREATE INDEX IF NOT EXISTS idx_org_audit_log_org ON org_audit_log (organization_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_org_audit_log_actor ON org_audit_log (actor_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_org_audit_log_type ON org_audit_log (change_type, created_at DESC); + +COMMENT ON TABLE org_audit_log IS + 'Append-only log of every change made to an organization''s name, settings, or issuer account.'; +COMMENT ON COLUMN org_audit_log.change_type IS + 'Classifies the kind of change: name_updated, setting_upserted, setting_deleted, issuer_updated, org_created, org_deleted.'; +COMMENT ON COLUMN org_audit_log.config_key IS + 'The tenant_configurations key that was changed; NULL for name/issuer changes.'; diff --git a/backend/src/routes/orgAuditRoutes.ts b/backend/src/routes/orgAuditRoutes.ts new file mode 100644 index 0000000..5c33fba --- /dev/null +++ b/backend/src/routes/orgAuditRoutes.ts @@ -0,0 +1,38 @@ +import { Router, Request, Response } from 'express'; +import authenticateJWT from '../middlewares/auth.js'; +import { authorizeRoles, isolateOrganization } from '../middlewares/rbac.js'; +import { orgAuditService } from '../services/orgAuditService.js'; + +const router = Router(); + +router.use(authenticateJWT); +router.use(authorizeRoles('EMPLOYER')); +router.use(isolateOrganization); + +/** + * GET /api/org-audit + * Returns paginated organization audit log entries for the authenticated org. + * + * Query params: + * limit – max rows per page (default 50, max 200) + * offset – rows to skip (default 0) + */ +router.get('/', async (req: Request, res: Response) => { + try { + const organizationId = req.user?.organizationId; + if (!organizationId) { + return res.status(403).json({ error: 'User is not associated with an organization' }); + } + + const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 200); + const offset = Math.max(parseInt(String(req.query.offset ?? '0'), 10) || 0, 0); + + const { rows, total } = await orgAuditService.list(organizationId, { limit, offset }); + return res.status(200).json({ success: true, data: rows, total, limit, offset }); + } catch (err) { + console.error('org-audit list error:', err); + return res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +export default router; diff --git a/backend/src/services/orgAuditService.ts b/backend/src/services/orgAuditService.ts new file mode 100644 index 0000000..30c763e --- /dev/null +++ b/backend/src/services/orgAuditService.ts @@ -0,0 +1,112 @@ +/** + * orgAuditService + * + * Writes append-only records to the org_audit_log table whenever an + * organization's name, tenant settings, or issuer account changes. + * + * All methods are fire-and-forget safe: errors are logged but never + * re-thrown so that a logging failure cannot break the main request path. + */ + +import { Pool } from 'pg'; +import { pool } from '../config/database.js'; + +export type OrgChangeType = + | 'name_updated' + | 'setting_upserted' + | 'setting_deleted' + | 'issuer_updated' + | 'org_created' + | 'org_deleted'; + +export interface OrgAuditEntry { + organizationId: number; + changeType: OrgChangeType; + configKey?: string; + oldValue?: unknown; + newValue?: unknown; + actorId?: number; + actorEmail?: string; + actorIp?: string; +} + +export interface OrgAuditRow { + id: string; + organization_id: number; + change_type: string; + config_key: string | null; + old_value: unknown; + new_value: unknown; + actor_id: number | null; + actor_email: string | null; + actor_ip: string | null; + created_at: string; +} + +export class OrgAuditService { + constructor(private readonly db: Pool = pool) {} + + /** + * Append a single entry to the org_audit_log table. + * Returns the inserted row, or null if the insert failed. + */ + async log(entry: OrgAuditEntry): Promise { + const query = ` + INSERT INTO org_audit_log + (organization_id, change_type, config_key, old_value, new_value, + actor_id, actor_email, actor_ip) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::inet) + RETURNING * + `; + + try { + const result = await this.db.query(query, [ + entry.organizationId, + entry.changeType, + entry.configKey ?? null, + entry.oldValue !== undefined ? JSON.stringify(entry.oldValue) : null, + entry.newValue !== undefined ? JSON.stringify(entry.newValue) : null, + entry.actorId ?? null, + entry.actorEmail ?? null, + entry.actorIp ?? null, + ]); + return result.rows[0] ?? null; + } catch (err) { + console.error('[OrgAuditService] Failed to write audit log entry:', err); + return null; + } + } + + /** + * Retrieve paginated audit log entries for an organization. + * Results are ordered newest-first. + */ + async list( + organizationId: number, + options: { limit?: number; offset?: number } = {} + ): Promise<{ rows: OrgAuditRow[]; total: number }> { + const limit = Math.min(options.limit ?? 50, 200); + const offset = options.offset ?? 0; + + const [dataResult, countResult] = await Promise.all([ + this.db.query( + `SELECT * FROM org_audit_log + WHERE organization_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3`, + [organizationId, limit, offset] + ), + this.db.query<{ count: string }>( + `SELECT COUNT(*) AS count FROM org_audit_log WHERE organization_id = $1`, + [organizationId] + ), + ]); + + return { + rows: dataResult.rows, + total: parseInt(countResult.rows[0]?.count ?? '0', 10), + }; + } +} + +export const orgAuditService = new OrgAuditService(); From cc31a60b434ba4b09d4d370484d829c4718e5dda Mon Sep 17 00:00:00 2001 From: Akpolo Ogagaoghene Prince Date: Mon, 30 Mar 2026 11:49:40 +0100 Subject: [PATCH 4/4] fix(#356): fix sidebar navigation for mobile view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root causes addressed: 1. Mobile dropdown was positioned absolute inside a flex item (AppNav) causing it to be narrower than the viewport and misaligned. → Replaced with a fixed overlay panel (role=dialog, aria-modal=true) anchored to var(--header-h), so it always spans the full viewport width regardless of the flex ancestor. 2. Mobile dropdown used hardcoded bg-white, breaking the dark theme. → Now uses var(--surface) and var(--border-hi) CSS variables. 3. Header had px-16 (64 px) on all viewports, leaving almost no usable space for the hamburger and logo on small phones. → Changed to px-4 sm:px-8 lg:px-16 (responsive padding). Also adds a semi-transparent backdrop that closes the drawer on tap, and constrains the drawer height with overflow-y-auto so long nav lists stay within the viewport on short screens. Adds 4 accessibility-focused unit tests (hamburger open/close, backdrop close, aria-modal attribute). --- frontend/src/components/AppLayout.tsx | 2 +- frontend/src/components/AppNav.tsx | 28 ++++++- .../src/components/__tests__/AppNav.test.tsx | 74 +++++++++++++++++++ 3 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/__tests__/AppNav.test.tsx diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index b2e3306..cbf27d0 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -23,7 +23,7 @@ const AppLayout: React.FC = () => { > {/* Header */}
{
- {/* Mobile dropdown menu */} + {/* Mobile drawer — rendered as a fixed overlay so it never clips inside a flex ancestor */} {mobileOpen && ( -
-
{navLinks}
-
+ <> + {/* Backdrop */} +