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
11 changes: 8 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,19 @@ jobs:
- name: Check locale parity
run: npm run check:locales

# Fails only on hardcoded strings that are not in
# scripts/hardcoded-strings-baseline.json (see frontend/README.md).
# Non-blocking while the regex heuristic beds in; remove
# continue-on-error once the team is happy with its signal.
- name: Check hardcoded strings
run: npm run check:hardcoded
continue-on-error: true

- name: Build
run: npm run build
env:
NEXT_PUBLIC_API_URL: http://localhost:3001

- name: Typecheck
run: npm run typecheck

- name: Unit tests with coverage
run: npm run test:coverage

Expand Down
16 changes: 16 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,19 @@ Reusable test setups, mocks, and shared helpers live in `frontend/test-utils/`.

To add a new shared helper, drop it into `frontend/test-utils/` (same directory as `test-setup.ts`) and reference it from individual tests via relative imports.

## Routing and i18n

Every page lives under `app/[locale]/`. With `localePrefix: "as-needed"`, English URLs stay unprefixed (`/documents/abc`) and other locales are prefixed (`/fr/documents/abc`). Do not add pages outside `app/[locale]/`: they would bypass translations and duplicate a localised route. If a page moves to a new URL, add the old prefix to `LEGACY_ROUTE_REDIRECTS` in `middleware.ts` so existing links keep working.

User-facing copy goes in `messages/{en,fr,es}.json` and is read with `useTranslations` / `getTranslations`. CI enforces two checks:

- `npm run check:locales` fails if the three message files do not have identical key sets.
- `npm run check:hardcoded` flags JSX text that looks like untranslated copy (test files are skipped).

### Hardcoded-string baseline

Strings that were already hardcoded when the check was added to CI are listed in `scripts/hardcoded-strings-baseline.json`, keyed by file and text (not line number). The check fails only on strings that are **not** in the baseline, so existing debt does not block PRs but new debt cannot be added. The CI step is currently `continue-on-error` while the heuristic beds in.

- **Translated a baselined string?** The check prints a reminder. Run `npm run check:hardcoded -- --update-baseline` and commit the smaller baseline. The goal is to ratchet it down to empty.
- **Got a new violation?** Move the copy into `messages/*.json`. Do not regenerate the baseline to silence it. If the match is a false positive, adjust the markup or the ignore patterns in `scripts/check-hardcoded-strings.mjs`.

12 changes: 0 additions & 12 deletions frontend/app/(protected)/admin/layout.tsx

This file was deleted.

86 changes: 0 additions & 86 deletions frontend/app/(protected)/admin/users/page.tsx

This file was deleted.

17 changes: 0 additions & 17 deletions frontend/app/(protected)/documents/[id]/not-found.tsx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"use client";

import React, { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { CircleUser } from "lucide-react";
import { useDateFormatting } from "@/i18n/formatting";
import { apiUrl } from "@/lib/api-config";
import { getAccessToken } from "@/lib/session";

Expand Down Expand Up @@ -79,6 +81,9 @@ const ACTION_TYPES = ["login", "upload", "delete", "view", "verify", "dispute"];
// ---------------------------------------------------------------------------

export default function AdminActivityPage() {
const t = useTranslations("adminActivity");
const tCommon = useTranslations("common");
const { formatDateTime } = useDateFormatting();
const [activities, setActivities] = useState<Activity[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
Expand Down Expand Up @@ -124,7 +129,7 @@ export default function AdminActivityPage() {
);
setActivities(sorted);
setTotal(json?.total ?? sorted.length);
} catch (err) {
} catch {
// Backend may be unavailable — fall back to sample data.
const filtered = MOCK_ACTIVITY.filter(
(a) =>
Expand All @@ -133,15 +138,11 @@ export default function AdminActivityPage() {
);
setActivities(filtered);
setTotal(filtered.length);
setError(
err instanceof Error
? `${err.message} — showing sample data`
: "Showing sample data",
);
setError(t("sampleData"));
} finally {
setLoading(false);
}
}, [page, appliedUserId, appliedActionType]);
}, [page, appliedUserId, appliedActionType, t]);

