diff --git a/.forge/features/hacker-management/spec.md b/.forge/features/hacker-management/spec.md index 9ffb34c5a..c795d345d 100644 --- a/.forge/features/hacker-management/spec.md +++ b/.forge/features/hacker-management/spec.md @@ -18,8 +18,11 @@ the readiness signal that slice computed finally means something. ## Users / actors -- **Officers** — the only actors here. They read the roster, change statuses, - blacklist, and un-blacklist. +- **Hacker readers** — users granted Read Hackers can browse, search, filter, + and open applications, including their event attendance. They cannot edit. +- **Hacker editors** — users granted Edit Hackers can also correct applications, + adjust points, change statuses singly or in bulk, and delete applications. +- **Officers** — retain full access, including blacklist information and controls. - **Applicants** — never see this screen. They experience it only as the mail a transition sends. They are the reason the guards exist. @@ -27,6 +30,15 @@ Not actors in this slice: applicants applying (the hack sites own that, and the SDK carrying it is two slices out), and anyone checking in (moved to the event slice). +### Delegated access (approved 2026-09-06) + +Read Hackers and Edit Hackers must work without officer access. Editors also +have read access. The Hackers navigation and direct links use the same policy. +Readers see application details and filters without selection or write controls. +Blacklist information, filtering, and changes remain officer-only. Editors cannot +delete a blacklisted application to clear that restriction; they must ask an +officer. Configuration and event check-in permissions remain separate. + ## User-visible interface **`/admin/hackathon/[id]/hackers`** — the roster for one hackathon, reached from diff --git a/.forge/features/hacker-management/srd.md b/.forge/features/hacker-management/srd.md index dd32f2145..ed71bd50c 100644 --- a/.forge/features/hacker-management/srd.md +++ b/.forge/features/hacker-management/srd.md @@ -29,23 +29,32 @@ From `docs/agentic-development/forge-engineering-principles.md`: ## Access policy -Every procedure is `permProcedure` asserting officer, matching the -configuration router it sits beside. - -- **Unauthenticated / non-officer:** no access; the route redirects. -- **Officer:** full read and write. - -Two notes that are not obvious: - -- **`READ_HACKERS` exists and is deliberately not accepted.** A read-only tier - would be defensible for the roster, but the roster carries applicant PII - (email, school, phone via the hacker record) and every write on this screen is - officer-only anyway. Splitting the tier is a decision for whoever needs it, - not a guess to make now. -- **The blacklist flag must never leave the officer tier.** It is a judgement - about a person recorded where they cannot see it. No procedure reachable by a - member or by an applicant may return it, and the SDK slice must not expose it - when it starts serving hacker data to external sites. +Delegated hacker access was approved on 2026-09-06 and supersedes the original +officer-only policy. Existing permission bits are reused; no migration is needed. + +- `READ_HACKERS` or `EDIT_HACKERS`: roster options, filters, counts, selection + survival, application details, and the existing hacker event-attendance read. +- `EDIT_HACKERS`: profile corrections, point adjustments, single/bulk status + changes (including preview), and application deletion. +- `IS_OFFICER`: overrides the above and is still required for blacklist access + and hackathon configuration. Do not broaden the platform configuration guard. +- No hacker capability: no navigation, page access, or API access. + +The API redacts blacklist fields to null for non-officers, rejects blacklist +filters (including false), and replaces blacklist skip reasons/errors with a +request for officer review. Existing transition safeguards still apply. Deleting +a blacklisted application requires an officer, checked under the attendee lock. +Officer responses retain their existing shape and values. + +Blade must gate its admin layout, navigation, and page consistently. It passes +read/edit/officer capabilities to the roster and detail controls. Read-only +users cannot select rows or see mutations. The event-attendance read uses hacker +read access; general event administration and check-in keep their existing gates. + +Validation covers the actual routers, role unions/revocation, read-only UI, and +blacklist redaction/filtering. Rollout is a normal application deployment; +rollback is reverting the code. No persisted data, permission indices, email +delivery mechanics, or dependencies change. ## Architecture / data flow diff --git a/.forge/features/hacker-management/status.md b/.forge/features/hacker-management/status.md index c86afd9f2..dc9031ccd 100644 --- a/.forge/features/hacker-management/status.md +++ b/.forge/features/hacker-management/status.md @@ -3,7 +3,43 @@ Phase: **implemented** — whole bundle approved 2026-08-03 and built. Awaiting `forge-review` and the owner's UI pass. -## Context +## Delegated hacker permissions — 2026-09-06 + +Phase: PR open; awaiting CI and review. Branch: `codex/blade-hacker-permissions`. + +- Issue: [#538](https://github.com/KnightHacks/forge/issues/538). +- PR: [#539](https://github.com/KnightHacks/forge/pull/539). +- Rebased onto `main` before publishing to exclude the unrelated navigation + changes in PR #537. The permission fix applied without conflicts; validation + passed again on this standalone branch (194 API tests, 34 Blade tests, and + seven browser tests). + +- Owner approved making Read Hackers functional without officer status, with + Edit Hackers controlling writes and blacklist/configuration remaining officer-only. +- Scope: Blade hacker navigation/page/controls, hacker API guards and responses, + and the hacker detail's event-attendance read. No schema or dependency changes. +- Regression proof: the new API matrix failed 31 cases before implementation. + The completed API checks pass 194 tests across hacker access, real database + guards, hackathon configuration access, event access, and role permissions. +- Blade: 34 targeted tests pass. All seven hacker-management browser tests pass, + including readers at 1440px and 320px, the legacy link, filtering/search, + details and attendance, editor controls, unauthorized redirects, and existing + officer flows. Roster/detail screenshots were inspected at both widths. +- Database and browser checks used disposable local databases, dropped afterward. + Status-mail tests enqueue only in the disposable database; no delivery worker runs. +- `pnpm format`, `pnpm lint`, `pnpm typecheck`, and + `pnpm analyze:react:changed` pass. Lint retains the repository's existing + warnings. The root typecheck covers shared API consumers. +- No schema, permission-bit layout, dependency, or persistent environment changes. + `pnpm build` was attempted before publishing and failed while collecting page + data: Guild lacks `JUDGING_ACCESS_SECRET` and `NEXT_PUBLIC_BLADE_URL`; the 2026 + app lacks `KHIX_HACKER_PORTAL_CLIENT_ID` and `KHIX_HACKER_PORTAL_ORIGIN`. + `pnpm --filter=@forge/blade build` also failed collecting `/judge/end` due to + missing `JUDGING_ACCESS_SECRET` and `NEXT_PUBLIC_BLADE_URL`. + No environment values were changed to bypass this. Deployment is not performed. +- Screenshot files are excluded from the repository at the owner's request. + +## Original context The slice after hackathon configuration. That slice made per-status mail officer-editable; nothing sends it, and `isConfigured` is computed and read by diff --git a/.forge/features/hacker-management/test-cases.md b/.forge/features/hacker-management/test-cases.md index 6518cdb68..9be30bf95 100644 --- a/.forge/features/hacker-management/test-cases.md +++ b/.forge/features/hacker-management/test-cases.md @@ -273,13 +273,27 @@ Expected: the total is unchanged by all of them. ### TC-NEG-001: Access (AC — SRD access policy) -Setup: unauthenticated; a logged-in non-officer; an officer. +Setup: unauthenticated; no hacker permissions; Read Hackers only; Edit Hackers +only; both hacker permissions; an officer. Action: call every procedure in the router. -Expected: the first two are refused for every procedure; the officer succeeds. -Asserted against the router's actual procedure list so a procedure added later -without a guard fails this test rather than slipping through. +Expected: the first two are refused for every procedure. Readers pass every read +and cannot mutate, including bulk preview. Editors pass reads and writes except +blacklist management. Officers pass all guards. Assert against the router's actual +procedure list so new procedures need an access decision. Permissions granted +through separate roles combine; revoking them takes effect on the next request. + +Blade: a reader can reach `/admin/hackers` and the legacy per-hackathon link, +switch hackathons, search/filter, and open details including event attendance. +There are no selection, status, edit, points adjustment, delete, or blacklist +controls. Editors see ordinary write controls; only officers see configuration +and blacklist controls. A signed-in user without hacker access is redirected. + +For readers and editors, roster/detail responses redact blacklist values to null. +Blacklist filters, including `false`, fail before roster/count/selection reads. +Bulk responses never name the blacklist to editors. Status changes still reject +ineligible applicants, and an editor cannot delete a blacklisted application. ### TC-NEG-002: A blacklisted applicant cannot be accepted (AC-014) diff --git a/apps/blade/src/app/_components/admin/hackathon/hackers/bulk-confirm-dialog.tsx b/apps/blade/src/app/_components/admin/hackathon/hackers/bulk-confirm-dialog.tsx index 135a90964..18505acb1 100644 --- a/apps/blade/src/app/_components/admin/hackathon/hackers/bulk-confirm-dialog.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/hackers/bulk-confirm-dialog.tsx @@ -30,7 +30,8 @@ type SendingStatus = keyof typeof HACKER_STATUS_LABELS; * `undefined`, which React renders as nothing. A blank explanation on the one * list whose job is saying who was *not* mailed is worse than an ugly one. */ -export function skipLabel(reason: string) { +export function skipLabel(reason: string | null) { + if (reason === null) return "Requires officer review"; // `hasOwn`, not `in`: `in` walks the prototype, so `"toString"` would pass the // guard and return a function, which React refuses to render — an error // boundary in place of the fallback this helper exists to provide. diff --git a/apps/blade/src/app/_components/admin/hackathon/hackers/hacker-detail-dialog.tsx b/apps/blade/src/app/_components/admin/hackathon/hackers/hacker-detail-dialog.tsx index 51f66cf30..ac703d738 100644 --- a/apps/blade/src/app/_components/admin/hackathon/hackers/hacker-detail-dialog.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/hackers/hacker-detail-dialog.tsx @@ -144,6 +144,8 @@ function HackerDetailSkeleton() { * explain themselves. */ export function HackerDetailDialog({ + canEdit, + isOfficer, attendeeId, blocked, blockedReason, @@ -151,6 +153,8 @@ export function HackerDetailDialog({ onOpenChange, onSaved, }: { + canEdit: boolean; + isOfficer: boolean; attendeeId: string | null; blocked: boolean; blockedReason: string | null; @@ -301,16 +305,17 @@ export function HackerDetailDialog({ - {/* A real control, not a ghost link buried in the subtitle. */} - + {canEdit ? ( + + ) : null} {/* The summary row the member panel leads with: the numbers an @@ -405,7 +410,7 @@ export function HackerDetailDialog({ school, major and the MLH consent answers are the applicant's own answers, not an officer's to rewrite. */} - {editing ? ( + {canEdit && editing ? (
- A change, not a new total, so two officers awarding at - once add up. -
-+ A change, not a new total, so two officers awarding + at once add up. +
+- Their Blade account and reusable profile remain. Status, - points, agreements, and check-ins for this hackathon are - removed permanently. -
-+ Their Blade account and reusable profile remain. Status, + points, agreements, and check-ins for this hackathon are + removed permanently. +
+- Create a hackathon before managing applicants.{" "} - - Hackathons - + {isOfficer + ? "Create a hackathon before managing applicants." + : "Ask an officer to create a hackathon before managing applicants."}{" "} + {isOfficer ? ( + + Hackathons + + ) : null}
); @@ -267,7 +275,11 @@ export function HackerRoster({ return (