Commit Graph
343 Commits
Author SHA1 Message Date
Moheeb Zara af503c3869 feat(certifications): per-member award/revoke panel on Members
A "Certs" column appears for anyone holding certifications.grant (the
Instructor capability), independent of admin/board, so a non-admin
instructor can reach it. The button opens a self-contained dialog that
loads the member's grants and the active cert catalog, and supports
award (with optional note + expiry override), soft revoke, and renew.
Status badges come from the shared certifications-logic. The members
server page resolves certifications.grant via the RPC and passes it in.
2026-05-16 18:59:44 -07:00
Moheeb Zara 446177bf86 feat(certifications): admin cert-type pages + sidebar nav
/certifications (guarded by certifications.manage) lists cert types with
inline create/edit, archive/restore (is_active), and delete (refused
server-side once granted). Sidebar gets a Certifications link in the
Admin block next to Forms & waivers. Awarding to members is a separate
permission handled from the Members page (next phase).
2026-05-16 18:57:02 -07:00
Moheeb Zara f23abb0050 feat(certifications): server actions + page guard
certifications.ts: create/update/delete/list cert types (gated by
certifications.manage; delete refused once granted, archive instead),
and grant/revoke/renew/listMemberCertifications/getMyCertifications
(gated by certifications.grant, the Instructor capability). Grant
verifies cert and target member are in-space and the cert is active,
computes expiry from validity_months unless overridden, and relies on
the partial unique index for one active grant per member+cert. Revoke
is soft; nothing is ever deleted. getMyCertifications takes no params
(member resolved from session; RLS restricts to own rows).

certifications-guard.ts mirrors the forms guard for the admin pages.
Barrel re-export and API_REFERENCE updated.
2026-05-16 18:55:17 -07:00
Moheeb Zara ddf6717d1b feat(certifications): pure expiry/status logic + Zod schemas
certifications-logic.ts: computeExpiry (month math with end-of-month
clamping and year rollover), isCertificationActive, certificationStatus
(revoked > expired > expiring_soon > active). Dependency-free and
unit-tested (16 cases incl. Jan-31 and leap-year boundaries).

validations.ts: create/update/grant/revoke/renew + id/list schemas.
grant's expires_at is an optional override (empty -> null); validity is
a positive integer count of months.
2026-05-16 18:52:53 -07:00
Moheeb Zara e439b1130f feat(certifications): schema + RLS + permissions (data layer)
Migration 030 adds certifications (per-space cert types, optional
validity_months, is_active archive) and member_certifications (per-member
grants; expires_at snapshotted at grant time; soft revoke; partial unique
on one active grant per member+cert). RLS is additive and default-deny:
cert types readable by any space member, write-gated by
certifications.manage; grants readable by managers/granters (all) or the
member (own), written by certifications.grant, with no DELETE policy so
the history is immutable. No anonymous path.

Two new permission codes in the catalog (group Certifications):
certifications.manage and certifications.grant (the Instructor
capability, assignable to any role via the existing additive model).
Seeded to board and backfilled. Mirrored idempotently in schema.sql;
types and schema docs updated.
2026-05-16 18:51:33 -07:00
Moheeb Zara 04f1a31999 fix(members): surface invite-link UI on the members page
The first-run "Invite or add members" step links to /members, but
invite creation only lived in Customize, so the page dead-ended. Reuse
the existing InvitesPanel: the members server page loads invites + space
slug for admin/board and composes the panel into MembersClient via an
inviteSlot prop (MembersClient stays unaware of invites). First-run copy
updated to match.
2026-05-16 18:26:42 -07:00
Moheeb Zara a762fae1f1 feat(auth): gate OAuth buttons on login behind config flags
GitHub/Google buttons on the login page pointed at providers that may
not be configured in Supabase, dead-ending in an error. Add
NEXT_PUBLIC_OAUTH_GITHUB / NEXT_PUBLIC_OAUTH_GOOGLE (opt-in, "true" to
show); a provider button renders only when enabled and the whole social
block is hidden when neither is. Pure resolveOAuthProviders helper is
unit-tested; flags documented in .env.example and DEPLOYMENT.md.
2026-05-16 18:26:36 -07:00
Moheeb Zara ad477252c8 feat(incidents): anonymous report tracking by token + public /track
Gap: fileIncident minted a reporter_token and the filing UI showed it
("the only way to look it up later") but there was no lookup UI.

- lib/incident-logic.ts (pure, tested): publicIncidentView redacts —
  board_only updates, subjects, decision-maker ids dropped; disposition
  only surfaced once decided/closed; status label mapping
- trackIncident: public service-client action (token is a 192-bit
  bearer credential on a UNIQUE column; anon has no RLS path so the
  service client is used AFTER token validation; generic not-found is
  enumeration-safe; board_only filtered at the query AND in logic)
- public /track page + client (also accepts ?token= deep link);
  /track added to middleware PUBLIC_ROUTES
- filing UI now deep-links /track?token=<code>
- trackIncidentSchema; __tests__/incident-tracking.test.ts (6)
- docs: API_REFERENCE + HANDOFF pass-20 (session arc, web-verified
  integration facts, locked forward roadmap, open items incl. the
  still-open production incident-INSERT RLS bug)

