feat(certifications): member-facing /me view + docs

/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.
This commit is contained in:
Moheeb Zara
2026-05-16 19:01:57 -07:00
parent af503c3869
commit b2ac0e0271
4 changed files with 210 additions and 3 deletions
+158
View File
@@ -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<CertStatus, 'default' | 'outline'> = {
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 (
<>
<PageHeader>
<PageTitle>My certifications &amp; access</PageTitle>
</PageHeader>
<div className="p-4 md:p-6 space-y-8 max-w-3xl">
<section>
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3">
Certifications
</h2>
{grants.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground">
You have no certifications yet. An instructor can award one to you.
</p>
) : (
<ul className="divide-y rounded-lg border border-border">
{grants.map(g => {
const status = certificationStatus(g)
return (
<li key={g.id} className="p-4 flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-sans text-sm font-semibold text-foreground">
{g.certifications?.name ?? 'Certification'}
</span>
<Badge variant={STATUS_VARIANT[status]}>{CERT_STATUS_LABEL[status]}</Badge>
</div>
{g.certifications?.description && (
<p className="font-sans text-sm text-muted-foreground mt-1">
{g.certifications.description}
</p>
)}
<p className="font-mono text-[10px] text-muted-foreground mt-1">
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()}` : ''}
</p>
{g.revoked_at && g.revoked_reason && (
<p className="font-sans text-xs text-muted-foreground mt-0.5">
Reason: {g.revoked_reason}
</p>
)}
</div>
</li>
)
})}
</ul>
)}
</section>
<section>
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3">
My permissions
</h2>
{held.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground">
You have the standard member access for this space. No extra permissions are granted to your role.
</p>
) : (
<ul className="flex flex-wrap gap-2">
{held.map(p => (
<li
key={p.code}
className="font-mono text-[11px] border border-border rounded px-2 py-1 text-foreground"
title={p.code}
>
{p.label}
</li>
))}
</ul>
)}
<p className="font-sans text-xs text-muted-foreground mt-3">
Permissions are set per role by a space admin. This is a read-only view.
</p>
</section>
</div>
</>
)
}
+2 -1
View File
@@ -14,7 +14,7 @@ import {
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2, Wrench, Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2, Wrench,
MessageSquare, Users, CreditCard, BookUser, Download, LogOut, MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch, Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch,
MessagesSquare, SlidersHorizontal, ClipboardList, FileText, Award, MessagesSquare, SlidersHorizontal, ClipboardList, FileText, Award, BadgeCheck,
} from 'lucide-react' } from 'lucide-react'
interface NavLinkProps { interface NavLinkProps {
@@ -135,6 +135,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
<NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} onClick={onNav} /> <NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} onClick={onNav} />
<NavLink href="/my-forms" label="Forms" icon={FileText} active={isActive('/my-forms')} onClick={onNav} /> <NavLink href="/my-forms" label="Forms" icon={FileText} active={isActive('/my-forms')} onClick={onNav} />
<NavLink href="/profile" label="My profile" icon={UserCircle} active={isActive('/profile')} onClick={onNav} /> <NavLink href="/profile" label="My profile" icon={UserCircle} active={isActive('/profile')} onClick={onNav} />
<NavLink href="/me" label="My access" icon={BadgeCheck} active={isActive('/me')} onClick={onNav} />
{isAdmin && ( {isAdmin && (
<NavLink href="/recruitment" label="Recruitment" icon={UserSearch} active={isActive('/recruitment')} onClick={onNav} /> <NavLink href="/recruitment" label="Recruitment" icon={UserSearch} active={isActive('/recruitment')} onClick={onNav} />
)} )}
+25 -2
View File
@@ -415,10 +415,11 @@ Integration credentials stored in `integrations.config` (JSONB):
| 010 | Fixed channel trigger (removed description column) | | 010 | Fixed channel trigger (removed description column) |
| 011 | Fixed enum values (active→current, pending→unverified) | | 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, (governance kernel, areas, forum/tiers/roles/invites, configurable onboarding,
customizable permissions + Ops ACLs, self-change hardening, `026`-`029` custom 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) ### 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` grant on `forms`. Form management is gated by the new additive `forms.manage`
permission via `user_has_permission`. 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 ## 13. Known Limitations
+25
View File
@@ -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 ## 2026-05-17 (pass 20) — Forms epic deployed; research; incident tracking; forward roadmap
### Deployed & verified ### Deployed & verified