Skip to content

Commit 05a2767

Browse files
committed
Large UX/docs batch: ESC/outside modal close, moderation queue ergonomics, naming consistency, policy+test docs
1 parent 8254aec commit 05a2767

8 files changed

Lines changed: 214 additions & 27 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,17 @@ This repository now includes:
7272
Detailed setup steps:
7373

7474
- [docs/cloudflare-auth-setup.md](./docs/cloudflare-auth-setup.md)
75+
- [docs/access-policy-templates.md](./docs/access-policy-templates.md)
76+
77+
## Testing
78+
79+
- Test plan: [docs/testing-plan.md](./docs/testing-plan.md)
80+
- Run baseline checks:
81+
82+
```bash
83+
npm test
84+
npm run build
85+
```
7586

7687
## Running with Docker Compose
7788

docs/BACKLOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ State: stabilization pass (no net-new product features unless explicitly approve
1717
- [ ] E-mail notifications (starting with account approval)
1818
- [ ] Rehaul of dockumentation (readme.md)
1919
- [ ] Set up a compehensive testing plan
20+
- [x] Set up a compehensive testing plan
2021
- [ ] Branding
2122
- [ ] Elevation plot visibility toggle
2223
- [x] Instead of showing actions on the users in the admin panel, show a simple list of users and make open the profile popover when clicking the names. This should be the same popover that appears anywhere else. Admin gets extra moderation buttons.
@@ -59,17 +60,20 @@ State: stabilization pass (no net-new product features unless explicitly approve
5960
- [ ] Full terminology pass (Project/Simulation/Setup/Snapshot/etc.)
6061
- [x] Move crowded metadata out of list rows and into details panels
6162
- [ ] Clean sidebar information density and progressive disclosure
63+
- Progress: simplified library/action labels and streamlined user moderation list flow.
6264
- [ ] Unify labels/buttons across libraries and managers
65+
- Progress: aligned labels for library open/save/add actions and moderation wording.
6366
- [ ] Standardize error messages across endpoints and UI surfaces
6467
- Progress: backend endpoints now use centralized error normalization and status mapping; UI surface pass still pending.
68+
- [x] Modal UX: support ESC and click-outside to close dialogs (in addition to close button)
6569

6670
### Simulation quality clarity
6771
- [ ] Improve explanatory info for FSPL / TwoRay / ITM and defaults
6872
- [ ] Document map sampling strategies clearly in UI help
6973
- [ ] Recheck pass/fail interpretation and communication around terrain blocking
7074

7175
### Security and access hardening
72-
- [ ] Productize Access policy templates in-app docs and setup checklist
76+
- [x] Productize Access policy templates in-app docs and setup checklist
7377
- [x] Add admin warning surfaces for unsafe auth/access configuration
7478

7579
## Hardening execution paths (agreed, no further discussion required now)

docs/access-policy-templates.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Cloudflare Access Policy Templates
2+
3+
## Objective
4+
Provide a safe baseline for LinkSim without over-complicated guardrails.
5+
6+
## Recommended baseline
7+
8+
### Application
9+
- Protect the LinkSim app hostname with Cloudflare Access.
10+
- Identity providers:
11+
- GitHub (primary)
12+
- One-time PIN (fallback)
13+
14+
### Session duration
15+
- Recommended: `24h` to `7d` depending on team preference.
16+
- Keep lower for admin-heavy deployments.
17+
18+
### App policy (minimum)
19+
- Action: `Allow`
20+
- Include:
21+
- Allowed email domains OR specific emails/groups
22+
- Exclude:
23+
- Explicitly blocked users/groups (if used)
24+
25+
### LinkSim app-level approval
26+
- Keep `REGISTRATION_MODE=approval_required`.
27+
- Cloudflare Access answers “who can sign in”.
28+
- LinkSim approval answers “who can use simulation features”.
29+
30+
## Hardened profile (optional)
31+
- Restrict to one IdP per environment.
32+
- Reduce session duration for admin users.
33+
- Add country/IP device posture restrictions if required by org policy.
34+
35+
## Required env variables
36+
- `ACCESS_TEAM_DOMAIN`
37+
- `ACCESS_AUD`
38+
- `REGISTRATION_MODE=approval_required`
39+
- `ADMIN_USER_IDS=<comma-separated admin ids>`
40+
- `AUTH_OBSERVABILITY=true` (recommended)
41+
42+
## Validation checklist
43+
1. Unauthenticated user gets Access challenge.
44+
2. Authenticated non-approved user lands in pending flow.
45+
3. Admin can open:
46+
- `/api/auth-diagnostics`
47+
- `/api/schema-diagnostics`
48+
4. App denies privileged endpoints for non-admin users.
49+
50+
## Common misconfigurations
51+
- Missing `ACCESS_AUD` or `ACCESS_TEAM_DOMAIN`.
52+
- `ALLOW_INSECURE_DEV_AUTH=true` in production.
53+
- Expecting Access policy alone to replace LinkSim role/approval controls.

docs/cloudflare-auth-setup.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,8 @@ npm run dev:edge
103103
```
104104

105105
This is for local testing only.
106+
107+
## Related docs
108+
109+
- Access policy templates: `docs/access-policy-templates.md`
110+
- Testing plan: `docs/testing-plan.md`

docs/testing-plan.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# LinkSim Testing Plan
2+
3+
## Goals
4+
- Prevent auth/permission regressions.
5+
- Validate simulation correctness stability.
6+
- Keep UI workflow regressions visible.
7+
- Make cloud deployment checks repeatable.
8+
9+
## Layers
10+
11+
### 1) Unit tests (fast, every commit)
12+
- Location: `src/**/*.test.ts`, `functions/**/*.test.ts`
13+
- Focus:
14+
- RF model math (`propagation`, `coverage`, `terrainLoss`)
15+
- Auth source resolution and fallback behavior
16+
- Error normalization/status mapping
17+
18+
### 2) API behavior tests (local edge)
19+
- Run against local `dev:edge` with D1.
20+
- Focus:
21+
- `me`, `users`, `users/[id]`, `library`, `notifications`, `changes`
22+
- Admin vs non-admin permissions
23+
- Pending/revoked/deleted session behavior
24+
- Metadata repair endpoints
25+
26+
### 3) UI smoke checks
27+
- Existing smoke scripts in `scripts/`.
28+
- Focus:
29+
- Map render + sidebar interaction
30+
- Path profile updates
31+
- Modal stack behavior
32+
- User settings open/save/logout
33+
34+
### 4) Deployment checks (preview/prod)
35+
- Cloudflare Access gate active.
36+
- D1 migration applied.
37+
- Diagnostics endpoints for admins:
38+
- `/api/auth-diagnostics`
39+
- `/api/schema-diagnostics`
40+
41+
## Required checks before push
42+
1. `npm test`
43+
2. `npm run build`
44+
3. Manual quick pass:
45+
- open app
46+
- open/close nested modals
47+
- load simulation library + site library
48+
- open user settings and verify user state loads
49+
50+
## Priority expansion
51+
- Add permission-matrix API tests for:
52+
- self-role protection
53+
- pending user restrictions
54+
- revoked/deleted session handling
55+
- admin-only moderation routes
56+
- Add end-to-end scenario for metadata repair and creator/editor attribution.

src/components/ModalOverlay.tsx

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,60 @@ import { createPortal } from "react-dom";
44
type ModalOverlayProps = {
55
"aria-label": string;
66
children: ReactNode;
7+
onClose?: () => void;
78
};
89

9-
let modalStackCounter = 0;
10+
let modalIdCounter = 0;
1011
let openModalCount = 0;
12+
const openModalStack: number[] = [];
1113

12-
const nextModalZIndex = (): number => {
13-
modalStackCounter += 1;
14-
return 2000 + modalStackCounter * 10;
15-
};
16-
17-
export function ModalOverlay({ children, ...rest }: ModalOverlayProps) {
18-
const zIndex = useMemo(() => nextModalZIndex(), []);
14+
export function ModalOverlay({ children, onClose, ...rest }: ModalOverlayProps) {
15+
const modalId = useMemo(() => {
16+
modalIdCounter += 1;
17+
return modalIdCounter;
18+
}, []);
19+
const zIndex = useMemo(() => 2000 + modalId * 10, [modalId]);
1920

2021
useEffect(() => {
2122
openModalCount += 1;
23+
openModalStack.push(modalId);
2224
const previousOverflow = document.body.style.overflow;
2325
document.body.style.overflow = "hidden";
26+
const onKeyDown = (event: KeyboardEvent) => {
27+
if (event.key !== "Escape") return;
28+
const top = openModalStack[openModalStack.length - 1];
29+
if (top !== modalId) return;
30+
if (!onClose) return;
31+
event.preventDefault();
32+
onClose();
33+
};
34+
window.addEventListener("keydown", onKeyDown);
2435
return () => {
36+
window.removeEventListener("keydown", onKeyDown);
2537
openModalCount = Math.max(0, openModalCount - 1);
38+
const idx = openModalStack.lastIndexOf(modalId);
39+
if (idx >= 0) openModalStack.splice(idx, 1);
2640
if (openModalCount === 0) {
2741
document.body.style.overflow = previousOverflow;
2842
}
2943
};
30-
}, []);
44+
}, [modalId, onClose]);
3145

3246
return createPortal(
33-
<div aria-modal="true" className="library-manager-overlay" role="dialog" style={{ zIndex }} {...rest}>
47+
<div
48+
aria-modal="true"
49+
className="library-manager-overlay"
50+
onMouseDown={(event) => {
51+
if (!onClose) return;
52+
if (event.target !== event.currentTarget) return;
53+
const top = openModalStack[openModalStack.length - 1];
54+
if (top !== modalId) return;
55+
onClose();
56+
}}
57+
role="dialog"
58+
style={{ zIndex }}
59+
{...rest}
60+
>
3461
{children}
3562
</div>,
3663
document.body,

src/components/Sidebar.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,10 +1072,10 @@ export function Sidebar() {
10721072
onClick={() => setShowSimulationLibraryManager(true)}
10731073
type="button"
10741074
>
1075-
Open Simulation Library
1075+
Simulation Library
10761076
</button>
10771077
<button className="inline-action" onClick={saveSimulationAsNew} type="button">
1078-
Save Current Simulation
1078+
Save Simulation
10791079
</button>
10801080
</div>
10811081
{simulationSaveStatus ? <p className="field-help">{simulationSaveStatus}</p> : null}
@@ -1120,7 +1120,7 @@ export function Sidebar() {
11201120
<p className="field-help">Use Site Library to add/edit sites, then add selected sites to this simulation.</p>
11211121
<div className="chip-group">
11221122
<button className="inline-action" onClick={() => setShowSiteLibraryManager(true)} type="button">
1123-
Open Site Library
1123+
Site Library
11241124
</button>
11251125
{siteLibrary.length ? (
11261126
<button className="inline-action" onClick={() => insertSiteFromLibrary(siteLibrary[0].id)} type="button">
@@ -1748,7 +1748,7 @@ export function Sidebar() {
17481748
</section>
17491749

17501750
{profilePopupUser ? (
1751-
<ModalOverlay aria-label="User Profile">
1751+
<ModalOverlay aria-label="User Profile" onClose={() => setProfilePopupUser(null)}>
17521752
<div className="library-manager-card user-profile-popup">
17531753
<div className="library-manager-header">
17541754
<h2>User Profile</h2>
@@ -1783,7 +1783,7 @@ export function Sidebar() {
17831783
) : null}
17841784

17851785
{changeLogPopup ? (
1786-
<ModalOverlay aria-label="Change Log">
1786+
<ModalOverlay aria-label="Change Log" onClose={() => setChangeLogPopup(null)}>
17871787
<div className="library-manager-card">
17881788
<div className="library-manager-header">
17891789
<h2>Change Log · {changeLogPopup.label}</h2>
@@ -1818,7 +1818,7 @@ export function Sidebar() {
18181818
) : null}
18191819

18201820
{resourceDetailsPopup ? (
1821-
<ModalOverlay aria-label="Resource Details">
1821+
<ModalOverlay aria-label="Resource Details" onClose={() => setResourceDetailsPopup(null)}>
18221822
<div className="library-manager-card user-profile-popup">
18231823
<div className="library-manager-header">
18241824
<h2>Details · {resourceDetailsPopup.label}</h2>
@@ -1863,7 +1863,7 @@ export function Sidebar() {
18631863
) : null}
18641864

18651865
{showSimulationLibraryManager ? (
1866-
<ModalOverlay aria-label="Simulation Library">
1866+
<ModalOverlay aria-label="Simulation Library" onClose={() => setShowSimulationLibraryManager(false)}>
18671867
<div className="library-manager-card">
18681868
<div className="library-manager-header">
18691869
<h2>Simulation Library</h2>
@@ -1895,7 +1895,7 @@ export function Sidebar() {
18951895
</label>
18961896
<div className="chip-group">
18971897
<button className="inline-action" onClick={saveSimulationAsNew} type="button">
1898-
Save Current Simulation
1898+
Save Simulation
18991899
</button>
19001900
</div>
19011901
{simulationSaveStatus ? <p className="field-help">{simulationSaveStatus}</p> : null}
@@ -2014,7 +2014,7 @@ export function Sidebar() {
20142014
</ModalOverlay>
20152015
) : null}
20162016
{showSiteLibraryManager ? (
2017-
<ModalOverlay aria-label="Site Library">
2017+
<ModalOverlay aria-label="Site Library" onClose={() => setShowSiteLibraryManager(false)}>
20182018
<div className="library-manager-card">
20192019
<div className="library-manager-header">
20202020
<h2>Site Library</h2>
@@ -2040,7 +2040,7 @@ export function Sidebar() {
20402040
onClick={() => setShowAddLibraryForm((current) => !current)}
20412041
type="button"
20422042
>
2043-
{showAddLibraryForm ? "Hide Add" : "Add Library Site"}
2043+
{showAddLibraryForm ? "Hide Add Form" : "Add Site"}
20442044
</button>
20452045
<button
20462046
className="inline-action"
@@ -2077,7 +2077,7 @@ export function Sidebar() {
20772077
</div>
20782078
{showAddLibraryForm ? (
20792079
<div className="library-editor">
2080-
<h3>Add Library Site</h3>
2080+
<h3>Add Site</h3>
20812081
<label className="field-grid">
20822082
<span>Name</span>
20832083
<input

0 commit comments

Comments
 (0)