useEffect(() => {
fetchActivity();
Expand All @@ -168,10 +169,10 @@ export default function AdminActivityPage() {
<main className="p-4 md:p-8">
<div className="rounded-xl border border-red-200 bg-red-50 p-6">
<h1 className="text-lg font-semibold text-red-800">
Admin access required
{t("accessDenied.title")}
</h1>
<p className="mt-1 text-sm text-red-700">
You need an administrator account to view the activity timeline.
{t("accessDenied.description")}
</p>
</div>
</main>
Expand All @@ -180,29 +181,27 @@ export default function AdminActivityPage() {

return (
<main className="p-4 md:p-8">
<h1 className="mb-1 text-2xl font-bold">User Activity Timeline</h1>
<p className="mb-6 text-sm text-gray-500">
Paginated user-action timeline, most recent first.
</p>
<h1 className="mb-1 text-2xl font-bold">{t("title")}</h1>
<p className="mb-6 text-sm text-gray-500">{t("subtitle")}</p>

{/* Filters */}
<section
aria-label="Activity filters"
aria-label={t("filters.label")}
className="mb-6 flex flex-wrap items-end gap-4 rounded-xl border border-gray-200 bg-white p-4"
>
<div className="flex flex-col gap-1">
<label
htmlFor="userId-filter"
className="text-sm font-medium text-gray-700"
>
User ID
{t("filters.userId")}
</label>
<input
id="userId-filter"
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="Filter by user id"
placeholder={t("filters.userIdPlaceholder")}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
Expand All @@ -211,15 +210,15 @@ export default function AdminActivityPage() {
htmlFor="actionType-filter"
className="text-sm font-medium text-gray-700"
>
Action type
{t("filters.actionType")}
</label>
<select
id="actionType-filter"
value={actionType}
onChange={(e) => setActionType(e.target.value)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All action types</option>
<option value="">{t("filters.allActionTypes")}</option>
{ACTION_TYPES.map((type) => (
<option key={type} value={type}>
{type}
Expand All @@ -232,13 +231,13 @@ export default function AdminActivityPage() {
onClick={handleApply}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Apply
{t("filters.apply")}
</button>
<button
onClick={handleReset}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Reset
{t("filters.reset")}
</button>
</div>
</section>
Expand All @@ -251,19 +250,17 @@ export default function AdminActivityPage() {

<div className="rounded-xl border border-gray-200 bg-white shadow-sm">
<div className="border-b border-gray-100 p-4">
<h2 className="text-sm font-semibold text-gray-900">Activity feed</h2>
<p className="text-xs text-gray-500">
{total} {total === 1 ? "event" : "events"}
</p>
<h2 className="text-sm font-semibold text-gray-900">{t("feedTitle")}</h2>
<p className="text-xs text-gray-500">{t("eventCount", { count: total })}</p>
</div>

{loading ? (
<p className="p-6 text-sm text-gray-500" role="status">
Loading activity…
{t("loading")}
</p>
) : activities.length === 0 ? (
<div className="p-10 text-center">
<p className="text-sm text-gray-500">No activity found.</p>
<p className="text-sm text-gray-500">{t("empty")}</p>
</div>
) : (
<div className="relative pl-6">
Expand All @@ -282,7 +279,7 @@ export default function AdminActivityPage() {
</div>
<p className="mt-1 text-sm text-gray-600">{activity.action}</p>
<p className="mt-1 text-xs text-gray-400">
{new Date(activity.created_at).toLocaleString()}
{formatDateTime(activity.created_at)}
</p>
</div>
))}
Expand All @@ -292,26 +289,26 @@ export default function AdminActivityPage() {
{/* Pagination */}
{totalPages > 1 && (
<nav
aria-label="Pagination"
aria-label={t("pagination.label")}
className="flex items-center justify-between border-t border-gray-100 px-4 py-3 text-sm"
>
<p className="text-gray-600">
Page {page} of {totalPages}
{t("pagination.summary", { page, totalPages })}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="rounded border px-3 py-1 text-gray-700 hover:bg-gray-50 disabled:opacity-40"
>
Previous
{tCommon("previous")}
</button>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="rounded border px-3 py-1 text-gray-700 hover:bg-gray-50 disabled:opacity-40"
>
Next
{tCommon("next")}
</button>
</div>
</nav>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ paginated.

## Access

The route lives under `(protected)/admin`, so it is only reachable by admins
(guarded per FE-44).
The route lives under `[locale]/(protected)/admin`, so it is only reachable by
admins (guarded per FE-44). Copy is translated under the `adminAuditLogs`
namespace in `messages/*.json`.
Loading
Loading