Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3c4c085
readme matches the current repo, auth and deploy
theobong Sep 23, 2026
c271dcf
one env example with accurate api url notes
theobong Sep 23, 2026
df4ae34
wrangler header describes the direct upload deploy
theobong Sep 23, 2026
90a28e9
deploy workflow notes match same-origin api
theobong Sep 23, 2026
b748cfc
drop stale transpile comment
theobong Sep 23, 2026
c0cabe2
drop next start script, meaningless for a static export
theobong Sep 23, 2026
3bc1dd5
gitignore covers every env file but the example
theobong Sep 23, 2026
a346b4a
ci runs on stacked prs too
theobong Sep 23, 2026
7bac334
drop unreferenced pin-cleanup svg
theobong Sep 23, 2026
87da9fb
wire the favicon into layout metadata
theobong Sep 23, 2026
d79abf3
drop unused clsx, lucide-react and tailwind-merge
theobong Sep 23, 2026
177c963
tailwind config: accurate header, real content glob, no unused animat…
theobong Sep 23, 2026
1df5dc8
deploy header names the staging and production triggers
theobong Sep 23, 2026
5918d6d
readme wraps the sign-in paragraph; gitignore keeps the plain DS_Stor…
theobong Sep 23, 2026
7c08b39
globals: drop unused spinner and scrollbar utilities, align tailwind …
theobong Sep 23, 2026
6c8b421
merge origin/main
theobong Sep 24, 2026
26db25f
maps: one leaflet base for tiles, resize and teardown; reuse the tile…
theobong Sep 24, 2026
7b20083
move the home query hooks into features/home
theobong Sep 24, 2026
132cec2
query layer: one infinite-list factory, flatPages and invalidateKeys;…
theobong Sep 24, 2026
c3d1d3d
merge the pr 8 branch
theobong Sep 24, 2026
f0c743c
one useSelection hook and shared list pieces (states, card, search, l…
theobong Sep 24, 2026
a2d4d01
every section uses useSelection and the shared list pieces
theobong Sep 24, 2026
f4a088a
one implementation per helper: initials, short ids, first names, comp…
theobong Sep 24, 2026
a22a3eb
perf: date formatters built once, not per row
theobong Sep 24, 2026
aac941b
perf: invalidateKeys skips keys a broader key in the batch already co…
theobong Sep 24, 2026
b3c2932
perf: list rows are memoized with a stable select callback
theobong Sep 24, 2026
dab6585
merge the pr 1 branch (#32)
theobong Sep 24, 2026
525f9af
lint: eslint 9 flat config with typed rules; drop the meaningless no-…
theobong Sep 24, 2026
d93d62f
knip config; unresolved postcss type annotation removed
theobong Sep 24, 2026
7a2a2e2
jscpd duplication gate; knip and jscpd steps in ci; lint via the esli…
theobong Sep 24, 2026
c6b08aa
keep the https link check as it was; the shared safe-link gate is def…
theobong Sep 24, 2026
2511aeb
invalidateKeys never skips object-param keys; keep useDiscoveryTask u…
theobong Sep 24, 2026
c18e638
memoized rows take the clock as a prop, so their age labels tick ever…
theobong Sep 24, 2026
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
16 changes: 0 additions & 16 deletions .env.example

This file was deleted.

12 changes: 9 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
name: CI

# main is the only long-lived branch and the only PR target (issue civfix/issue-tracker#108): main is
# the staging lane, and production ships from a published v* release.
# main is the only long-lived branch (issue civfix/issue-tracker#108): main is the staging lane, and
# production ships from a published v* release. No base filter: a stacked PR targets its parent slice's
# branch and still needs CI before it retargets onto main.
on:
pull_request:
branches: [main]

# Cancel superseded runs on the same ref.
concurrency:
Expand Down Expand Up @@ -36,6 +36,12 @@ jobs:
- name: Lint
run: pnpm lint

- name: Unused files, exports and dependencies (knip)
run: pnpm --filter admin knip

- name: Duplication (jscpd)
run: pnpm --filter admin dup

- name: Typecheck
run: pnpm typecheck

Expand Down
20 changes: 10 additions & 10 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
name: Deploy (Cloudflare Pages)

# Builds the civfix-admin static export and publishes it to its Cloudflare Pages project on every
# push to main, plus on-demand via the Actions "Run workflow" button. One app ships from this repo:
# - admin -> Pages project "civfix-admin" (apps/admin/out, repo-root wrangler.jsonc) -> admin.civfix.org
# Builds the civfix-admin static export and publishes it to its Cloudflare Pages project "civfix-admin"
# (apps/admin/out, repo-root wrangler.jsonc). A push to main (or a manual run on main) deploys STAGING,
# admin.civfix.dev; a published v* release deploys PRODUCTION, admin.civfix.org (ref -> environment below).
#
# Required GitHub configuration (Settings -> Secrets and variables -> Actions):
#
Expand All @@ -11,21 +11,21 @@ name: Deploy (Cloudflare Pages)
# CLOUDFLARE_ACCOUNT_ID The Cloudflare account id that owns the Pages project.
#
# Variables (vars.*), inlined into the static export at BUILD time (NEXT_PUBLIC_*):
# NEXT_PUBLIC_API_URL Base URL of the civfix API (e.g. https://api.civfix.example). The admin
# dashboard is served at admin.civfix.org and talks to this API. The API's
# WEB_ORIGINS must include https://admin.civfix.org for CORS + cookies.
# NEXT_PUBLIC_API_URL Leave unset: both environments call the API same-origin (see below).
# Setting it points the build at a cross-origin API instead.
# NEXT_PUBLIC_CARTO_API_KEY Optional publishable CARTO key for the basemap tiles.
#
# The project name + prebuilt output directory come from wrangler.jsonc, so the deploy step is just
# `pages deploy` (no args). The project is created automatically by the first run. We use Direct Upload
# (build here, ship the prebuilt out/) rather than Cloudflare's native Git build; @civfix/shared installs
# from the public-read private registry via the committed .npmrc, so no submodule checkout is needed.
# from the public-read private registry via the committed .npmrc.
# SPA deep links and security/cache headers are handled by the app's public/{_redirects,_headers}
# (copied into out/).

# Ref -> environment (issue https://github.com/civfix/issue-tracker/issues/108):
# push to main -> STAGING --branch=staging -> staging.civfix-admin.pages.dev -> admin.civfix.dev
# (via the civfix-infra staging Caddy, which reverse-proxies the SPA to that
# alias — env/staging.sh ADMIN_SPA_UPSTREAM)
# alias: env/staging.sh ADMIN_SPA_UPSTREAM)
# published v* tag -> PRODUCTION --branch=main -> civfix-admin.pages.dev -> admin.civfix.org
#
# There is no `dev` branch: feature branches PR into main, main IS the staging lane, and cutting a
Expand All @@ -38,7 +38,7 @@ on:
branches: [main]
release:
# `released`, NOT `published`: `published` also fires for a PRERELEASE, and a prerelease must never
# reach admin.civfix.org — the operator plane. The regex gate below is the second guard.
# reach admin.civfix.org, the operator plane. The regex gate below is the second guard.
types: [released]
workflow_dispatch:

Expand Down Expand Up @@ -117,7 +117,7 @@ jobs:
# hash that already includes the NEXT_PUBLIC_* vars, so a changed API URL or source file rebuilds.
# The key is scoped by DEPLOY_ENV anyway: staging and production build the same commit, and
# `.next/cache` is restored underneath Turbo, so the two must never share one. (Admin happens to
# build identically in both environments today — this keeps that from becoming load-bearing.)
# build identically in both environments today; this keeps that from becoming load-bearing.)
- name: Restore Turbo + Next.js build cache
uses: actions/cache@v4
with:
Expand Down
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ dist/
npm-debug.log*
pnpm-debug.log*

# Env files (never commit secrets)
# Env files (never commit secrets); only the documented example is tracked
.env
.env.local
.env.*.local
.env.*
!.env.example

# Editor / OS
.DS_Store
Expand Down
37 changes: 22 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

Workspace for the civfix ADMIN / OPERATOR dashboard, served at `admin.civfix.org`. A pnpm + Turbo
monorepo. This is the internal tool operators use to triage reports, route jurisdiction contacts,
moderate content, run mail outreach, provision government accounts, and read analytics. It is a
sibling of `civfix-shared`, `civfix-backend`, `civfix-web`, and `civfix-mobile`.
moderate content, run mail outreach, provision government accounts, and read analytics. Its siblings
are `civfix-app` (community web + mobile, and the `@civfix/shared` contract), `civfix-backend` (the
API it calls) and `civfix-infra` (the edge that serves it).

## Layout

Expand All @@ -18,37 +19,43 @@ civfix-admin/

`@civfix/shared` (the shared contract package) is installed from the private Verdaccio registry at
`https://repo.civfix.org`. The repo-root `.npmrc` scopes `@civfix` to it and the app depends on a
published version (`^0.1.0` today); the registry allows anonymous read, so no credentials are needed.
After cloning, `pnpm install` fetches it — there is no submodule to initialize. The published tarball
already contains the built `dist`, so no local build of the contract is required.
published version by caret range (see `apps/admin/package.json`); the registry allows anonymous read, so
no credentials are needed. After cloning, `pnpm install` fetches it. The published tarball already
contains the built `dist`, so no local build of the contract is required.

## Commands (from this root)

```
pnpm install # fetches @civfix/shared from repo.civfix.org
pnpm build # admin (next build, static export -> apps/admin/out)
pnpm typecheck
pnpm lint
pnpm lint # eslint (flat config, typed rules) over apps/admin
pnpm test # vitest unit tests
pnpm --filter admin knip # unused files, exports and dependencies
pnpm --filter admin dup # jscpd duplication gate (app code and tests measured separately)
pnpm dev # runs the dashboard dev server (admin)
```

## Auth + API

The dashboard talks to the civfix API at `NEXT_PUBLIC_API_URL` (inlined at build time; see
`.env.example`). Operator sign-in reuses the civfix Email-OTP flow through the allowlist-gated
`/admin/auth/*` routes; only emails in the backend `ADMIN_EMAILS` allowlist can receive a code. The
shell renders only after an authenticated `operator` session exists; otherwise the login gate shows.
Cookies + CSRF + `x-client: web` are sent exactly as the public web app does. The API's
`WEB_ORIGINS` must include `https://admin.civfix.org`.
In production the dashboard calls the API same-origin: `admin.civfix.org` sits behind Cloudflare Access,
and the edge serves the SPA and proxies `/v1/admin/*` to the backend, so `NEXT_PUBLIC_API_URL` stays
unset. A development build defaults to `http://localhost:8080`; see `apps/admin/.env.example`.

Operator sign-in is the Cloudflare Access exchange: the dashboard first reuses an existing operator
session, and otherwise posts to the admin Access-exchange route, which trades the Access identity the
edge attached for an `operator` session. An Access identity whose email is not on the operator
allowlist gets the "forbidden" screen. The shell renders only after an operator session exists.
Cookies, CSRF and `x-client: web` are sent exactly as the public web app sends them.

## Deploy

Cloudflare Pages (Direct Upload). GitHub Actions builds the static export (a plain checkout +
`pnpm install`, which fetches `@civfix/shared` from `repo.civfix.org`; pinned Node 22 + pnpm 9.12.0)
and ships the prebuilt `apps/admin/out` via `wrangler pages deploy`. The Pages project name and output
dir come from the repo-root `wrangler.jsonc`
(`civfix-admin` -> `apps/admin/out`). See `.github/workflows/deploy.yml` and
`../documents/phase2/07-civfix-admin-repo.md`.
(`civfix-admin` -> `apps/admin/out`). A push to `main` deploys staging (`admin.civfix.dev`); a published
`v*` release deploys production (`admin.civfix.org`). See `.github/workflows/deploy.yml`.

## License

Expand All @@ -58,6 +65,6 @@ covered by the declaration in [REUSE.toml](REUSE.toml); there are no per-file
license headers. The dashboard honors the AGPL's source offer with the
"Source code" link on its home screen and sign-in screen, which points at the
deployed commit. Contributions are accepted under the
[Contributor License Agreement](CLA.md) — see
[Contributor License Agreement](CLA.md); see
[CONTRIBUTING.md](CONTRIBUTING.md). civfix is a project of Reach Out Los Angeles Inc.; the civfix name and
logos are its trademarks and are not covered by the license.
14 changes: 6 additions & 8 deletions apps/admin/.env.example
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
# civfix admin / operator dashboard environment (admin.civfix.org).
#
# NEXT_PUBLIC_* values are INLINED into the static export at BUILD time (not read at runtime). Set
# them before `pnpm -C apps/admin build` (or `pnpm build` from the repo root). In CI they come from
# repo Variables (vars.*); see .github/workflows/deploy.yml.
# NEXT_PUBLIC_* values are INLINED into the static export at BUILD time, not read at runtime. Set them
# before `pnpm build`. In CI they come from repo Variables (vars.*); see .github/workflows/deploy.yml.

# Base URL of the civfix API. The dashboard is served at admin.civfix.org and calls this API for all
# /admin/* data and auth; the API's WEB_ORIGINS must include https://admin.civfix.org. Defaults to the
# local backend when unset.
# Example (production): https://api.civfix.example
NEXT_PUBLIC_API_URL=http://localhost:8080
# Base URL of the civfix API. Leave it unset for staging and production: there the dashboard calls the
# API same-origin, because the edge serves the SPA and proxies /v1/admin/* to the backend behind the
# same Cloudflare Access app. When unset, a development build (`pnpm dev`) calls http://localhost:8080.
# NEXT_PUBLIC_API_URL=http://localhost:8080

# Publishable CARTO basemap api key, appended to the Leaflet raster tile URLs. Optional: unset, the maps
# still render the same CARTO tiles, just with CARTO's watermark. Public by nature (it is inlined into
Expand Down
13 changes: 0 additions & 13 deletions apps/admin/.eslintrc.json

This file was deleted.

7 changes: 7 additions & 0 deletions apps/admin/.jscpd.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"path": ["src"],
"format": ["typescript", "tsx", "css"],
"ignore": ["**/*.test.ts", "**/*.test.tsx", "src/test/**"],
"reporters": ["console"],
"threshold": 1
}
7 changes: 7 additions & 0 deletions apps/admin/.jscpd.tests.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"path": ["src"],
"format": ["typescript", "tsx"],
"pattern": "{**/*.test.ts,**/*.test.tsx,src/test/**}",
"reporters": ["console"],
"threshold": 6
}
49 changes: 49 additions & 0 deletions apps/admin/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { dirname } from "node:path"
import { fileURLToPath } from "node:url"

