diff --git a/app/(app)/me/page.tsx b/app/(app)/me/page.tsx new file mode 100644 index 0000000..564ac3f --- /dev/null +++ b/app/(app)/me/page.tsx @@ -0,0 +1,158 @@ +import { redirect } from 'next/navigation' +import { createClient } from '@/lib/supabase/server' +import { PageHeader, PageTitle } from '@/components/ui/page-title' +import { Badge } from '@/components/ui/badge' +import { + certificationStatus, + CERT_STATUS_LABEL, + type CertStatus, +} from '@/lib/certifications-logic' +import { PERMISSIONS, PERMISSION_CODES } from '@/lib/permissions-catalog' + +export const dynamic = 'force-dynamic' + +type Grant = { + id: string + granted_at: string + expires_at: string | null + revoked_at: string | null + revoked_reason: string | null + note: string | null + certifications: { name: string; description: string | null; validity_months: number | null } | null +} + +const STATUS_VARIANT: Record = { + active: 'default', + expiring_soon: 'outline', + expired: 'outline', + revoked: 'outline', +} + +export default async function MePage() { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const { data: member } = await supabase + .from('space_members') + .select('id, space_id, role, display_name') + .eq('user_id', user.id) + .in('status', ['current', 'unverified', 'late']) + .maybeSingle() + if (!member) redirect('/signup') + + const { data: grantsRaw } = await supabase + .from('member_certifications') + .select( + 'id, granted_at, expires_at, revoked_at, revoked_reason, note, certifications(name, description, validity_months)', + ) + .eq('space_id', member.space_id) + .eq('member_id', member.id) + .order('granted_at', { ascending: false }) + const grants = (grantsRaw ?? []) as unknown as Grant[] + + // Effective permissions (read-only view). admin implicitly holds everything. + let heldCodes: string[] = [] + if (member.role === 'admin') { + heldCodes = [...PERMISSION_CODES] + } else { + const { data: roles } = await supabase.rpc('user_effective_roles', { + uid: user.id, + sid: member.space_id, + }) + const subjects = (roles ?? []) as string[] + if (subjects.length > 0) { + const { data: perms } = await supabase + .from('space_role_permissions') + .select('permission') + .eq('space_id', member.space_id) + .in('subject', subjects) + heldCodes = Array.from(new Set((perms ?? []).map(p => p.permission as string))) + } + } + const held = PERMISSIONS.filter(p => heldCodes.includes(p.code)) + + return ( + <> + + My certifications & access + + +
+
+

+ Certifications +

+ {grants.length === 0 ? ( +

+ You have no certifications yet. An instructor can award one to you. +

+ ) : ( +
    + {grants.map(g => { + const status = certificationStatus(g) + return ( +
  • +
    +
    + + {g.certifications?.name ?? 'Certification'} + + {CERT_STATUS_LABEL[status]} +
    + {g.certifications?.description && ( +

    + {g.certifications.description} +

    + )} +

    + granted {new Date(g.granted_at).toLocaleDateString()} + {g.expires_at + ? ` · expires ${new Date(g.expires_at).toLocaleDateString()}` + : ' · no expiry'} + {g.revoked_at ? ` · revoked ${new Date(g.revoked_at).toLocaleDateString()}` : ''} +

    + {g.revoked_at && g.revoked_reason && ( +

    + Reason: {g.revoked_reason} +

    + )} +
    +
  • + ) + })} +
+ )} +
+ +
+

+ My permissions +

+ {held.length === 0 ? ( +

+ You have the standard member access for this space. No extra permissions are granted to your role. +

+ ) : ( +
    + {held.map(p => ( +
  • + {p.label} +
  • + ))} +
+ )} +

+ Permissions are set per role by a space admin. This is a read-only view. +

+
+
+ + ) +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index ac3159b..4be0031 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -14,7 +14,7 @@ import { Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2, Wrench, MessageSquare, Users, CreditCard, BookUser, Download, LogOut, Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch, - MessagesSquare, SlidersHorizontal, ClipboardList, FileText, Award, + MessagesSquare, SlidersHorizontal, ClipboardList, FileText, Award, BadgeCheck, } from 'lucide-react' interface NavLinkProps { @@ -135,6 +135,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa + {isAdmin && ( )} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 77d0e85..f036ad3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -415,10 +415,11 @@ Integration credentials stored in `integrations.config` (JSONB): | 010 | Fixed channel trigger (removed description column) | | 011 | Fixed enum values (active→current, pending→unverified) | -Migrations 012-029 are tracked authoritatively in `docs/DATABASE_SCHEMA.md` +Migrations 012-030 are tracked authoritatively in `docs/DATABASE_SCHEMA.md` (governance kernel, areas, forum/tiers/roles/invites, configurable onboarding, customizable permissions + Ops ACLs, self-change hardening, `026`-`029` custom -forms + waivers + onboarding form step + per-space form slug + invite roles). +forms + waivers + onboarding form step + per-space form slug + invite roles, +`030` certifications + Instructor capability). ### Forms & waivers (migrations 026-029; complete Phases 1-5) @@ -442,6 +443,28 @@ served by a service-client server action, so the `anon` Postgres role gets no grant on `forms`. Form management is gated by the new additive `forms.manage` permission via `user_has_permission`. +### Certifications & Instructor (migration 030) + +`certifications` (per-space cert types; optional `validity_months`; +`is_active` archive) and `member_certifications` (per-member grants) back a +certification tracker. Pure decision logic is isolated in +`lib/certifications-logic.ts` (`computeExpiry` with month/leap-year clamping; +`certificationStatus` = revoked > expired > expiring_soon > active), +unit-tested. Server actions in `lib/actions/certifications.ts`. Two new +additive permissions: `certifications.manage` (cert types, gates +`/certifications` admin pages via `lib/certifications-guard.ts`) and +`certifications.grant` (award/revoke = the Instructor capability, assignable +to any role/area-lead through the existing `space_role_permissions` model, NOT +a new built-in role). The per-member award/revoke/renew panel is reachable +from a "Certs" column on `/members` shown to any `certifications.grant` holder +(independent of admin/board). Members see their own grants + effective +permissions read-only at `/me`. RLS is additive and default-deny: cert types +readable by any space member; grants readable by managers/granters (all) or +the member (own); `member_certifications` has no DELETE policy so grant/revoke +history is immutable (revoke is a soft `revoked_at` UPDATE). Expiry is +snapshotted at grant time so later edits to the cert type never retroactively +change an existing grant. There is no anonymous path. + --- ## 13. Known Limitations diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index f9e7185..522c4db 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -4,6 +4,31 @@ Append-only. Newest entries on top. Keep each entry to one screen. --- +## 2026-05-17 (pass 21) — Two UX fixes DEPLOYED; Certifications module (1) code-complete LOCAL + +Branch `main`. Start state clean, deploys green (incident-tracking pass-20 live). + +### Deployed this pass (2 commits, pushed, Actions run 25978300957 success 1m28s, smoke-verified) +- `feat(auth)`: login OAuth buttons are now opt-in via `NEXT_PUBLIC_OAUTH_GITHUB`/`_GOOGLE` (pure `lib/auth-config.ts` `resolveOAuthProviders`, unit-tested; `.env.example` + DEPLOYMENT.md). Prod `/login` smoke: 200, GitHub/Google + OR divider ABSENT (no env set = correctly hidden). +- `fix(members)`: members page now renders the existing `InvitesPanel` for admin/board (server page loads invites + slug, composes via a new `inviteSlot` prop; MembersClient stays invite-agnostic). First-run dashboard copy fixed. Prod `/members` 307 -> /login (auth-gated; logged-in invite UI NOT browser-verified — needs a manual check). + +### Certifications + Instructor (locked module 1) — CODE-COMPLETE, LOCAL, 6 commits, NOT deployed +Built phase-by-phase, each gated (vitest + `pnpm build` read in-run) + its own commit: +- A: migration `030_certifications.sql` (`certifications`, `member_certifications`) + mirrored in `schema.sql` (in-place seed fn updated + appended Section 18) + `permissions-catalog` (`certifications.manage`, `certifications.grant`, group Certifications, board defaults + backfill) + `types/database.ts` + DATABASE_SCHEMA/DB_SCHEMA_MAP. RLS additive/default-deny: cert types readable by any space member; grants readable by managers/granters (all) or member (own); INSERT/UPDATE = `certifications.grant`; NO DELETE policy (immutable history, soft revoke); no anon path. Expiry snapshotted at grant. +- B: pure `lib/certifications-logic.ts` (`computeExpiry` month/leap clamp; `isCertificationActive`; `certificationStatus`) + 16 tests (suite 348 -> 364) + Zod schemas. +- C: `lib/actions/certifications.ts` (create/update/delete/list types; grant/revoke/renew; listMemberCertifications; getMyCertifications — no-params, session+RLS scoped) + `lib/certifications-guard.ts` + barrel + API_REFERENCE. +- D: `/certifications` admin pages (manage cert types) + sidebar Admin link. +- E: per-member award/revoke/renew dialog reachable from a "Certs" column on `/members` shown to ANY `certifications.grant` holder (non-admin instructors included; server resolves the perm via RPC). +- F: member-facing `/me` ("My certifications & access": own grants + read-only effective permissions) + sidebar "My access" link + ARCHITECTURE. + +### Open / not done +- Certifications is LOCAL (6 commits ahead). Awaiting deploy approval. After push: confirm `030` applied, board has both perms, exercise create-type -> grant -> revoke -> renew -> `/me` -> non-admin-instructor path; nothing is browser-verified (no live browser this session). +- Pure logic is unit-tested; action *orchestration* (RLS/visibility/grant uniqueness) is NOT (no Supabase mock harness — same systemic gap as forms). +- Queued by user, NOT yet started: (a) verify secrets reveal works via BOTH `ops.secrets.read` role perm AND direct `ops_acl` entry (read-only audit under the RLS guardrail); (b) KB + processes should render markdown with working in-document anchor links. +- STILL OPEN: production incident-INSERT RLS bug — raised to user this pass; awaiting a prod retry to diagnose (#1 SSR auth.uid() vs #3 data drift). Do not touch the permissions/RLS surface without that diagnosis + approval. + +--- + ## 2026-05-17 (pass 20) — Forms epic deployed; research; incident tracking; forward roadmap ### Deployed & verified