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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: CI

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Verify template readiness
run: npm run verify
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ WeBase Admin Template is an independent Next.js App Router admin scaffold for bu
| `/system/notifications` | Notification center and delivery state management |
| `/system/sessions` | Active session and device risk management |
| `/system/components` | Local design-system component showcase |
| `/system/health` | Runtime mode, backend readiness, and production smoke status |
| `/system/settings` | System profile, theme, session, and notification settings |
| `/account/profile` | Current user profile, preferences, and security summary |

Expand All @@ -37,24 +38,38 @@ The login form is prefilled with demo credentials:
- Username: `admin`
- Password: `admin123`

The current mock adapter accepts any non-empty username and password, then returns a local demo session. Use the displayed `admin / admin123` credentials for the intended demo flow.
The current mock adapter accepts the displayed `admin / admin123` credentials, then returns a local demo session. HTTP mode delegates credential validation to the backend.

## Development commands

```bash
npm run dev
npm run verify
npm run lint
npm run smoke:template
npm run smoke:production
npm run build
npm run start
```

- `npm run dev` starts the local development server.
- `npm run verify` runs the full CI gate: standards lint, production smoke suite, TypeScript, and production build.
- `npm run lint` runs ESLint across the project.
- `npm run smoke:template` verifies template extension contracts such as route registry, RBAC, API adapter, form validation, docs, status pages, theme tokens, and dashboard chart loading.
- `npm run smoke:production` runs the production readiness smoke suite, including i18n, auth, API, permissions, CRUD, audit, accessibility, template, and `/system/health` checks.
- `npm run build` creates a production Next.js build and verifies buildable routes.
- `npm run start` serves the production build after `npm run build`.

## Release verification

Before connecting a real backend or deploying a release, run:

```bash
npm run verify
```

Then open `/system/health` to confirm API mode, backend/mock status, frontend version, build target, and operational indicators.

## Template extension docs

- [Add an admin module](docs/add-admin-module.md)
Expand Down
18 changes: 13 additions & 5 deletions docs/add-admin-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ Follow existing table/form patterns:
- `DataTable`, `TableToolbar`, `Pagination`, and `EmptyState` for list pages.
- `FormField`, `FormErrors`, `hasErrors`, and validators from `src/lib/forms/validators.ts`.
- `PermissionGuard` around create/edit/delete actions.
- `bulkActions` for batch operations when row selection is enabled.
- CSV export actions that call a service-level helper, for example `downloadCsv("examples.csv", exportExamplesCsv(records))`.

## 7. Add status and loading states

Expand All @@ -114,15 +116,21 @@ Create `scripts/example-experience-smoke.mjs` for source-level invariants such a

Then add or update `scripts/template-hardening-smoke.mjs` only for cross-template capabilities.

## 9. Verify
## 9. Accessibility and responsive QA

Before handing off a new module, check the module at desktop and mobile widths:

- icon-only actions include a stable `aria-label`
- dialogs, sheets, selects, and table controls are reachable by keyboard
- dense tables use stable columns or horizontal scrolling instead of overlapping text
- loading, empty, error, and disabled states remain announced through visible text or accessible labels

## 10. Verify

Run:

```bash
npm run smoke:template
node scripts/example-experience-smoke.mjs
npx tsc --noEmit --pretty false
npm run lint -- --no-warn-ignored
npm run build
npm run verify
```

