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
40 changes: 31 additions & 9 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,48 @@
- [x] **PR #613** — currency system: `ExchangeRate` table (composite PK), `UserPreferences.PreferredCurrency`, `NbuCurrencyService` with daily 06:00 Kyiv sync + previous-business-day fallback + last-stored-rate on NBU failure, `/api/currency/rates*` + `/api/currency/preferences` endpoints, frontend `useFormatCurrency` hook + Profile selector *(TZ 8.2)*
- [x] **PR #628** — currency refactor completion: migrate 20+ pages from hardcoded ₴/UAH/грн to `useFormatCurrency`/`useCurrencySymbol`/`useConvertFromUah`, dynamic formatUAH, reactive `<Money/>` component. Displayed values now reflect the user's preferred currency everywhere (Dashboard, Economics, Analytics, Sales, Fields, HR, Machinery, Warehouses, GrainStorage) *(TZ 8.2 completion)*
- [x] **PR #631** — **hotfix**: temporarily disable the Profile currency switcher with a tooltip and force-reset any stored non-UAH user preference to UAH on next login. Prevents the mixed-label regression where `/expenses` showed `1000.00 USD` rows alongside `1 000,00 грн` totals *(safety net; blocks #632 until it is solved)*
- [x] **PR #632** — currency conversion v2: rewrite `useFormatCurrency` with the signature `(uahValue, date?)` and proper math (`uah / rateToUah`), null-safe rendering (`—`), warn-once fallback when the rate table is empty; introduce the single-source `<Money/>` component; lock all monetary input addons to hardcoded `₴` (Variant B) and show a "Сума зберігається в гривнях" helper text; unit tests (7 cases) + Playwright regression test for the mixed-label bug. Switcher stays disabled until rates are wired into all pages. *(TZ 8.2, conversion layer)*
- [x] **PR #634** — currency conversion v2: rewrite `useFormatCurrency` with the signature `(uahValue, date?)` and proper math (`uah / rateToUah`), null-safe rendering (`—`), warn-once fallback when the rate table is empty; introduce the single-source `<Money/>` component; lock all monetary input addons to hardcoded `₴` (Variant B) and show a "Сума зберігається в гривнях" helper text; unit tests (7 cases) + Playwright regression test for the mixed-label bug. Switcher re-enabled as the last commit. *(TZ 8.2, conversion layer)*
- [x] **PR #616** *(parallel design-system track)* — design-system foundation: TypeScript token source-of-truth, `scripts/build-tokens.ts`, `frontend/src/design-system/tokens/*`, `lightTheme.ts` as deadcode ThemeConfig. Zero breaking changes to existing CSS variable names.

---

## In progress

- [ ] **PR #614 — Super-admin advanced + impersonation** *(TZ 14 remainder)*
- Impersonation: 60min TTL, mandatory reason, red banner in UI, email to target user, rate limit 3/day per (admin, target) pair
- Forbidden actions in impersonation: password/email change, API keys write scope, billing ops, tenant export
- `/admin/users` global search, impersonate action
- `/admin/audit-log` global view with filters (tenant, user, action type, period)
- `/admin/system` (queue/jobs health, storage, connections)
- `/admin/catalogs` (global reference data: crops, equipment types, units)
- `/admin/broadcast` (notification to all/selected tenants)
- [ ] **PR #614 — Super-admin core: impersonation + global users + audit log** *(TZ 14 remainder, core)*
- Impersonation engine: `POST /api/admin/impersonate` with mandatory `reason` (min 10 chars), 60min TTL, not renewable, returns short-lived JWT carrying `impersonating_user_id` + `impersonated_by_user_id` + `original_tenant_id` + `reason` claims
- `POST /api/admin/impersonate/end` returns the super-admin's restored token and writes `impersonate.end` audit
- Rate limit: 3 sessions per (admin, target) per 24h, enforced by query against `SuperAdminAuditLog` filtered by `Action = 'impersonate.start'`. Backed by a **partial composite index** on `(admin_user_id, target_id, occurred_at DESC) WHERE action = 'impersonate.start'`
- Forbidden-action filter in impersonation: blocks password change, email change, API keys write scope, billing, full data export. Returns **403 AND** writes a separate audit entry of type `impersonate.forbidden_attempt` with the attempted route
- Audit: every mutation while impersonating tagged `impersonated_by` + `acted_as` (extension to existing `ISuperAdminAuditService`)
- **In-app notification** to target user (via `Notification` entity, severity `warning`, title "Сесія імперсонації", body "Адміністратор {full_name} увійшов під вашим акаунтом {timestamp_kyiv}. Причина: {reason}."), independent of SMTP availability
- Best-effort `IEmailService.SendAsync` call as well (silently no-ops if SMTP unconfigured)
- `/admin/users` page — global search across tenants, impersonate button → reason modal → token swap → redirect to `/`
- `/admin/audit-log` page — global view with filters (tenant, user, action type, period); CSV export deferred to #614a
- **Red persistent banner** during impersonation: not closable, not dismissable, z-index 9999, full viewport width, shows target user + tenant + remaining TTL countdown + "Вийти з режиму" button. No localStorage opt-out
- 6 integration tests required (non-super-admin → 403, no-MFA → 403+header, valid start → 200+audit+notification, reason<10 → 400, rate-limit 4th → 429, forbidden action → 403+forbidden-attempt audit). `TestAuthHandler` extended in this PR if needed