import { FlatCompat } from "@eslint/eslintrc"

const baseDirectory = dirname(fileURLToPath(import.meta.url))

// eslint-config-next 15.x ships only eslintrc presets; FlatCompat is the supported bridge until 16.
const compat = new FlatCompat({ baseDirectory })

const config = [
{
ignores: [".next/**", "out/**", "coverage/**", "next-env.d.ts"],
},
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
// images.unoptimized is set for the static export, so next/image would render a plain <img> anyway.
"@next/next/no-img-element": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: baseDirectory,
},
},
rules: {
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports", fixStyle: "separate-type-imports" },
],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false } },
],
"@typescript-eslint/switch-exhaustiveness-check": "error",
},
},
]

export default config
28 changes: 28 additions & 0 deletions apps/admin/knip.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { KnipConfig } from "knip"

const CSS_IMPORT = /@import\s+(?:url\(\s*)?["']([^"']+)["']/g

const config: KnipConfig = {
project: ["src/**/*.{ts,tsx,css}", "*.{ts,mjs}"],
next: {
config: ["next.config.mjs"],
entry: ["src/app/**/{layout,page,not-found,error,loading}.tsx"],
},
vitest: {
config: ["vitest.config.ts"],
entry: ["src/**/*.test.{ts,tsx}", "vitest.setup.ts"],
},
// Knip skips any extension it cannot compile, so without this an orphaned stylesheet is never
// reported; following @import is what keeps colors-and-type.css (reached only from admin.css) used.
compilers: {
css: (text: string) =>
[...text.matchAll(CSS_IMPORT)].map(([, specifier]) => `import "${specifier}";`).join("\n"),
},
ignoreDependencies: [
// Loaded by name through FlatCompat.extends("next/...") in eslint.config.mjs, which knip's ESLint
// plugin cannot see into.
"eslint-config-next",
],
}

export default config
1 change: 0 additions & 1 deletion apps/admin/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ const nextConfig = {
env: {
NEXT_PUBLIC_COMMIT_SHA: resolveCommitSha(),
},
// The @civfix/shared package ships ESM + CJS from the workspace; let Next transpile it.
transpilePackages: ["@civfix/shared"],
}

Expand Down
14 changes: 7 additions & 7 deletions apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,24 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint --max-warnings=0",
"lint": "eslint . --max-warnings=0",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"clean": "rimraf .next out *.tsbuildinfo"
"clean": "rimraf .next out *.tsbuildinfo",
"knip": "knip",
"dup": "jscpd -c .jscpd.json && jscpd -c .jscpd.tests.json"
},
"dependencies": {
"@civfix/shared": "^0.57.0",
"@tanstack/react-query": "^5.62.7",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^0.469.0",
"next": "15.5.25",
"react": "19.0.0",
"react-dom": "19.0.0",
"tailwind-merge": "^2.6.0",
"zustand": "^5.0.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
Expand All @@ -38,11 +37,12 @@
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-config-next": "15.5.25",
"jscpd": "^5.3.2",
"jsdom": "^26.1.0",
"knip": "^6.38.0",
"postcss": "^8.5.28",
"rimraf": "^6.0.1",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.2",
"vitest": "^3.2.7"
}
Expand Down
1 change: 0 additions & 1 deletion apps/admin/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
Expand Down
6 changes: 0 additions & 6 deletions apps/admin/public/ds/pin-cleanup.svg

This file was deleted.

Loading
Loading