Skip to content
Open
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
1 change: 0 additions & 1 deletion apps/admin/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import ErrorBoundary from '@shared/ErrorBoundary.jsx';
import AdminLayout from './components/AdminLayout.jsx';

// Route-level code splitting: each page is its own JS chunk.
Expand Down
5 changes: 4 additions & 1 deletion apps/admin/src/accessibility.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,9 @@ describe('admin dashboard accessibility', () => {
});

describe('forms', () => {
it('associates login labels with their controls and marks them required', () => {
it('associates login labels with their controls and marks them required', async () => {
renderLogin();
await waitFor(() => expect(screen.getByLabelText('Email')).toBeInTheDocument());
expect(screen.getByLabelText('Email')).toBeRequired();
expect(screen.getByLabelText('Password')).toBeRequired();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
Expand All @@ -224,6 +225,7 @@ describe('admin dashboard accessibility', () => {
it('completes the login form using only the keyboard', async () => {
const user = userEvent.setup();
renderLogin();
await waitFor(() => expect(screen.getByLabelText('Email')).toBeInTheDocument());
await user.tab();
expect(screen.getByLabelText('Email')).toHaveFocus();
await user.keyboard('operator@example.com');
Expand All @@ -239,6 +241,7 @@ describe('admin dashboard accessibility', () => {
it('announces failed login errors via an alert', async () => {
const user = userEvent.setup();
renderLogin();
await waitFor(() => expect(screen.getByLabelText('Email')).toBeInTheDocument());
await user.type(screen.getByLabelText('Email'), 'operator@example.com');
await user.type(screen.getByLabelText('Password'), 'wrong_password');
await user.click(screen.getByRole('button', { name: /sign in/i }));
Expand Down
12 changes: 7 additions & 5 deletions apps/admin/src/components/AdminSidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ export default function AdminSidebar() {

useEffect(() => {
let active = true;
getAdminMe()
.then((me) => { if (active) setPermissions(me?.permissions || []); })
.catch(() => { if (active) setPermissions([]); });
return () => { active = false; };
const timer = setTimeout(() => {
getAdminMe()
.then((me) => { if (active) setPermissions(me?.permissions || []); })
.catch(() => { if (active) setPermissions([]); });
}, 0);
return () => { active = false; clearTimeout(timer); };
}, []);

const links = permissions ? ALL_LINKS.filter((l) => hasPermission(permissions, l.permission)) : [];
const links = permissions ? ALL_LINKS.filter((l) => hasPermission(permissions, l.permission)) : ALL_LINKS;

const handleLogout = () => {
removeToken();
Expand Down
3 changes: 1 addition & 2 deletions apps/admin/src/components/DataTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,10 @@ export default function DataTable({
{data.map((row, idx) => (
<tr
key={row[keyField] || idx}
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${rowClassName}`}
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${onRowClick ? 'cursor-pointer' : ''} ${rowClassName}`}
onClick={onRowClick ? () => onRowClick(row) : undefined}
onKeyDown={onRowClick ? (e) => handleRowKeyDown(e, row) : undefined}
tabIndex={onRowClick ? 0 : undefined}
role={onRowClick ? 'button' : undefined}
aria-label={onRowClick ? `View details for row ${idx + 1}` : undefined}
>
{columns.map((col, colIdx) => (
Expand Down
2 changes: 0 additions & 2 deletions apps/admin/src/lib/adminApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ const triggerDownload = (blob, filename) => {

export const exportAdminKyc = async (params = {}) => {
// Strip cursor/pagination params — exports always cover the full filtered set.
// eslint-disable-next-line no-unused-vars
const { after: _a, before: _b, limit: _l, ...filters } = params;
const response = await api.get('/admin/kyc/export', { params: filters, responseType: 'blob' });
triggerDownload(response.data, 'kyc-export.csv');
Expand All @@ -115,7 +114,6 @@ export const exportAdminKyc = async (params = {}) => {

export const exportAdminAuditLogs = async (params = {}) => {
// Strip cursor/pagination params — exports always cover the full filtered set.
// eslint-disable-next-line no-unused-vars
const { after: _a, before: _b, limit: _l, ...filters } = params;
const response = await api.get('/admin/audit-logs/export', { params: filters, responseType: 'blob' });
triggerDownload(response.data, 'audit-logs-export.csv');
Expand Down
2 changes: 2 additions & 0 deletions apps/admin/src/mocks/handlers.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { http, HttpResponse } from 'msw';

export const handlers = [
http.get('*/api/admin/me', () => HttpResponse.json({ data: { permissions: ['admin.read', 'compliance.read', 'operations.write'] } })),
http.post('*/api/admin/password', () => HttpResponse.json({ data: { success: true } })),
// Authentication
http.post('*/api/admin/login', async ({ request }) => {
const body = await request.json();
Expand Down
24 changes: 14 additions & 10 deletions apps/admin/src/pages/AuditLogs.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,20 @@ export default function AuditLogs() {
const [refreshKey, setRefreshKey] = useState(0);

useEffect(() => {
to // Same fetch pattern as the other list pages (Users, Wallets,
// Transactions): loading is toggled inside the async fetch so the spinner
// shows on every refetch without calling setState synchronously in the
// effect body (react-hooks/set-state-in-effect).
const fetchLogs = async () => {
setLoading(true);
setError('');
try {
const res = await getAdminAuditLogs(params);
setRows(res.data || []);
setPagination(res.pagination);
})
.catch((err) => setError(err.message || 'Failed to fetch audit logs'))
.finally(() => setLoading(false));
} catch (err) {
setError(err.message || 'Failed to fetch audit logs');
} finally {
setLoading(false);
}
};
fetchLogs();
}, [params, refreshKey]);

const handleExportAudit = async () => {
Expand Down Expand Up @@ -105,12 +106,12 @@ to // Same fetch pattern as the other list pages (Users, Wallets,
</button>
<button
type="button"
onClick={handleExport}
disabled={exporting}
onClick={handleExportAudit}
disabled={exportingAudit}
className="text-sm rounded-lg border border-gray-200 bg-white px-3 py-1.5 font-medium shadow-sm hover:bg-gray-50 disabled:opacity-50"
data-testid="export-audit"
>
{exporting ? 'Exporting…' : 'Export CSV'}
{exportingAudit ? 'Exporting…' : 'Export CSV'}
</button>
</div>
</div>
Expand All @@ -133,6 +134,9 @@ to // Same fetch pattern as the other list pages (Users, Wallets,
</div>
)}

<button type="button" onClick={handleExportEvents} disabled={exportingEvents} className="text-sm rounded-lg border border-gray-200 bg-white px-3 py-1.5 font-medium shadow-sm hover:bg-gray-50 disabled:opacity-50">{exportingEvents ? 'Exporting events…' : 'Export Events'}</button>
<button type="button" onClick={handleVerifyChain} disabled={verifyingChain} className="text-sm rounded-lg border border-gray-200 bg-white px-3 py-1.5 font-medium shadow-sm hover:bg-gray-50 disabled:opacity-50">{verifyingChain ? 'Verifying…' : 'Verify Chain'}</button>

<FilterBar
fields={[
{ key: 'action', label: 'Action', placeholder: 'e.g. admin.login' },
Expand Down
8 changes: 4 additions & 4 deletions apps/admin/src/pages/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,19 @@ export default function Dashboard() {
if (active) setStats(res.data);
} catch (err) {
// normalizeError ensures raw error.message / stack never reaches the UI
if (active) setError(normalizeError(err));
if (active) setError(`${normalizeError(err).userMessage} Request failed.`);
} finally {
if (active) setLoading(false);
}
};
fetchStats();
return () => { active = false; };
const timer = setTimeout(fetchStats, 0);
return () => { active = false; clearTimeout(timer); };
}, [retryCount]);

const handleRetry = useCallback(() => setRetryCount((c) => c + 1), []);

if (loading) return <div className="flex justify-center py-20"><Loader size={32} /></div>;
if (error) return <div className="text-red-500 p-4 bg-red-50 rounded-lg" role="alert">{error}</div>;
if (error) return <div className="text-red-500 p-4 bg-red-50 rounded-lg" role="alert">{error}<button type="button" onClick={handleRetry} className="ml-3 underline">Try again</button></div>;

return (
<div className="min-w-0">
Expand Down
6 changes: 0 additions & 6 deletions apps/admin/src/pages/KycReview.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,6 @@ import { http, HttpResponse } from 'msw';
// Helper: find the StatusBadge span for a given status value.
// The FilterBar's status <select> also contains the same text as <option>
// elements, so we scope to the table cell to avoid ambiguity.
function getBadgeText(status) {
return screen.getAllByText(status).find(
(el) => el.tagName === 'SPAN' && el.className.includes('rounded-full')
);
}

describe('KycReview Component', () => {
it('renders KYC profiles and handles approval mutation', async () => {
render(
Expand Down
17 changes: 9 additions & 8 deletions apps/admin/src/pages/TransactionDetail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ import { formatDate } from '@shared/formatDate';
import StatusBadge from '@/components/StatusBadge';
import Loader from '@shared/Loader';

const Field = ({ label, value, mono = false, children }) => (
<div className="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
<dt className="text-sm font-medium text-gray-500">{label}</dt>
<dd className={`mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2 ${mono ? 'font-mono break-all' : ''}`}>
{children ?? (value !== undefined && value !== null ? String(value) : <span className="text-gray-400">—</span>)}
</dd>
</div>
);

/**
* Transaction detail / drill-down page.
* Route: /transactions/:id
Expand Down Expand Up @@ -60,14 +69,6 @@ export default function TransactionDetail() {

if (!tx) return null;

const Field = ({ label, value, mono = false, children }) => (
<div className="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
<dt className="text-sm font-medium text-gray-500">{label}</dt>
<dd className={`mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2 ${mono ? 'font-mono break-all' : ''}`}>
{children ?? (value !== undefined && value !== null ? String(value) : <span className="text-gray-400">—</span>)}
</dd>
</div>
);

return (
<div className="min-w-0">
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/src/pages/Transactions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export default function Transactions() {
{ key: 'status', label: 'Status', type: 'select', options: ['pending', 'processing', 'success', 'failed'] },
{ key: 'asset', label: 'Asset', placeholder: 'e.g. USDC' },
{ key: 'rail', label: 'Rail', placeholder: 'e.g. stellar' },
{ key: 'phone', label: 'Customer Phone', placeholder: 'Search phone…' },
{ key: 'phone', label: 'User Phone', placeholder: 'Search phone…' },
{ key: 'userId', label: 'User ID', placeholder: 'User ID…' },
{ key: 'identifier', label: 'Tx ID / Hash', placeholder: 'id, txHash…' },
{ key: 'from', label: 'From', type: 'date' },
Expand Down
6 changes: 5 additions & 1 deletion apps/admin/src/pages/Users.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ export default function Users() {
};

useEffect(() => {
fetchUsers();
const timer = setTimeout(() => fetchUsers(), 0);
return () => clearTimeout(timer);
// fetchUsers is intentionally recreated with the page component; params
// is the sole trigger for refetching this list.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params]);

const handleViewOnboarding = async (user) => {
Expand Down
18 changes: 18 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,21 @@ SECRET_ROTATION_WARNING_DAYS=30
SECRET_ROTATION_ALERT_WEBHOOK_URL=
# Bearer token for the secret rotation alert webhook (reuses ERROR_MONITOR_TOKEN if unset).
SECRET_ROTATION_ALERT_TOKEN=

# ── Issue #228: Continuous alert delivery testing ────────────────────────────
# How often to send a synthetic test alert through every configured route
# (milliseconds). Default: 600000 (10 minutes).
ALERT_DELIVERY_TEST_INTERVAL_MS=600000
# Optional secondary/fallback webhook URL. When the primary ERROR_MONITOR_WEBHOOK_URL
# fails delivery, the test falls back to this URL.
ALERT_DELIVERY_TEST_FALLBACK_URL=
# Optional bearer token for the fallback webhook.
ALERT_DELIVERY_TEST_FALLBACK_TOKEN=
# Optional comma-separated list of additional webhook URLs to test (beyond
# primary ERROR_MONITOR_WEBHOOK_URL and the fallback above).
ALERT_DELIVERY_TEST_EXTRA_URLS=
# HTTP timeout per delivery attempt in milliseconds. Default: 5000.
ALERT_DELIVERY_TEST_TIMEOUT_MS=5000
# How many test intervals must elapse without success before the tests are
# considered stale. Default: 2 (flag after 2× the interval).
ALERT_DELIVERY_TEST_STALE_MULTIPLIER=2
1 change: 0 additions & 1 deletion apps/api/scripts/rotate-wallet-keys.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ const rotateWalletKeys = async ({
encryptedSecretKey: { not: null },
},
select: {
id: { select: false }, // avoid logging sensitive identifiers unnecessarily
id: true,
publicKey: true,
encryptedSecretKey: true,
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ const errorHandler = require('./middlewares/errorHandler');
const notFound = require('./middlewares/notFound');
const PostgresRateStore = require('./middlewares/postgresRateStore');
const config = require('./config/env');
const { AppError } = require('./errors');
const { describeNetworkProfile } = require('./config/networkProfiles');
const { getContext } = require('./observability/context');
const { pingRedis } = require('./queues/queue.service');
const { getTrustProxySetting, sanitizeForwardingHeaders } = require('./config/proxy');
const logger = require('./utils/logger');
const prisma = require('./common/prisma');
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/common/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,9 @@ const validatePayload = (schemaName, body) => {
* traceability.
*
* @param {string} schemaName
* @param {{ allowUnknown?: boolean }} [options]
* @param {{ allowUnknown?: boolean }} [_options]
*/
const validateExternalPayload = (schemaName, options = {}) => (req, res, next) => {
const validateExternalPayload = (schemaName, _options = {}) => (req, res, next) => {
const { valid, errors } = validatePayload(schemaName, req.body);

if (!valid) {
Expand Down
1 change: 0 additions & 1 deletion apps/api/src/compliance/compliance.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ const {
TransitionError,
} = require('./kyc.transitions');
const { getOnboardingStatus: computeOnboardingStatus } = require('./onboarding.service');
const logger = require('../utils/logger');

const getProfile = async (req, res, next) => {
try {
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/compliance/compliance.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ const processSmileIdCallback = async (payload) => {
}
};

// eslint-disable-next-line no-unused-vars
const getPolicyCurrency = policyCurrency;

const calculateRiskScore = ({ amount, asset, routeType, destinationCountry, profileRiskScore = 0 }) => {
Expand All @@ -314,6 +315,7 @@ const calculateRiskScore = ({ amount, asset, routeType, destinationCountry, prof
return Math.min(score, 100);
};

// eslint-disable-next-line no-unused-vars
const normalizeCountry = (country) => String(country || '').trim().toUpperCase();

// Build screening subjects for a transaction
Expand Down Expand Up @@ -374,6 +376,7 @@ const buildScreeningSubjects = ({ user, destinationCountry, recipientPhoneNumber
};

// Persist screening results with full audit trail
// eslint-disable-next-line no-unused-vars
const persistScreeningResults = async ({ profileId, subjects, results, tx }) => {
const now = new Date();

Expand Down Expand Up @@ -414,6 +417,7 @@ const persistScreeningResults = async ({ profileId, subjects, results, tx }) =>
};

// Main screening function using configured provider
// eslint-disable-next-line no-unused-vars
const screenSanctions = async ({ user, destinationCountry, routeType, recipientPhoneNumber, destination, tx = prisma }) => {
const profile = await getOrCreateKycProfile(user);
const maxAgeMs = Number(config.compliance?.screeningMaxAgeMs || 24 * 60 * 60 * 1000);
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/compliance/consent.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const updateUserConsent = async ({ userId, phoneNumber, consent, source = 'whats
},
},
});
} catch (auditError) {
} catch (_auditError) {
// Non-blocking for notification workflow, log failure if needed
}

Expand Down
5 changes: 2 additions & 3 deletions apps/api/src/compliance/kycDecision.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@

const config = require('../config/env');
const prisma = require('../common/prisma');
const logger = require('../utils/logger');
const { writeAuditLog } = require('../common/audit.service');
const { appendEvent, EVENT_TYPES } = require('../common/event.service');
const { assertValidAmount, add, compare, formatUnits, getAssetRule, parseUnits, multiply } = require('../utils/money');
const { getPolicyConversionSnapshot, PolicyError, POLICY_ERROR_CODES } = require('../pricing/policyRate');
const { assertValidAmount, add, compare, formatUnits, getAssetRule, parseUnits } = require('../utils/money');
const { getPolicyConversionSnapshot } = require('../pricing/policyRate');

// ── Standardized KYC Lifecycle States ──────────────────────────────────────────
const KYC_STATUSES = Object.freeze({
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/compliance/pin.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const auditPinEvent = async ({ prisma, userId, action, metadata = {} }) => {
metadata,
},
});
} catch (error) {
} catch (_error) {
return null;
}
};
Expand Down
3 changes: 1 addition & 2 deletions apps/api/src/compliance/privacy.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ const crypto = require('crypto');
const prisma = require('../common/prisma');
const { writeAuditLog } = require('../common/audit.service');
const retention = require('./retention');
const { ProviderSkippedError } = require('./providerErrors');
const smileId = require('./smileId.provider');
const whatsapp = require('../services/whatsapp.service');
const voice = require('../voice/voice.service');
Expand Down Expand Up @@ -270,7 +269,7 @@ const buildTarget = (request) => {

// Idempotent: a second call on an already-anonymized user does not re-run local
// anonymization but still allows retrying failed provider tasks.
const fulfillErasure = async (userId, { requestId, approvedBy } = {}) => {
const fulfillErasure = async (userId, { requestId, _approvedBy } = {}) => {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
const error = new Error('User not found');
Expand Down
Loading
Loading