/me shows the signed-in member their own certifications (status badges
from the shared logic) and a read-only view of their effective
permissions. Sidebar gets a "My access" link. ARCHITECTURE documents the
module; HANDOFF pass-21 records the UX fixes (deployed) and the
code-complete-but-local certifications module.
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.
/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).
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.
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.
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.
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.
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.
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).
- 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).
- 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.
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.
- 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.
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.
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.
- 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.
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.
- 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.
- 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.
- 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)
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
- 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.
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.
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.
- 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
- 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
Records the UX polish tranche and the incident-RLS diagnosis (incl. the
discriminating production SQL) and adds migration 025 to the schema
migration table.
- 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)
- 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
- 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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.