343 tests; build verified (Compiled successfully, exit 0).
2026-05-16 18:13:09 -07:00
Moheeb Zara 2559166092 feat(forms): visible public link + one-click Copy on member and admin lists
- shared components/forms/copy-link-button.tsx (one control, no drift):
  copies the absolute /f/<space>/<slug> URL, check-mark + toast feedback
- admin /forms list: Copy link button on every published public form
- member /my-forms list: shows the /f/<space>/<slug> path and a Copy
  button for public forms; row restructured so the button is not nested
  in the row link
- members-only forms show no public link (correct: they have none)

337 tests; build verified. Local commit (deploy on your word).
2026-05-16 18:05:23 -07:00
Moheeb Zara 73eef7293a feat(forms): obvious publish flow, member Forms surface, templates (P8)
- builder: draft/published/closed status banners; creating a form now
  lands on its edit page so Publish is immediately visible; a
  members-only form shows a "no public link" note instead of a dead URL
- member-facing forms: /my-forms list + /my-forms/[id] fill
  (MemberFillClient), "Forms" sidebar item for all members. Fixes the
  long-standing gap where members-only forms had no fill surface
- starter templates: lib/form-templates.ts + a picker on /forms/new
  (Liability waiver, Membership application, Event RSVP, Photo release,
  Blank) that seeds builder state (no table)
- forms-client relabels members-only forms (was showing a misleading
  /f/<space>/<slug> that always 404s for members-only)
- docs corrected per the audit pass: DATABASE_SCHEMA / DB_SCHEMA_MAP /
  ARCHITECTURE / API_REFERENCE now reflect migrations 014-029, per-space
  slug, /f/[space]/[slug], getPublicForm({space,slug}), invite roles;
  HANDOFF pass-19 added with residual gaps

337 tests; build verified (Compiled successfully, exit 0). No deploy.
2026-05-16 17:41:16 -07:00
Moheeb Zara df6951c323 feat(invites): role-granting + usage-capped invites; audit hardening (P7)
Invites
- migration 029: space_invites.role (member_role enum, default
  'member' — legacy invites and the spaces.invite_code path keep
  granting member). schema.sql + types/database.ts mirrored
- lib/invite-logic.ts (pure, tested): canAssignInviteRole — admin may
  grant any role; board may grant anything except admin; nobody else.
  Privilege-escalation guard on top of the existing admin/board gate
- createInvite/updateInvite take + enforce role via that guard;
  createInviteSchema/updateInviteSchema validate the enum
- joinSpace applies the invite's granted role (validated), default
  member; uses_count/max_uses/expiry already enforced
- invites panel: role picker (filtered to what the creator may grant),
  one-click "single use", per-invite role badge
- space-scoped invite link /join/<space>?code=… via a presentational
  app/join/[space] landing that hands off to the proven
  /signup?invite= flow; /join added to middleware PUBLIC_ROUTES