---

## Upcoming (in order — do not reorder without approval)

- [ ] **PR #614a — Super-admin: system health page** *(TZ 14 follow-up)*
- `/admin/system` page (read-only dashboard)
- Backend: `GET /api/admin/system/health` aggregates Hangfire queue depth + failed-jobs count, DB connection pool usage, storage volume usage, active SignalR connections (when notification hub lands), background-job last-run-at per recurring job
- Auto-refresh every 30s, severity colours (green/amber/red) per metric
- CSV export added to `/admin/audit-log` here as well

- [ ] **PR #614b — Super-admin: global catalogs CRUD** *(TZ 14 follow-up)*
- `/admin/catalogs` page with tabs: Crops, Equipment types, Units, Document types
- Global reference data is shared across all tenants — soft-delete only (existing tenant data must keep referencing the row)
- Backend: `/api/admin/catalogs/{type}` CRUD with audit on every mutation (`catalog.crop.create`, etc.), validation that a row in use cannot be hard-deleted
- Bulk import CSV (deferred until concrete request)

- [ ] **PR #614c — Super-admin: broadcast notifications** *(TZ 14 follow-up)*
- `/admin/broadcast` page: composer (title, body, severity) + audience picker (all tenants / selected tenants / by feature flag)
- Backend: `POST /api/admin/broadcast` fans out to `Notification` rows per target tenant; rate-limited to 1 broadcast per minute per super-admin
- History view of past broadcasts with reach count per broadcast
- Depends on Notifications fixes in PR #617 (per-user routing) — order: ship #614a, then #617, then #614b/c

- [ ] **PR #617 — Export currency header** *(TZ 8.2 follow-up)*
- Add NBU rate on export date to CSV/PDF export headers where currency amounts appear (costs, revenue, grain)
- Tied to existing export helpers; deferred from PR #613 to keep that PR reviewable
Expand Down
2 changes: 1 addition & 1 deletion docs/TZ.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@

---

## ПУНКТ 14 — Супер-адмін має бути реально супер-адміном `[FOUNDATION CLOSED in PR #610; advanced features in PR #614]`
## ПУНКТ 14 — Супер-адмін має бути реально супер-адміном `[FOUNDATION CLOSED in PR #610; CORE advanced (impersonation, /admin/users, /admin/audit-log) IN PROGRESS in PR #614; /admin/system in #614a; /admin/catalogs in #614b; /admin/broadcast in #614c]`

**Problem:** Previous "super-admin" role was tenant-scoped, couldn't see other tenants.

