From af503c3869b311a1b534f0ea3a3928801cced883 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 18:59:44 -0700 Subject: [PATCH] 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. --- app/(app)/members/members-client.tsx | 30 ++- app/(app)/members/page.tsx | 10 + .../member-certifications-dialog.tsx | 240 ++++++++++++++++++ 3 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 components/certifications/member-certifications-dialog.tsx diff --git a/app/(app)/members/members-client.tsx b/app/(app)/members/members-client.tsx index 0b8d6af..b7a2683 100644 --- a/app/(app)/members/members-client.tsx +++ b/app/(app)/members/members-client.tsx @@ -10,6 +10,7 @@ import { useRouter } from 'next/navigation' import type { Tables } from '@/types/database' import { PageTitle } from '@/components/ui/page-title' import { useConfirm } from '@/components/ui/confirm' +import { MemberCertificationsDialog } from '@/components/certifications/member-certifications-dialog' type Member = Tables<'space_members'> type AreaLeadRole = { id: string; area_name: string; lead_id: string | null } @@ -22,6 +23,10 @@ interface Props { // so the members page is a real place to create and share a join link // (the first-run "Invite or add members" step lands here). inviteSlot?: ReactNode + // True when the viewer holds certifications.grant (the Instructor + // capability), independent of admin/board. Adds a per-member + // certifications panel reachable by non-admin instructors. + canGrantCerts?: boolean } const TIER_COLORS: Record = { @@ -33,8 +38,9 @@ const TIER_COLORS: Record = { const isAdmin = (role: string) => role === 'admin' || role === 'board' -export function MembersClient({ members: initialMembers, currentRole, areaLeadRoles = [], inviteSlot }: Props) { +export function MembersClient({ members: initialMembers, currentRole, areaLeadRoles = [], inviteSlot, canGrantCerts = false }: Props) { const router = useRouter() + const [certMember, setCertMember] = useState(null) const confirm = useConfirm() const [members, setMembers] = useState(initialMembers) @@ -271,6 +277,9 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo JOINED LAST PAYMENT STATUS + {canGrantCerts && ( + CERTS + )} {isAdmin(currentRole) && ( ACTIONS )} @@ -325,6 +334,16 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo {m.status} )} + {canGrantCerts && ( + + + + )} {isAdmin(currentRole) && (
@@ -370,7 +389,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo ) }) : ( - + @@ -424,6 +443,13 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo + + {canGrantCerts && ( + setCertMember(null)} + /> + )}
) } diff --git a/app/(app)/members/page.tsx b/app/(app)/members/page.tsx index 4c5fa7a..e058ba3 100644 --- a/app/(app)/members/page.tsx +++ b/app/(app)/members/page.tsx @@ -87,10 +87,20 @@ export default async function MembersPage() { .order('created_at', { ascending: false }) : { data: [] } + // certifications.grant (the Instructor capability) is independent of + // admin/board, so check it explicitly to decide whether to show the + // per-member certifications panel. + const { data: canGrantCerts } = await supabase.rpc('user_has_permission', { + uid: user.id, + sid: self.space_id, + perm: 'certifications.grant', + }) + return ( } inviteSlot={ canInvite ? ( diff --git a/components/certifications/member-certifications-dialog.tsx b/components/certifications/member-certifications-dialog.tsx new file mode 100644 index 0000000..3645f6b --- /dev/null +++ b/components/certifications/member-certifications-dialog.tsx @@ -0,0 +1,240 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { toast } from 'sonner' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useConfirm } from '@/components/ui/confirm' +import { + listMemberCertifications, + listCertifications, + grantCertification, + revokeCertification, + renewCertification, +} from '@/lib/actions' +import { + certificationStatus, + CERT_STATUS_LABEL, + type CertStatus, +} from '@/lib/certifications-logic' + +type Grant = { + id: string + certification_id: string + granted_at: string + expires_at: string | null + revoked_at: string | null + revoked_reason: string | null + note: string | null + certifications: { name: string; validity_months: number | null; is_active: boolean } | null +} + +type CertType = { + id: string + name: string + is_active: boolean +} + +const STATUS_CLASS: Record = { + active: 'text-primary bg-primary/10 border-primary/20', + expiring_soon: 'text-amber-600 bg-amber-50 border-amber-200', + expired: 'text-red-600 bg-red-50 border-red-200', + revoked: 'text-muted-foreground bg-muted border-border', +} + +export function MemberCertificationsDialog({ + member, + onClose, +}: { + member: { id: string; display_name: string | null } | null + onClose: () => void +}) { + const confirm = useConfirm() + const open = !!member + const [loading, setLoading] = useState(false) + const [grants, setGrants] = useState([]) + const [types, setTypes] = useState([]) + const [certId, setCertId] = useState('') + const [note, setNote] = useState('') + const [expires, setExpires] = useState('') + const [busy, setBusy] = useState(false) + + const load = useCallback(async (memberId: string) => { + setLoading(true) + const [g, t] = await Promise.all([ + listMemberCertifications({ memberId }), + listCertifications(), + ]) + setLoading(false) + if ('error' in g && g.error) { + toast.error(g.error) + } else { + setGrants(((g as { data: Grant[] }).data) ?? []) + } + if (!('error' in t) || !t.error) { + setTypes(((t as { data: CertType[] }).data ?? []).filter(c => c.is_active)) + } + }, []) + + useEffect(() => { + if (member) { + setCertId('') + setNote('') + setExpires('') + load(member.id) + } + }, [member, load]) + + async function onGrant(e: React.FormEvent) { + e.preventDefault() + if (!member) return + if (!certId) { + toast.error('Pick a certification') + return + } + setBusy(true) + const res = await grantCertification({ + memberId: member.id, + certificationId: certId, + note: note.trim() || null, + expires_at: expires || null, + }) + setBusy(false) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + toast.success('Certification awarded') + setCertId('') + setNote('') + setExpires('') + load(member.id) + } + + async function onRevoke(g: Grant) { + const ok = await confirm({ + title: 'Revoke certification', + description: `Revoke "${g.certifications?.name ?? 'this certification'}" from ${member?.display_name ?? 'this member'}? The record is kept as history.`, + confirmText: 'Revoke', + destructive: true, + }) + if (!ok || !member) return + const res = await revokeCertification({ memberCertificationId: g.id }) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + toast.success('Revoked') + load(member.id) + } + + async function onRenew(g: Grant) { + if (!member) return + const res = await renewCertification({ memberCertificationId: g.id }) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + toast.success('Renewed') + load(member.id) + } + + return ( + { if (!o) onClose() }}> + + + Certifications — {member?.display_name ?? 'Member'} + + +
+

Award a certification

+ + setNote(e.target.value)} + placeholder="Note (optional, e.g. completed in class)" + className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-2 focus:outline-none focus:border-primary" + /> + + +
+ +
+ {loading ? ( +

Loading…

+ ) : grants.length === 0 ? ( +

No certifications yet.

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

    + 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.note &&

    {g.note}

    } +
    +
    + {!g.revoked_at && ( + <> + + + + )} +
    +
  • + ) + })} +
+ )} +
+
+
+ ) +}