Audit hardening (from this pass's review)
- /login now honors a validated same-origin ?next= (was hardcoded to
  /dashboard) so the public_auth form sign-in round-trips; OAuth
  threads next through the callback (which already honored it)
- submitForm requires formId; submitFormSchema drops the now-ambiguous
  slug path (form slugs are per-space after 028). Both callers already
  pass formId. Tests updated

337 tests; build verified (Compiled successfully, exit 0). No deploy.
2026-05-16 17:34:45 -07:00
Moheeb Zara 8bbe99d34f feat(forms): space-scoped public URL /f/[space]/[slug] (P6)
- migration 028: drop global UNIQUE(forms.slug), add
  UNIQUE(space_id, slug); schema.sql mirrored. Form slugs are now
  unique per space (fixes cross-tenant slug squatting)
- getPublicForm now takes { space, slug }: resolves the space by its
  slug then the form by space_id + slug (maybeSingle)
- route moved app/f/[slug] -> app/f/[space]/[slug]; the public client
  submits by formId (slug is no longer globally unambiguous) and the
  sign-in link round-trips the full /f/space/slug path
- forms-guard returns the space slug; the builder + forms list show
  the full /f/<space>/<slug> share URL; new/edit pass it through
- middleware /f allowlist already covers /f/<space>/<slug>
- getPublicFormSchema + tests updated (333 pass)

build verified (Compiled successfully, exit 0; new route present). No deploy.
2026-05-16 17:21:39 -07:00
Moheeb Zara 5a49ecb2cf fix(forms): allow anonymous access to the public /f/[slug] page
The middleware PUBLIC_ROUTES allowlist did not include the public
form/waiver fill route, so unauthenticated visitors hitting
/f/<slug> were redirected to /login — making the entire public/
anonymous forms feature unreachable in production. Add '/f' to the
allowlist; only /f/[slug] lives under it (the /forms* management
routes do not match the '/f/' prefix and stay auth-gated). The page
already serves only published public forms and submitForm enforces
visibility, so this exposes nothing new.
2026-05-16 17:06:00 -07:00
Moheeb Zara 61403ad29f docs: HANDOFF — test-coverage hardening (forms-logic extraction, 333 tests) 2026-05-16 16:40:32 -07:00
Moheeb Zara 5c39317877 test(forms): extract pure decision logic and add exhaustive coverage
The forms security/correctness logic was buried in DB-coupled server
actions and the builder component, so it had no real tests (the suite
is pure-logic only; nothing exercises Supabase). Extract the pure
parts behaviour-unchanged into lib/forms-logic.ts and test them for
real:

- csvCell (results-export escaping), parseClientIp (e-sign IP capture
  filtering), shouldBumpFormVersion (waiver non-blocking re-sign),
  evaluateRequiredFormStep (onboarding required/auto-satisfy/fail-open),
  slugify + deriveFieldKeys (builder key derivation + dedupe)
- forms.ts / onboarding.ts / form-builder.tsx now consume these; the
  local copies are removed (no behaviour change — verified by the full
  suite staying green)
- __tests__/forms.test.ts expanded 16 -> 62: every validateAnswers
  type/branch (type coercion, length caps, required, unknown-key
  stripping, first-error ordering), parseFormSchema, every new Zod
  schema incl. formSlug boundaries, and all extracted helpers

333/333 tests; build verified (Compiled successfully, exit 0). No deploy.
2026-05-16 16:40:09 -07:00
Moheeb Zara 40d4cdad98 feat(forms): verified-email retro-link of anonymous submissions (Phase 5)
- claimMyAnonymousSubmissions(): exported but safe — no trusted params,
  resolves member + email from the caller's session, gated on
  email_confirmed_at, idempotent. Links the caller's own prior
  anonymous submissions to their member row
- hooked best-effort from joinSpace (member insert now returns id;
  links prior anon submissions for the verified email) and
  finishOnboarding
- addMember/importMembers deliberately NOT auto-linked: those emails
  are admin-asserted, not verified; the locked decision forbids
  auto-link there, so the forms.manage linkSubmissionsForMember stays
  the admin manual-link path
- also repairs a build break committed in 295d220: finishOnboarding
  declared `const admin` twice (Phase 4 added a second). The probe and
  the completion update now share one admin client
- docs: API_REFERENCE / ARCHITECTURE / HANDOFF

287/287 tests, build compiles clean (verified Compiled successfully). No deploy.
2026-05-16 16:29:32 -07:00
Moheeb Zara da25950988 harden(forms): prevent duplicate onboarding form submission on Back/Forward
Phase 4 self-audit: alreadySubmitted was a page-load snapshot, so a
member who submitted a form/waiver step then navigated Back and
Forward would insert a second submission. Track submitted steps in
session state and treat them as already-submitted.
2026-05-16 16:29:32 -07:00
Moheeb Zara 295d220849 feat(forms): onboarding form/waiver step with required enforcement (Phase 4)
- scripts/027 (idempotent CHECK replace) + schema.sql Section 14: allow
  space_onboarding_steps.step_type = 'form'; referenced form id lives in
  the step's existing config.form_id (no new column)
- finishOnboarding: a required form step is satisfied when a submission
  for config.form_id by the member exists (any version — re-sign is
  non-blocking / auto-satisfy); fails open if the form is missing or
  unpublished so a misconfig cannot trap a member. Probe uses the
  service client (submissions are forms.manage-only under RLS)
- onboarding page enriches form steps with the published form def +
  prior-submission flag; onboarding-flow renders FormRenderer + waiver
  legal text/consent and submits via submitForm then marks the step done
- customize: forms fetched and threaded to the onboarding panel; new
  "+ Form step" button + form picker writing config.form_id
- validations/types/casts include 'form'; forms.test.ts +2
- docs: DATABASE_SCHEMA/DB_SCHEMA_MAP/API_REFERENCE/ARCHITECTURE/HANDOFF

287/287 tests, build exit 0. No deploy.
2026-05-16 15:15:17 -07:00
Moheeb Zara 01b924b2f5 harden(forms): use const for derived field-key base (Phase 3 audit) 2026-05-16 15:15:17 -07:00
Moheeb Zara 03bb3a7466 feat(forms): builder UI, management pages, and public fill page (Phase 3)
- components/forms/form-renderer.tsx: one controlled renderer for all 8
  field types, shared by the builder preview and the public page
- components/forms/form-builder.tsx: easy builder — label-only field
  creation (keys auto-derived and de-duped at save), one-click type
  buttons, up/down reordering, inline options editor, live preview;
  edit mode adds publish/close/draft/delete and copy-public-link
- /forms directory, /forms/new, /forms/[id]/edit, /forms/[id]/results
  (CSV download). Server-gated by lib/forms-guard.ts (forms.manage via
  the same RPC the RLS uses; RLS remains the real boundary)
- public app/f/[slug] (outside the app group): getPublicForm serves
  only published public forms — members-only forms are never exposed
  there; waiver consent, anon email capture, signed-in gate for
  public_auth, thank-you state
- sidebar "Forms & waivers" link; sonner toasts (codebase convention)
- ARCHITECTURE + HANDOFF updated

285/285 tests, build clean, all four routes compiled. No deploy.
2026-05-16 15:03:48 -07:00
Moheeb Zara 897fa30ec1 feat(forms): server actions, Zod schemas, and dynamic answer validation (Phase 2)
- lib/validations.ts: form field/array schemas (8 field types,
  duplicate-key and select-options refinements) and create/update/
  status/submit/link envelope schemas; slug immutable on update
- lib/forms-schema.ts: pure parseFormSchema + validateAnswers; unknown
  answer keys are discarded so a caller cannot inject arbitrary jsonb
- lib/actions/forms.ts: create/update/setStatus/delete/list/results/
  CSV export (forms.manage via user_has_permission + RLS), submitForm
  (service-client read+write, visibility + waiver consent enforced,
  schema/legal/version snapshot, IP/UA capture), and
  linkSubmissionsForMember (manual admin link; auto path is Phase 5)
- updateForm bumps a published waiver's version on legal/schema change
  (non-blocking; existing submissions stay valid against their snapshot)
- deleteForm refuses when submissions exist (immutable records)
- types/database.ts: type user_has_permission/user_effective_roles
  (stale since migration 023)
- __tests__/forms.test.ts (14); API_REFERENCE + HANDOFF updated

285/285 tests, build clean. No deploy.
2026-05-16 14:51:20 -07:00
Moheeb Zara 66dd9b7ec0 harden(forms): self-audit fixes for Phase 1 RLS and indexes
- drop redundant idx_forms_slug (UNIQUE constraint already indexes slug)
- tighten forms_select: forms.manage sees all; ordinary members see
  only published forms (drafts/closed no longer exposed via PostgREST)
- make submitter_email and member_id submission indexes partial
  (both columns are frequently NULL)
2026-05-16 14:42:51 -07:00
Moheeb Zara 3a2d3ca13f feat(forms): schema + RLS for custom forms and waivers (Phase 1)
Adds the forms and form_submissions tables behind a custom form/waiver
builder. Phase 1 is schema + RLS only; server actions, builder UI, the
public /f/[slug] page, onboarding integration, and retro-linking follow
in later phases.

- scripts/026_forms.sql (idempotent), mirrored as schema.sql Section 17
- forms: globally-unique slug, kind (form/waiver), visibility
  (public_anon/public_auth/members), status, jsonb field schema,
  legal_text, version
- form_submissions: per-row snapshot of schema/legal-text/version for
  immutable waiver records, plus ip/user_agent for e-sign defensibility
- RLS additive + default-deny: forms write gated by forms.manage via
  user_has_permission; form_submissions has no write policy, so it is
  service-client-only and immutable; anon gets no grant on forms
- new forms.manage permission seeded to board + backfilled
- types/database.ts and DATABASE_SCHEMA/DB_SCHEMA_MAP/ARCHITECTURE/HANDOFF
  updated
2026-05-16 14:38:28 -07:00
Moheeb Zara 3ace1afa17 docs: polish Tiers 1-3 complete; Tier 2+3 local pending deploy 2026-05-16 14:14:04 -07:00
Moheeb Zara b7461f07fd feat(ux): Tier 3 security-adjacent UX (presentational only)
- secrets: revealed value gets a copy-to-clipboard with feedback,
  auto re-hides after 30s, re-hides on window blur, and the timer is
  cleared on unmount (no change to revealSecret or RLS)
- permissions matrix: a view-only notice when the viewer is not admin,
  and a per-checkbox aria-label so the grid is usable non-visually
- OpsAclEditor: pills are now aria-pressed toggles with a visible
  check when selected and a success toast on save

No server action, RLS, or permission logic changed.
2026-05-16 14:13:11 -07:00
Moheeb Zara a1ea92a71c feat(ux): founder first-run dashboard + onboarding skills/ack fixes
Tier 2. Admin/board alone in an empty space now gets a guided
getting-started checklist (invite members, set up onboarding, first
task, connect payments) instead of a grid of zeros; reverts to the
normal dashboard once there is any activity. Onboarding: Skills is now
a controlled field (edits survive Back/forward, no normalize surprise)
and a hint appears when a required acknowledgement is unchecked.
2026-05-16 14:10:29 -07:00
Moheeb Zara ca6caac005 docs: HANDOFF — Tier 1 complete (S3 local), forms decisions recorded 2026-05-16 14:03:57 -07:00
Moheeb Zara 108c2a54cb feat(ux): convert all hand-rolled modals to accessible Radix Dialog
S3 (final Tier 1 item). ops (4 modals), members/tasks/projects/
payments/contacts add-edit modals, settings integration-credentials
modal, and the payments link-member modal now use components/ui/dialog
(focus trap, ESC, aria, scroll lock for free). Forms, fields, handlers,
validation and close/reset behavior preserved verbatim; onOpenChange
mirrors the old Cancel/X reset. Removed now-unused X imports; replaced
a stray em-dash with a middot in the link-member subtitle.
2026-05-16 14:02:32 -07:00
Moheeb Zara 97cb4d756a docs: HANDOFF reflects UX Tier 1 (minus S3) being deployed this push 2026-05-16 00:21:19 -07:00
Moheeb Zara b8bb213bf5 docs: HANDOFF/CHANGELOG reflect S5-S8 done, S3 + Tier 2/3 remaining 2026-05-16 00:01:11 -07:00
Moheeb Zara 075b274204 fix(ux): focus visibility, tap targets, form labels, surfaced errors
- S6: unlayered :focus-visible ring for form controls and links so
  keyboard focus is perceivable (was focus:outline-none + 1px border)
- S5: >=44px touch targets at the named hotspots (dashboard actions,
  list-row buttons, ops pills/icons, customize number inputs); dense
  rows use a 44px hit area without growing row height
- S8: 28 form fields associated via id/htmlFor; search inputs get
  aria-label
- S7: claim/complete/delete/link handlers now surface result.error via
  toast and stop instead of failing silently
2026-05-16 00:00:23 -07:00
Moheeb Zara ea49e2129d docs: HANDOFF pass-17 reflects deployed incident hotfix + Tier 1 progress 2026-05-15 22:19:07 -07:00
Moheeb Zara 1db9434930 feat(ux): accessible confirm dialog replaces window.confirm
- new components/ui/confirm.tsx: ConfirmProvider + useConfirm() built
  on the existing Radix alert-dialog (focus trap, ESC, aria for free)
- wired once into the app layout
- all 13 native window.confirm() delete/remove guards now use the
  styled, accessible confirm with a destructive action button
2026-05-15 22:18:15 -07:00
Moheeb Zara 64d7d626db docs: pass-17 CHANGELOG / HANDOFF / schema migration table
Records the UX polish tranche and the incident-RLS diagnosis (incl. the
discriminating production SQL) and adds migration 025 to the schema
migration table.
2026-05-15 22:13:03 -07:00
Moheeb Zara f885ac73d3 feat(ux): adopt shared Empty primitive across all empty states
- one Empty/EmptyHeader/EmptyMedia/EmptyTitle/EmptyDescription
  component replaces ~18 ad-hoc empty states
- projects gains a real empty state (was four bare columns) with a
  create CTA wired to the existing handler
- governance/ops/customize/comms/comments empty states unified;
  existing CTAs and conditions preserved (presentational only)
2026-05-15 22:12:52 -07:00
Moheeb Zara 92ba6fb390 feat(ux): shared PageTitle/SectionTitle primitive, adopt app-wide
- new components/ui/page-title.tsx (PageHeader/PageTitle/SectionTitle)
- every page heading routed through PageTitle (one source of truth
  for the page-title style; identical render)
- members restricted-directory screen now uses the standard
  PageHeader bar instead of an off-style mono heading
2026-05-15 22:12:52 -07:00
Moheeb Zara 3360f54232 fix(ux): accessible sidebar/drawer, distinct Ops icon, skip link
- Ops nav now uses Wrench (was Settings2, collided with Settings)
- aria-current on active nav link, aria-label on primary nav
- mobile drawer: role=dialog/aria-modal, Escape to close, body
  scroll lock, focus moves into drawer and back to the menu button
- larger touch targets on the menu open/close buttons
- sidebar logo uses the shared BrandMark instead of an ad-hoc {hs}
- skip-to-content link added to the app layout
2026-05-15 22:12:52 -07:00
Moheeb Zara fa71d9a63e docs: HANDOFF session-close state-of-the-app summary 2026-05-15 22:12:52 -07:00
Moheeb Zara 1340b4cd79 fix(incidents): re-assert hardened incidents_insert policy + clearer error
Production: a basic member filing an incident (incl. anonymous) hit an
RLS violation on table incidents. Not reproducible from source; the code
and the 016/017 policy both permit it, so it is a deployed policy/data
divergence.

- scripts/025 idempotently re-asserts incidents_insert verbatim from
  017/schema.sql so a drifted deployment converges to the known-good
  policy. Access-neutral, no schema change.
- fileIncident maps an RLS rejection to an actionable message and logs
  the technical detail server-side instead of raw Postgres.

Minimal hotfix off origin/main: only the two production-affecting files.
If the report persists post-deploy the cause is not policy drift; the
discriminating SQL is tracked in the local stack's HANDOFF.
2026-05-15 22:11:33 -07:00
Moheeb Zara 4c9b55147b chore: clear audit backlog (types sync, email norm, docs, members shortcut)
Finishes all remaining tracked work from the full-app audit.

- types/database.ts synced: added the 8 missing tables (forum_threads,
  comments, space_tiers, space_invites, space_onboarding_steps,
  space_role_labels, space_custom_roles, space_member_custom_roles),
  the comment_entity_type enum (type + Constants), and the missing
  columns (space_members.tier_id/onboarding_completed_at/
  onboarding_progress, secrets.encrypted_value/encryption_version,
  knowledge_base.render_markdown).
- Email canonicalization: new emailField() helper (trim+lowercase)
  applied to signIn/signUp/addMember/updateMember/createContact and
  the import path; prevents case-variant duplicates and sign-in
  mismatches.
- Members directory: per-row "make area lead" picker (admin/board)
  assigns a member to any area-lead role; mirrors Customize.
- Docs refreshed and re-dated: DATABASE_SCHEMA.md (migrations 014-024
  summary), DB_SCHEMA_MAP.md (new-table quick map), API_REFERENCE.md
  (all server actions from migrations 016-024). schema.sql named as
  source of truth.

Backlog is now empty. 271/271 tests pass, build clean. No migration.
2026-05-15 19:01:52 -07:00
Moheeb Zara 86d44f6efc feat: import row validation + KB/process per-item ACLs
Continuing the audit backlog and finishing the permissions UI.

Import validation (recurring audit finding):
- Added importMembersSchema / importPaymentsCsvSchema. importMembers
  and importPaymentsCsv now validate per row (a single bad row no
  longer rejects the whole file), lowercase emails, normalize dates
  through flexibleDateTime(), enforce tier/platform enums and
  positive finite amounts, and return a `skipped` count instead of
  silently dropping invalid rows.

Permissions UI completion:
- OpsAclEditor is now wired into the KB/process viewer modal
  (admin/board), with entity type derived from the `process` tag.
  Per-item Ops access control now covers secrets + KB + processes,
  matching "all ops facilities stuff".

Left as-is with rationale in HANDOFF: /ops + /import denial paths
(unreachable behind the layout gate or already a clear non-leaking
denial); members-row make-area-lead shortcut (Customize -> Area
leads fully covers assignment); types/database.ts + docs regen
(pure hygiene, zero runtime/security impact, still tracked).

271/271 tests pass. Build clean. No migration this pass.
2026-05-15 18:33:53 -07:00
Moheeb Zara f5d670477b fix(security): close member self-escalation + harden after full audit
Full-application audit (5 parallel read-only audits). Two agent
"blockers" were false alarms and verified as already-correct. Real
findings fixed:

- Self-escalation (BLOCKER): migration 024 + schema.sql section 16
  extend prevent_member_self_role_change to also block a
  non-privileged member changing their own tier_id (self tier
  upgrade) and onboarding_completed_at (skipping required
  code-of-conduct via a raw PostgREST call). finishOnboarding and
  skipOnboarding now set onboarding_completed_at through the service
  client AFTER their server-side required-steps check, so the
  legitimate path is unaffected.
- Cross-tenant write scoping: added .eq('space_id', member.space_id)
  to updateForumThread, deleteForumThread, editComment, deleteComment,
  deleteChannel, renameChannel (RLS-only -> defence-in-depth).
- generateInviteCode: crypto.getRandomValues, widened to 8 chars
  (was Math.random, ~21 bits, enumerable under the join rate limit).
- SafeMarkdown: dropped iframe and the global style attribute
  (no unsandboxed embeds / UI-redress); markdown + safe HTML stay.
- /settings: server-side admin redirect before fetching space and
  integration config (was client-hidden only).
- app/(app)/layout.tsx: export const dynamic = 'force-dynamic'.
- Bounded queries: loadComments .limit(500), tasks .limit(2000).

Backlog from the audit is tracked in HANDOFF (import Zod schemas,
types/database.ts + docs regen, email lowercasing, unauthorized-
handling consistency, KB-modal ACL hookup).

271/271 tests pass. Build clean. Migrations 023 + 024 idempotent,
apply via deploy.sh on next deploy.
2026-05-15 17:35:11 -07:00
Moheeb Zara 9447b5ee80 feat: customizable permissions, per-item Ops ACLs, area-lead roles
Migration 023 (folded into schema.sql section 15). ADDITIVE and
backward compatible: with no permission/ACL rows the app behaves
exactly as before.

Schema:
- space_role_permissions (space_id, subject, permission); ops_acl
  (space_id, entity_type secret|kb|process|area_lead, entity_id,
  role). Full RLS, writes admin/board.
- user_effective_roles(uid,sid): built-in role + custom-role slugs +
  area_lead:<id> for areas the member leads. SECURITY DEFINER.
- user_has_permission(uid,sid,perm): admin implicit-all else a grant
  via an effective role. SECURITY DEFINER.
- secrets/knowledge_base SELECT rewritten as
  `exact existing rule OR ops_acl match` (only widens; zero change
  with no ACL rows). Seed + backfill default role permissions.

Area-lead roles reuse the existing area_leads table rather than
duplicating the concept: a row is a role, lead_id NULL = Vacant,
assigning sets lead_id, the member then holds area_lead:<id>.

App:
- lib/permissions-catalog.ts (codes + per-role defaults + guardrails),
  Zod schemas, lib/actions/permissions.ts (setRolePermissions,
  setOpsAcl, createAreaLeadRole, assign/unassign/delete).
- Customize gains Permissions (role x permission matrix, admin shown
  implicit-all/locked) and Area leads (create/Vacant/assign) panels,
  modular under customize/panels/. Reusable
  components/ops/ops-acl-editor.tsx wired into the Secrets list.

Audit-found latent fixes:
- revealSecret errors on encrypted-but-no-ciphertext corruption
  instead of silently returning the stale plaintext column.
- upsertAreaLead rejects null/empty area_code (NULLs are distinct in
  unique constraints; allowed duplicate rows).

types/database.ts: added the two new tables. 271/271 tests pass,
build clean. KB-modal ACL wiring + members-row shortcut deferred and
tracked in HANDOFF.
2026-05-15 17:15:14 -07:00
Moheeb Zara 109947552f fix: landing page rendered unstyled - route bypassed (landing) layout
Root cause: app/page.tsx owned `/` and imported the landing component
directly, so `/` only got the root layout. The (landing)/layout.tsx
that supplies .landing-root, the IBM Plex Mono / Libre Baskerville
fonts, and landing.css never ran, so the redesigned page (which is
scoped under .landing-root) rendered with no styles at all. The old
landing used global Tailwind tokens so the missing layout went
unnoticed.

Fix: the canonical App Router structure. `/` now resolves to
app/(landing)/page.tsx (route groups add no path segment), wrapped by
app/(landing)/layout.tsx. The logged-in -> /dashboard redirect moved
into that page. Deleted the duplicate app/page.tsx.

Build clean, no parallel-route conflict, `/` resolves. 266/266 tests
pass.
2026-05-15 16:50:20 -07:00
Moheeb Zara b769414f6d feat: restore mini previews on the landing hero
The animated product mini-previews are back, recovered from before
the redesign into components/landing/mini-previews.tsx ('use client',
own module for separation). They render framed as product screenshots
(window chrome + bordered surface in the landing palette) in a
two-column hero, so they read as the real app sitting inside the
editorial marketing page. Hidden below 900px.

266/266 tests pass. Build clean. No migration.
2026-05-15 16:41:03 -07:00
Moheeb Zara 43f9c09d70 feat: landing redesign in resources aesthetic; fix KB search crash
Landing:
- Rebuilt app/(landing)/page.tsx from a 539-line client monolith to a
  ~150-line server component in the /resources editorial style.
- New scoped app/(landing)/landing.css mirrors the resources palette
  (#0c0c0c / lime #d4ff00 / #252525), IBM Plex Mono + Libre
  Baskerville loaded in (landing)/layout.tsx, faint grid overlay.
- Removed the animated Mini* previews and inline Ico* set (clashed
  with the editorial look). Split into components/landing/icons.tsx
  and components/landing/resource-showcase.tsx for separation.
- Resource showcase: one row of six square tiles with the real
  project SVG logos linking to atlas/space-after-dark/proposal-duel/
  zine/learner/governance. Responsive 6 -> 3 -> 2.
- BrandMark accepts an optional style prop.

Fix:
- KB search crashed the Ops page. filteredLeads referenced
  l.member_name (the column is lead_handle), so undefined.toLowerCase()
  threw on every keystroke since the filter memos recompute on shared
  search state across tabs. All Ops filter predicates are now
  null-safe via a shared has() helper; filteredSecrets no longer
  assumes a non-null title.

Docs:
- docs/PERMISSIONS_DESIGN.md: concrete plan for customizable
  permissions, per-item Ops ACLs, and area-lead roles. That work is
  security-sensitive (RLS rewrites on secrets/knowledge_base) and is
  scoped as its own next pass, not bundled here.

266/266 tests pass. Production build clean. No migration this pass.
2026-05-15 16:15:56 -07:00
Moheeb Zara 71d5e243cf feat: Customize hub + roles editor, terminal logo, invite links
IA refactor and the custom-roles editor pass 11 left as data-only.

Customize hub:
- New /customize (admin/board) with a left section rail: Roles,
  Membership tiers, Areas, Invite codes, Onboarding.
- One focused module per panel under app/(app)/customize/panels/
  plus a shared card and types file. The client shell is ~75 lines;
  each panel is self-contained (smart separation of concerns).
- Settings slimmed to Space, Integrations, Webhooks. Removed the
  Roles/Tiers/Invites/Onboarding tabs and the Areas block from
  settings-client.tsx (~700 lines) and the matching fetches from
  settings/page.tsx. Dead props/state/types removed.
- Sidebar: added a Customize NavLink (admin/board).

Roles editor:
- Rename/recolor the five built-in roles (upsertRoleLabel) and full
  CRUD on custom org roles. Adds the read path the audit flagged as
  missing: lib/role-labels.ts (getRoleLabelMap/roleDisplayName) is
  consumed in (app)/layout.tsx so the sidebar shows the renamed role.

Logo:
- Reverted the brand mark to the terminal >_ glyph
  (components/brand-mark.tsx) on the landing nav/footer, onboarding
  header, and favicon (public/logo.svg). Removed the dead local
  IcoTerminal. AtlasLogo stays only on the resources project page.

Invite links:
- InvitesPanel has Copy code and Copy link (/signup?invite=CODE).
  signup reads ?invite= on mount, prefills the code, preselects join.

Deferred and tracked in HANDOFF: types/database.ts sync, renamed-role
display on the members table, splitting the still-large
settings-client.tsx, custom-role assignment UI.

266/266 tests pass. Production build clean; /customize, /onboarding,
slimmed /settings all in the route table. No migration this pass.
2026-05-15 16:03:52 -07:00
Moheeb Zara 30bba045f8 feat: configurable member onboarding + UX/persona research
A new member who joins via invite code now walks through an
admin-defined onboarding sequence before reaching the app.

Schema (migration 022, folded into schema.sql Section 14):
- space_onboarding_steps: step_key, step_type
  (welcome|code_of_conduct|profile|payment|content), title, body,
  config jsonb, is_enabled, is_required, is_system, sort_order,
  unique per (space, step_key). Full RLS: members read, admin/board
  write, admin delete non-system.
- space_members.onboarding_completed_at + onboarding_progress jsonb.
- Trigger seeds four default steps per space (welcome, code of
  conduct [required], profile, dues). Backfill seeds existing spaces
  and marks all existing members complete so the new gate never
  traps them.

Flow:
- /onboarding (top-level route, outside the (app) group so no
  redirect loop). Progress bar, step counter, back/continue, per-type
  rendering. Profile step calls updateMyProfile; payment step is a
  non-blocking nudge with optional link and "Remind me later".
- (app)/layout.tsx redirects to /onboarding while
  onboarding_completed_at is null.
- createSpace marks the founder complete: founders configure the
  space, they do not run the member flow (matches admin-vs-end-user
  persona research).
- finishOnboarding re-checks required steps server-side so a tampered
  client cannot skip a required code-of-conduct acknowledgement.
  skipOnboarding only works when nothing is required.

Admin config:
- Settings -> Onboarding tab: reorder, edit title/body/payment link,
  toggle enabled/required, add custom content steps, delete
  non-system steps.

Safety:
- components/safe-markdown.tsx renders admin-authored markdown and a
  sanitized subset of HTML (rehype-raw + rehype-sanitize). Added
  those two deps.

Also:
- bio added to updateMyProfileSchema (members can self-set it; not
  blocked by the migration-015 trigger).
- docs/UX_AND_PERSONAS.md: 4 personas and prioritized UX audit
  findings. Onboarding was finding #1; 2-6 deferred and tracked.

266/266 unit tests pass. Production build clean, /onboarding in the
route table. Migration 022 applies idempotently on the next deploy.
2026-05-15 15:38:40 -07:00
Moheeb Zara 96a1fb9ef6 fix: stop gitignore from matching lib/secrets/ application code
The .gitignore rule `secrets/` matched any directory named secrets/
anywhere in the tree, including the new lib/secrets/ application code
that holds the AES-GCM helpers. Production build failed with
"Module not found: Can't resolve '@/lib/secrets/crypto'". Anchor the
ignore to the repo root with `/secrets/` so only the top-level
credentials directory is excluded.
2026-05-14 17:49:39 -07:00
Moheeb Zara de51000584 feat: forum, polymorphic comments, custom tiers, multi-invite, secrets encryption
Big feature batch driven by the super-audit findings and the user's
follow-up requests.

Schema migration 021 (folded into schema.sql Section 13):
- forum_threads + polymorphic comments table keyed on (entity_type,
  entity_id). Entity types: forum_thread, proposal, incident, policy.
  Trigger maintains forum_threads.comment_count and last_comment_at.
- space_tiers per-space with monthly_price_cents, billing_cadence,
  is_system, sort_order. Trigger seeds plus/basic/associate on every
  space INSERT and backfills existing spaces. space_members.tier_id FK
  added and backfilled from the legacy enum value.
- space_role_labels (rename/recolor built-in roles per space).
  space_custom_roles + space_member_custom_roles for non-privileged
  extra labels. RLS gates by admin/board.
- space_invites: multi-code invites with expires_at, max_uses,
  uses_count, is_enabled. Legacy spaces.invite_code backfilled as a
  permanent enabled invite.
- secrets.encrypted_value bytea + encryption_version. Secrets RLS
  tightened. New comms_channels INSERT policy: any member can create;
  default channels are protected from deletion.
- knowledge_base.render_markdown boolean (default true).

Secrets encryption-at-rest:
- AES-256-GCM in lib/secrets/crypto.ts. Master key from
  SECRETS_ENCRYPTION_KEY env (64 hex chars).
- New revealSecret(id) server action: only path to plaintext, writes
  secret.revealed to activity_log.
- /ops/page.tsx no longer selects value or encrypted_value on the list
  endpoint - the original screenshot bug where ciphertext was already
  in the page bundle before "Reveal" is fixed.

UI:
- /forum index, /forum/new, /forum/[id]. Pin/lock/delete actions for
  admin/board.
- CommentThread component embedded on /proposals/[id], /incidents/[id],
  /policies/[slug]. Markdown rendering via react-markdown + remark-gfm.
- /comms sidebar: inline "+ New channel" form.
- /ops KB: row click opens markdown-rendering viewer modal.
- /settings: new Tiers tab and Invites tab. Inline price edit and
  archive/delete on tiers; copy/enable/disable/delete on invites.
- AtlasLogo replaces the terminal icon on the landing nav and footer.
  public/logo.svg wired as the favicon in app/layout.tsx metadata.
- Sidebar has a Forum nav link.

Server actions added:
- lib/actions/forum.ts, comms.ts, tiers.ts, roles.ts, invites.ts.
- lib/actions/secrets.ts rewritten with updateSecret + revealSecret.

Auth flow:
- joinSpace honors codes from space_invites first (enabled, expiry,
  use cap), falls back to legacy spaces.invite_code, increments
  uses_count on success.

Env:
- SECRETS_ENCRYPTION_KEY documented in .env.example and set on the
  Droplet's /opt/hackerspace-ops/.env.production.

README: live site link added (https://hackerspace.sh).

266/266 unit tests pass. Production build clean. Migration 021 will be
applied on the next deploy via /opt/hackerspace-ops/deploy.sh's
idempotent migration runner.
2026-05-14 17:47:52 -07:00