Expand Down
6 changes: 6 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ import WorkLogPage from './pages/HR/WorkLogPage';
import SalaryPage from './pages/HR/SalaryPage';
import UsersPage from './pages/Settings/UsersPage';
import AdminAuditLogPage from './pages/Admin/AuditLogPage';
import SuperAdminUsersPage from './pages/Admin/SuperAdminUsersPage';
import SuperAdminAuditLogPage from './pages/Admin/SuperAdminAuditLogPage';
import ImpersonationBanner from './components/Impersonation/ImpersonationBanner';
import ApiKeysPage from './pages/Admin/ApiKeysPage';
import RolePermissionsPage from './pages/Admin/RolePermissionsPage';
import PendingApprovalsPage from './pages/Admin/PendingApprovalsPage';
Expand Down Expand Up @@ -136,6 +139,7 @@ export default function App() {
<ErrorBoundary>
<BrowserRouter>
<DevBypassBanner />
<ImpersonationBanner />
{isPublicDemoMode && !demoReady ? (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
Expand Down Expand Up @@ -280,6 +284,8 @@ export default function App() {
<Route path="/hr/salary" element={<SalaryPage />} />
<Route path="/settings/users" element={<UsersPage />} />
<Route path="/admin/audit" element={<AdminAuditLogPage />} />
<Route path="/admin/users" element={<SuperAdminUsersPage />} />
<Route path="/admin/audit-log" element={<SuperAdminAuditLogPage />} />
<Route path="/admin/api-keys" element={<ApiKeysPage />} />
<Route path="/admin/role-permissions" element={<RolePermissionsPage />} />
<Route path="/admin/approvals" element={<PendingApprovalsPage />} />
Expand Down
82 changes: 82 additions & 0 deletions frontend/src/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,85 @@ export const updateAdminTenantFeatures = (
apiClient
.put<{ features: AdminFeature[] }>(`/api/admin/tenants/${id}/features`, { features })
.then((r) => r.data.features);

// =============================================================================================
// Global users (PR #614). Returned by GET /api/admin/users.
// =============================================================================================
export interface AdminUser {
id: string;
email: string;
firstName: string;
lastName: string;
role: string;
isActive: boolean;
isSuperAdmin: boolean;
tenantId: string;
tenantName: string;
}

export const listAdminUsers = (
params: { search?: string; tenantId?: string; page?: number; pageSize?: number } = {},
) =>
apiClient
.get<PagedResult<AdminUser>>('/api/admin/users', { params })
.then((r) => r.data);

// =============================================================================================
// Audit log (PR #614). Returned by GET /api/admin/audit-log.
// =============================================================================================
export interface SuperAdminAuditEntry {
id: string;
adminUserId: string;
action: string;
targetType: string;
targetId: string | null;
before: string | null;
after: string | null;
ipAddress: string | null;
userAgent: string | null;
occurredAt: string;
}

export const listAdminAuditLog = (
params: {
action?: string;
adminUserId?: string;
tenantId?: string;
fromUtc?: string;
toUtc?: string;
page?: number;
pageSize?: number;
} = {},
) =>
apiClient
.get<PagedResult<SuperAdminAuditEntry>>('/api/admin/audit-log', { params })
.then((r) => r.data);

// =============================================================================================
// Impersonation (PR #614).
// =============================================================================================
export interface ImpersonationStartResponse {
token: string;
expiresAtUtc: string;
targetUserId: string;
targetEmail: string;
targetFirstName: string;
targetLastName: string;
targetTenantId: string;
targetTenantName: string;
}

export interface ImpersonationEndResponse {
token: string;
expiresAtUtc: string;
}

export const startImpersonation = (targetUserId: string, reason: string) =>
apiClient
.post<ImpersonationStartResponse>('/api/admin/impersonate', { targetUserId, reason })
.then((r) => r.data);

export const endImpersonation = () =>
apiClient
.post<ImpersonationEndResponse>('/api/admin/impersonate/end')
.then((r) => r.data);
108 changes: 108 additions & 0 deletions frontend/src/components/Impersonation/ImpersonationBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react';
import { Button, message } from 'antd';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { endImpersonation } from '../../api/admin';
import { useTranslation } from '../../i18n';

/**
* Persistent red banner shown while a super-admin is impersonating another user
* (PR #614). Per spec it is NOT closable / dismissable, sits above every other
* UI layer (z-index 9999), spans the full viewport width and exposes a single
* "exit" action that calls /api/admin/impersonate/end and restores the
* pre-impersonation super-admin token.
*/
export default function ImpersonationBanner() {
const { t } = useTranslation();
const navigate = useNavigate();
const impersonation = useAuthStore((s) => s.impersonation);
const setAuth = useAuthStore((s) => s.setAuth);
const clearImpersonation = useAuthStore((s) => s.clearImpersonation);

const [now, setNow] = useState<number>(() => Date.now());
const [busy, setBusy] = useState(false);

useEffect(() => {
if (!impersonation) return;
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, [impersonation]);

if (!impersonation) return null;

const expiresAt = new Date(impersonation.expiresAtUtc).getTime();
const remainingMs = Math.max(0, expiresAt - now);
const totalSec = Math.floor(remainingMs / 1000);
const mm = Math.floor(totalSec / 60).toString().padStart(2, '0');
const ss = (totalSec % 60).toString().padStart(2, '0');

const onExit = async () => {
setBusy(true);
try {
// Best-effort server-side end. Even if it fails (e.g. token already
// expired) we still locally restore the original super-admin token.
try {
await endImpersonation();
} catch {
/* ignore — local restore below is the source of truth */
}
setAuth(
impersonation.originalToken,
impersonation.originalEmail,
impersonation.originalRole,
impersonation.originalTenantId,
Comment on lines +49 to +53
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore session using server end-token, not stale snapshot

After POST /api/admin/impersonate/end, the code immediately restores impersonation.originalToken instead of using the fresh token returned by the backend. If the original token has expired during impersonation, the next API call fails with 401 even though /end succeeded, causing an avoidable forced re-login path; the exit flow should apply the response token as the source of truth.

Useful? React with 👍 / 👎.

false,
true,
impersonation.originalFirstName,
impersonation.originalLastName,
undefined,
true,
);
clearImpersonation();
navigate('/admin/users');
} catch {
message.error(t.admin.bannerExitFailed);
} finally {
setBusy(false);
}
};

return (
<div
role="alert"
aria-live="assertive"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
width: '100vw',
zIndex: 9999,
background: '#b91c1c',
color: '#fff',
padding: '8px 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
boxShadow: '0 2px 6px rgba(0,0,0,0.35)',
fontSize: 13,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
<strong style={{ letterSpacing: 0.3 }}>
{t.admin.bannerActiveAs}: {impersonation.targetEmail} ({impersonation.targetTenantName})
</strong>
<span style={{ opacity: 0.9 }}>
{t.admin.bannerOriginal}: {impersonation.originalEmail}
</span>
<span style={{ opacity: 0.9 }}>
{t.admin.bannerExpiresIn}: {mm}:{ss}
</span>
</div>
<Button danger type="primary" size="small" loading={busy} onClick={onExit}>
{t.admin.bannerExit}
</Button>
</div>
);
}
43 changes: 43 additions & 0 deletions frontend/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1887,6 +1887,49 @@ const en: Translations = {
colTotalHectares: 'Hectares',
colStatus: 'Status',
colCreatedAt: 'Created',
// ===== Global users (PR #614) =====
usersTitle: 'Platform users',
usersSearchPlaceholder: 'Search by email or name',
colEmail: 'Email',
colFullName: 'Name',
colRole: 'Role',
colTenant: 'Tenant',
colActive: 'Active',
colSuperAdmin: 'Super-admin',
colActions: 'Actions',
actionImpersonate: 'Impersonate',
// ===== Impersonation modal =====
impersonateTitle: 'Impersonate user',
impersonateTargetLabel: 'Target user',
impersonateReasonLabel: 'Reason (minimum 10 characters)',
impersonateReasonPlaceholder: 'e.g. investigating complaint ticket #1234',
impersonateConfirm: 'Confirm & impersonate',
impersonateCancel: 'Cancel',
impersonateReasonTooShort: 'Reason must be at least 10 characters',
impersonateStartFailed: 'Failed to start impersonation',
impersonateStarted: 'Impersonation session started',
impersonateRateLimited: 'Impersonation rate limit exceeded for this user (3 per 24 hours)',
// ===== Banner =====
bannerActiveAs: 'You are signed in as',
bannerOriginal: 'Admin',
bannerExpiresIn: 'Time left',
bannerExit: 'Exit impersonation',
bannerExitFailed: 'Failed to end impersonation',
// ===== Audit log (PR #614) =====
auditLogTitle: 'Super-admin audit log',
auditFilterAction: 'Action',
auditFilterAdmin: 'Admin (ID)',
auditFilterTenant: 'Tenant (ID)',
auditFilterFrom: 'From',
auditFilterTo: 'To',
auditFilterApply: 'Apply',
auditFilterReset: 'Reset',
auditColOccurredAt: 'Time',
auditColAction: 'Action',
auditColAdmin: 'Admin',
auditColTarget: 'Target',
auditColIp: 'IP',
auditColPayload: 'Payload',
},
mfa: {
setupTitle: 'Set up two-factor authentication',
Expand Down
Loading
Loading