31 changes: 28 additions & 3 deletions docs/api-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ Set the mode with environment variables:
NEXT_PUBLIC_API_MODE=mock
NEXT_PUBLIC_API_MODE=http
NEXT_PUBLIC_API_BASE_URL=https://api.example.com
NEXT_PUBLIC_API_TIMEOUT_MS=15000
```

- `mock` is the default and delegates to `src/lib/api/mock-adapter.ts`.
- `http` delegates to `src/lib/api/http-adapter.ts` and uses `NEXT_PUBLIC_API_BASE_URL`.
- `NEXT_PUBLIC_API_TIMEOUT_MS` controls the default HTTP timeout. The default is 15000 ms; use `0` only when a caller supplies its own `AbortSignal`.

No service or page should change when switching modes.
HTTP mode sends same-origin credentials, disables GET caching with `cache: "no-store"`, handles `204 No Content`, and normalizes both transport and business-code failures.
HTTP mode sends same-origin credentials, disables GET caching with `cache: "no-store"`, handles `204 No Content`, preserves request IDs, supports timeout/caller abort through `AbortController`, and normalizes both transport and business-code failures.

Feature modules should call service functions in `src/lib/services/*`, not adapters directly. Do not import adapters directly from pages, components, or feature services; the adapter layer is an implementation detail behind `apiClient`.

## Response contract

Expand All @@ -27,10 +31,11 @@ export interface ApiResponse<T> {
code: number;
message: string;
data: T;
requestId?: string;
}
```

The HTTP backend should either return this exact shape or be normalized in `http-adapter.ts`.
The HTTP backend should either return this exact shape or be normalized in `http-adapter.ts`. When a backend exposes a request ID, send it in `x-request-id`, `x-correlation-id`, `x-trace-id`, or the response body `requestId` field. The adapter preserves that `requestId` on both successful responses and `ApiError` instances.

## Errors

Expand All @@ -40,6 +45,7 @@ All thrown errors are normalized through `src/lib/api/errors.ts`:
throw new ApiError("Session expired", {
code: "unauthorized",
status: 401,
requestId,
details,
});
```
Expand All @@ -50,6 +56,7 @@ UI code can safely check:
- `error.code`
- `error.status`
- `error.message`
- `error.requestId`

Keep user-facing messages concise and move detailed payloads into `details`.

Expand All @@ -66,9 +73,10 @@ Recommended UI routes for common adapter failures:
1. Add or reuse a type in `src/lib/api/types.ts`.
2. Add a mock handler in `src/lib/api/mock-adapter.ts`.
3. Add a service function in `src/lib/services/<module>-service.ts`.
4. Call the service from pages/components.
4. Call the service from pages/components. Pages and components should not call `apiClient` directly.
5. If using HTTP mode, ensure the backend URL and method match the mock URL.
6. Add source-level smoke coverage for important template behavior.
7. Run `npm run verify` before opening a pull request or connecting the endpoint to a real backend.

Example service:

Expand All @@ -85,3 +93,20 @@ export async function getUsers() {
## Session and auth handling

The adapter layer is the right place to normalize `401`/`403` responses. Route-level access is handled by `RoutePermissionGuard`; API-level authorization should still be enforced by the real backend.

Backend auth endpoints expected in HTTP mode:

- `POST /auth/login`: accepts `LoginPayload` and returns `LoginResult` with `token`, `user`, and `expiresAt`.
- `GET /auth/current-user`: returns the current `CurrentUser` including the backend-authoritative permission list.
- `GET /auth/permissions`: returns `{ permissions }` for permission refresh flows.
- `POST /auth/logout`: invalidates the current backend session.

The prefilled `admin / admin123` credentials are mock-only convenience for local demos. Real HTTP mode must validate credentials on the backend and return the typed contract above.

The client stores safe session state in session storage only: token, user snapshot, expiry time, and session status. Logout clears that safe session state. A `401` must be normalized to `session_expired` and routed to `/session-expired`; a `403` must be normalized to `forbidden` and routed to `/forbidden` or an inline permission state.

## Audit event contract

Mutation endpoints should create audit events that can be correlated with API failures and support review workflows. Each audit event should include `actor`, `action`, `module`, `target`, `result`, `ipAddress`, `userAgent`, `requestId`, and `createdAt`. When a mutation changes a record, include `beforeSnapshot` and/or `afterSnapshot` when the backend can safely expose them.

Use the same `requestId` returned by the API adapter so operators can move from a UI error or network trace to the matching audit event.
14 changes: 11 additions & 3 deletions docs/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ Wrap privileged actions with `PermissionGuard`:

For destructive actions, also keep existing confirmation and toast/error behavior. The guard hides UI affordances; the backend must still enforce authorization.

## Data-scope permissions

Roles now carry three permission dimensions:

- `permissions`: menu-level access used by navigation and search.
- `actionPermissions`: backend-aligned action keys such as `system:users:write`.
- `dataScope`: one of `self`, `department`, `tenant`, or `all`.

Data scopes describe which records a role may see or mutate after it reaches a page. The frontend RBAC hides UI affordances and keeps navigation tidy, but backend authorization remains mandatory for every API request. Treat frontend role state as a user-experience hint; the server must enforce action permissions and data scope against the authenticated session.

## Super admin and legacy sessions

`allPermissions` represents the demo super-admin capability set. In mock mode, `resolvePermissions()` gives legacy stored users full permissions when no permissions array is present, preventing old local sessions from being accidentally locked out after an upgrade.
Expand All @@ -68,7 +78,5 @@ In HTTP mode, missing permissions resolve to an empty permission list. Productio
5. Restore the permission and run:

```bash
npm run smoke:template
npx tsc --noEmit --pretty false
npm run lint -- --no-warn-ignored
npm run verify
```
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
"start": "next start",
"lint": "eslint .",
"lint:standards": "node scripts/lint-standards-smoke.mjs",
"verify": "npm run lint:standards && npm run smoke:production && npx tsc --noEmit --pretty false --project tsconfig.json && npm run build",
"smoke:a11y": "node scripts/accessibility-smoke.mjs",
"smoke:api": "node scripts/api-contract-smoke.mjs",
"smoke:audit": "node scripts/audit-contract-smoke.mjs",
"smoke:auth": "node scripts/auth-contract-smoke.mjs",
"smoke:crud": "node scripts/crud-pattern-smoke.mjs",
"smoke:i18n": "node scripts/i18n-copy-smoke.mjs",
"smoke:permissions": "node scripts/data-scope-permissions-smoke.mjs",
"smoke:production": "node scripts/production-readiness-smoke.mjs",
"smoke:template": "node scripts/template-hardening-smoke.mjs"
},
"dependencies": {
Expand Down
104 changes: 104 additions & 0 deletions scripts/accessibility-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { existsSync, readFileSync } from "node:fs";

function read(path) {
return existsSync(path) ? readFileSync(path, "utf8") : "";
}

const files = {
packageJson: "package.json",
dialog: "src/components/ui/dialog.tsx",
sheet: "src/components/ui/sheet.tsx",
select: "src/components/ui/select.tsx",
dataTable: "src/components/data-table/data-table.tsx",
globalSearch: "src/components/layout/global-search-dialog.tsx",
appHeader: "src/components/layout/app-header.tsx",
componentsPage: "src/app/system/components/page.tsx",
addModuleDoc: "docs/add-admin-module.md",
};

const source = Object.fromEntries(Object.entries(files).map(([key, path]) => [key, read(path)]));

const checks = [
{
name: "package exposes accessibility smoke command",
pass: source.packageJson.includes('"smoke:a11y"') && source.packageJson.includes("accessibility-smoke.mjs"),
},
{
name: "dialog traps focus and exposes accessible names",
pass:
source.dialog.includes("role=\"dialog\"") &&
source.dialog.includes("aria-modal=\"true\"") &&
source.dialog.includes("aria-labelledby") &&
source.dialog.includes("aria-label") &&
source.dialog.includes("initialFocusRef") &&
source.dialog.includes("previousActiveElementRef") &&
source.dialog.includes("event.key === \"Escape\"") &&
source.dialog.includes("event.key !== \"Tab\""),
},
{
name: "sheet traps focus and exposes accessible names",
pass:
source.sheet.includes("role=\"dialog\"") &&
source.sheet.includes("aria-modal=\"true\"") &&
source.sheet.includes("aria-labelledby") &&
source.sheet.includes("aria-label") &&
source.sheet.includes("previousActiveElementRef") &&
source.sheet.includes("event.key === \"Escape\"") &&
source.sheet.includes("event.key !== \"Tab\""),
},
{
name: "select supports listbox semantics and keyboard navigation",
pass:
source.select.includes("role=\"combobox\"") &&
source.select.includes("aria-expanded") &&
source.select.includes("aria-controls") &&
source.select.includes("aria-activedescendant") &&
source.select.includes("role=\"listbox\"") &&
source.select.includes("role=\"option\"") &&
source.select.includes("ArrowDown") &&
source.select.includes("Home") &&
source.select.includes("End") &&
source.select.includes("Escape"),
},
{
name: "data table has header scopes, selectable labels, loading and empty states",
pass:
source.dataTable.includes("scope=\"col\"") &&
source.dataTable.includes("aria-label=\"Select all rows\"") &&
source.dataTable.includes("aria-checked") &&
source.dataTable.includes("aria-hidden=\"true\"") &&
source.dataTable.includes("emptyContent"),
},
{
name: "global search has initial focus and labelled search input",
pass:
source.globalSearch.includes("initialFocusRef") &&
source.globalSearch.includes("searchInputRef") &&
source.globalSearch.includes("aria-label") &&
source.globalSearch.includes("Esc"),
},
{
name: "icon buttons in header and showcase expose aria-label text",
pass:
source.appHeader.includes("aria-label") &&
source.componentsPage.includes("aria-label") &&
!/size="icon"(?:(?!aria-label).){0,120}>/s.test(source.appHeader),
},
{
name: "module docs mention accessibility requirements",
pass:
source.addModuleDoc.includes("Accessibility") &&
source.addModuleDoc.includes("aria-label") &&
source.addModuleDoc.includes("keyboard"),
},
];

const failures = checks.filter((check) => !check.pass);

if (failures.length > 0) {
console.error("Accessibility smoke failed:");
for (const failure of failures) console.error(`- ${failure.name}`);
process.exitCode = 1;
} else {
console.log("PASS accessibility smoke checks");
}
Loading
Loading