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.
This commit is contained in:
@@ -899,3 +899,13 @@ Management actions are gated by `forms.manage` (checked via the `user_has_permis
|
|||||||
Onboarding integration (Phase 4, migration 027): a `space_onboarding_steps` step can have `step_type = 'form'` with `config.form_id`. The onboarding flow renders the form/waiver and submits via `submitForm`; `finishOnboarding` treats a required form step as satisfied if a submission for that form by the member already exists (any version — re-sign is non-blocking), and fails open if the configured form is missing/unpublished so a misconfiguration cannot trap members.
|
Onboarding integration (Phase 4, migration 027): a `space_onboarding_steps` step can have `step_type = 'form'` with `config.form_id`. The onboarding flow renders the form/waiver and submits via `submitForm`; `finishOnboarding` treats a required form step as satisfied if a submission for that form by the member already exists (any version — re-sign is non-blocking), and fails open if the configured form is missing/unpublished so a misconfiguration cannot trap members.
|
||||||
|
|
||||||
Retro-link (Phase 5): `claimMyAnonymousSubmissions()` is a safe, self-only, verified-email-gated action — it resolves the member/email from the caller's session (no trusted params), so a client can only ever link its own anonymous submissions, and only with a confirmed email (the locked decision). It is invoked best-effort from `joinSpace` (new membership, verified email) and `finishOnboarding`. `addMember`/`importMembers` are deliberately NOT auto-linked: those emails are admin-asserted, not verified, so auto-linking would violate the verified-email rule and risk mis-attributing a waiver; the admin uses the forms.manage `linkSubmissionsForMember` for those (the "admin manual-link" path).
|
Retro-link (Phase 5): `claimMyAnonymousSubmissions()` is a safe, self-only, verified-email-gated action — it resolves the member/email from the caller's session (no trusted params), so a client can only ever link its own anonymous submissions, and only with a confirmed email (the locked decision). It is invoked best-effort from `joinSpace` (new membership, verified email) and `finishOnboarding`. `addMember`/`importMembers` are deliberately NOT auto-linked: those emails are admin-asserted, not verified, so auto-linking would violate the verified-email rule and risk mis-attributing a waiver; the admin uses the forms.manage `linkSubmissionsForMember` for those (the "admin manual-link" path).
|
||||||
|
|
||||||
|
### Certifications + Instructor: `certifications.ts` (migration 030)
|
||||||
|
Cert-type actions are gated by `certifications.manage`; grant actions by `certifications.grant` (the Instructor capability). Both checked via the `user_has_permission` RPC; the `certifications` / `member_certifications` RLS independently enforces the same permission.
|
||||||
|
- `createCertification(input)` / `updateCertification(input)` / `deleteCertification(input)` — manage. Name is unique per space (case-insensitive); friendly error on collision. `validity_months` null = never expires. `deleteCertification` is refused once the cert has been granted to anyone (archive via `updateCertification {is_active:false}` instead — grant history is immutable).
|
||||||
|
- `listCertifications()` — readable by a manager OR a granter (the admin list and the per-member award panel both need the catalog); returns all cert types for the caller's space (RLS-scoped), active first.
|
||||||
|
- `grantCertification(input)` — granter. Verifies the cert is in-space and active and the target member is in-space; computes `expires_at` from the cert's `validity_months` at grant time unless an explicit `expires_at` override is supplied; the partial unique index rejects a second active grant of the same cert to the same member with a friendly message.
|
||||||
|
- `revokeCertification(input)` — granter. Soft revoke (`revoked_at`/`revoked_by`/`revoked_reason`); refused if already revoked. No row is ever deleted (no DELETE policy; history immutable).
|
||||||
|
- `renewCertification(input)` — granter. Resets `granted_at` to now and recomputes expiry from the cert's current validity; a revoked grant is terminal (issue a fresh grant instead).
|
||||||
|
- `listMemberCertifications({ memberId })` — manager or granter; all grants for one member (joined with the cert) for the per-member award panel.
|
||||||
|
- `getMyCertifications()` — the signed-in member's own grants. No params: the member is resolved from the session and RLS independently restricts a member to their own rows (the `/me` view).
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import {
|
||||||
|
requireMember,
|
||||||
|
logActivity,
|
||||||
|
parseInput,
|
||||||
|
type Member,
|
||||||
|
type ServerSupabase,
|
||||||
|
} from '@/lib/auth-helpers'
|
||||||
|
import {
|
||||||
|
createCertificationSchema,
|
||||||
|
updateCertificationSchema,
|
||||||
|
certificationIdSchema,
|
||||||
|
grantCertificationSchema,
|
||||||
|
revokeCertificationSchema,
|
||||||
|
renewCertificationSchema,
|
||||||
|
listMemberCertificationsSchema,
|
||||||
|
} from '@/lib/validations'
|
||||||
|
import { computeExpiry } from '@/lib/certifications-logic'
|
||||||
|
|
||||||
|
type Gate =
|
||||||
|
| { ok: true; supabase: ServerSupabase; member: Member }
|
||||||
|
| { ok: false; error: string }
|
||||||
|
|
||||||
|
// Permission gate. Errors here are advisory UX; the RLS on certifications /
|
||||||
|
// member_certifications independently enforces the same permission, so a
|
||||||
|
// bypass cannot write.
|
||||||
|
async function requirePermission(perm: 'certifications.manage' | 'certifications.grant'): Promise<Gate> {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const auth = await requireMember(supabase)
|
||||||
|
if (!auth.ok) return { ok: false, error: auth.error }
|
||||||
|
const { member } = auth
|
||||||
|
|
||||||
|
const { data: allowed, error } = await supabase.rpc('user_has_permission', {
|
||||||
|
uid: member.user_id as string,
|
||||||
|
sid: member.space_id,
|
||||||
|
perm,
|
||||||
|
})
|
||||||
|
if (error) return { ok: false, error: error.message }
|
||||||
|
if (!allowed) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
perm === 'certifications.manage'
|
||||||
|
? 'You do not have permission to manage certification types'
|
||||||
|
: 'You do not have permission to award or revoke certifications',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, supabase, member }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUniqueViolation(message: string): boolean {
|
||||||
|
return /duplicate key value|already exists|unique constraint/i.test(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Certification types (certifications.manage) ─────────────────────────────
|
||||||
|
|
||||||
|
export async function createCertification(input: unknown) {
|
||||||
|
const gate = await requirePermission('certifications.manage')
|
||||||
|
if (!gate.ok) return { error: gate.error }
|
||||||
|
const { supabase, member } = gate
|
||||||
|
|
||||||
|
const v = parseInput(createCertificationSchema, input)
|
||||||
|
if (!v.ok) return { error: v.error }
|
||||||
|
const c = v.data
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('certifications')
|
||||||
|
.insert({
|
||||||
|
space_id: member.space_id,
|
||||||
|
name: c.name,
|
||||||
|
description: c.description ?? null,
|
||||||
|
validity_months: c.validity_months ?? null,
|
||||||
|
created_by: member.id,
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (isUniqueViolation(error.message)) {
|
||||||
|
return { error: 'A certification with that name already exists in this space.' }
|
||||||
|
}
|
||||||
|
return { error: error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
await logActivity(supabase, member, 'created', 'certification', data.id as string, c.name)
|
||||||
|
revalidatePath('/certifications')
|
||||||
|
return { data: { id: data.id as string } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCertification(input: unknown) {
|
||||||
|
const gate = await requirePermission('certifications.manage')
|
||||||
|
if (!gate.ok) return { error: gate.error }
|
||||||
|
const { supabase, member } = gate
|
||||||
|
|
||||||
|
const v = parseInput(updateCertificationSchema, input)
|
||||||
|
if (!v.ok) return { error: v.error }
|
||||||
|
const u = v.data
|
||||||
|
|
||||||
|
const patch: Record<string, unknown> = {}
|
||||||
|
if (u.name !== undefined) patch.name = u.name
|
||||||
|
if (u.description !== undefined) patch.description = u.description ?? null
|
||||||
|
if (u.validity_months !== undefined) patch.validity_months = u.validity_months ?? null
|
||||||
|
if (u.is_active !== undefined) patch.is_active = u.is_active
|
||||||
|
if (Object.keys(patch).length === 0) return { data: { id: u.certificationId } }
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('certifications')
|
||||||
|
.update(patch)
|
||||||
|
.eq('id', u.certificationId)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (isUniqueViolation(error.message)) {
|
||||||
|
return { error: 'A certification with that name already exists in this space.' }
|
||||||
|
}
|
||||||
|
return { error: error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
await logActivity(supabase, member, 'updated', 'certification', u.certificationId)
|
||||||
|
revalidatePath('/certifications')
|
||||||
|
return { data: { id: u.certificationId } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCertification(input: unknown) {
|
||||||
|
const gate = await requirePermission('certifications.manage')
|
||||||
|
if (!gate.ok) return { error: gate.error }
|
||||||
|
const { supabase, member } = gate
|
||||||
|
|
||||||
|
const v = parseInput(certificationIdSchema, input)
|
||||||
|
if (!v.ok) return { error: v.error }
|
||||||
|
|
||||||
|
// A cert that has ever been granted is part of members' records; archive it
|
||||||
|
// (is_active=false) instead of destroying history.
|
||||||
|
const { count } = await supabase
|
||||||
|
.from('member_certifications')
|
||||||
|
.select('id', { count: 'exact', head: true })
|
||||||
|
.eq('certification_id', v.data.certificationId)
|
||||||
|
if ((count ?? 0) > 0) {
|
||||||
|
return { error: 'This certification has been granted to members. Archive it instead of deleting.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('certifications')
|
||||||
|
.delete()
|
||||||
|
.eq('id', v.data.certificationId)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
if (error) return { error: error.message }
|
||||||
|
|
||||||
|
await logActivity(supabase, member, 'deleted', 'certification', v.data.certificationId)
|
||||||
|
revalidatePath('/certifications')
|
||||||
|
return { data: { id: v.data.certificationId } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Readable by anyone who can manage OR grant (the admin list and the per-member
|
||||||
|
// award panel both need the catalog). RLS already scopes to the caller's space.
|
||||||
|
export async function listCertifications() {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const auth = await requireMember(supabase)
|
||||||
|
if (!auth.ok) return { error: auth.error }
|
||||||
|
const { member } = auth
|
||||||
|
|
||||||
|
const { data: allowed } = await supabase.rpc('user_has_permission', {
|
||||||
|
uid: member.user_id as string,
|
||||||
|
sid: member.space_id,
|
||||||
|
perm: 'certifications.manage',
|
||||||
|
})
|
||||||
|
let permitted = !!allowed
|
||||||
|
if (!permitted) {
|
||||||
|
const { data: canGrant } = await supabase.rpc('user_has_permission', {
|
||||||
|
uid: member.user_id as string,
|
||||||
|
sid: member.space_id,
|
||||||
|
perm: 'certifications.grant',
|
||||||
|
})
|
||||||
|
permitted = !!canGrant
|
||||||
|
}
|
||||||
|
if (!permitted) return { error: 'You do not have permission to view certifications' }
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('certifications')
|
||||||
|
.select('id, name, description, validity_months, is_active, created_at, updated_at')
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
.order('is_active', { ascending: false })
|
||||||
|
.order('name', { ascending: true })
|
||||||
|
if (error) return { error: error.message }
|
||||||
|
return { data: data ?? [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Grants (certifications.grant — the Instructor capability) ───────────────
|
||||||
|
|
||||||
|
export async function grantCertification(input: unknown) {
|
||||||
|
const gate = await requirePermission('certifications.grant')
|
||||||
|
if (!gate.ok) return { error: gate.error }
|
||||||
|
const { supabase, member } = gate
|
||||||
|
|
||||||
|
const v = parseInput(grantCertificationSchema, input)
|
||||||
|
if (!v.ok) return { error: v.error }
|
||||||
|
const g = v.data
|
||||||
|
|
||||||
|
// Cert must exist, be in this space, and be active.
|
||||||
|
const { data: cert, error: certErr } = await supabase
|
||||||
|
.from('certifications')
|
||||||
|
.select('id, name, validity_months, is_active, space_id')
|
||||||
|
.eq('id', g.certificationId)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
.single()
|
||||||
|
if (certErr || !cert) return { error: 'Certification not found' }
|
||||||
|
if (!cert.is_active) return { error: 'That certification is archived and cannot be granted.' }
|
||||||
|
|
||||||
|
// Target member must be in the same space.
|
||||||
|
const { data: target, error: targetErr } = await supabase
|
||||||
|
.from('space_members')
|
||||||
|
.select('id, space_id, display_name')
|
||||||
|
.eq('id', g.memberId)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
.single()
|
||||||
|
if (targetErr || !target) return { error: 'Member not found in this space' }
|
||||||
|
|
||||||
|
const grantedAt = new Date().toISOString()
|
||||||
|
const expiresAt = g.expires_at ?? computeExpiry(grantedAt, cert.validity_months)
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('member_certifications')
|
||||||
|
.insert({
|
||||||
|
space_id: member.space_id,
|
||||||
|
member_id: g.memberId,
|
||||||
|
certification_id: g.certificationId,
|
||||||
|
granted_by: member.id,
|
||||||
|
granted_at: grantedAt,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
note: g.note ?? null,
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (isUniqueViolation(error.message)) {
|
||||||
|
return { error: 'This member already holds an active grant of that certification.' }
|
||||||
|
}
|
||||||
|
return { error: error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
await logActivity(supabase, member, 'granted', 'certification', g.certificationId, cert.name)
|
||||||
|
revalidatePath('/members')
|
||||||
|
revalidatePath('/me')
|
||||||
|
return { data: { id: data.id as string } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeCertification(input: unknown) {
|
||||||
|
const gate = await requirePermission('certifications.grant')
|
||||||
|
if (!gate.ok) return { error: gate.error }
|
||||||
|
const { supabase, member } = gate
|
||||||
|
|
||||||
|
const v = parseInput(revokeCertificationSchema, input)
|
||||||
|
if (!v.ok) return { error: v.error }
|
||||||
|
|
||||||
|
const { data: grant, error: loadErr } = await supabase
|
||||||
|
.from('member_certifications')
|
||||||
|
.select('id, space_id, revoked_at, certification_id')
|
||||||
|
.eq('id', v.data.memberCertificationId)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
.single()
|
||||||
|
if (loadErr || !grant) return { error: 'Grant not found' }
|
||||||
|
if (grant.revoked_at) return { error: 'That certification is already revoked.' }
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('member_certifications')
|
||||||
|
.update({
|
||||||
|
revoked_at: new Date().toISOString(),
|
||||||
|
revoked_by: member.id,
|
||||||
|
revoked_reason: v.data.reason ?? null,
|
||||||
|
})
|
||||||
|
.eq('id', v.data.memberCertificationId)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
if (error) return { error: error.message }
|
||||||
|
|
||||||
|
await logActivity(supabase, member, 'revoked', 'certification', grant.certification_id as string)
|
||||||
|
revalidatePath('/members')
|
||||||
|
revalidatePath('/me')
|
||||||
|
return { data: { id: v.data.memberCertificationId } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renew a still-valid or expired (but not revoked) grant: reset granted_at to
|
||||||
|
// now and recompute expiry from the cert's current validity. A revoked grant
|
||||||
|
// is terminal — issue a fresh grant instead.
|
||||||
|
export async function renewCertification(input: unknown) {
|
||||||
|
const gate = await requirePermission('certifications.grant')
|
||||||
|
if (!gate.ok) return { error: gate.error }
|
||||||
|
const { supabase, member } = gate
|
||||||
|
|
||||||
|
const v = parseInput(renewCertificationSchema, input)
|
||||||
|
if (!v.ok) return { error: v.error }
|
||||||
|
|
||||||
|
const { data: grant, error: loadErr } = await supabase
|
||||||
|
.from('member_certifications')
|
||||||
|
.select('id, space_id, revoked_at, certification_id, note')
|
||||||
|
.eq('id', v.data.memberCertificationId)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
.single()
|
||||||
|
if (loadErr || !grant) return { error: 'Grant not found' }
|
||||||
|
if (grant.revoked_at) return { error: 'That certification is revoked. Grant a new one instead.' }
|
||||||
|
|
||||||
|
const { data: cert, error: certErr } = await supabase
|
||||||
|
.from('certifications')
|
||||||
|
.select('validity_months, name')
|
||||||
|
.eq('id', grant.certification_id as string)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
.single()
|
||||||
|
if (certErr || !cert) return { error: 'Certification not found' }
|
||||||
|
|
||||||
|
const grantedAt = new Date().toISOString()
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('member_certifications')
|
||||||
|
.update({
|
||||||
|
granted_at: grantedAt,
|
||||||
|
expires_at: computeExpiry(grantedAt, cert.validity_months),
|
||||||
|
granted_by: member.id,
|
||||||
|
note: v.data.note ?? grant.note ?? null,
|
||||||
|
})
|
||||||
|
.eq('id', v.data.memberCertificationId)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
if (error) return { error: error.message }
|
||||||
|
|
||||||
|
await logActivity(supabase, member, 'renewed', 'certification', grant.certification_id as string, cert.name)
|
||||||
|
revalidatePath('/members')
|
||||||
|
revalidatePath('/me')
|
||||||
|
return { data: { id: v.data.memberCertificationId } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// All grants for one member (the per-member award panel). Manager or granter.
|
||||||
|
export async function listMemberCertifications(input: unknown) {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const auth = await requireMember(supabase)
|
||||||
|
if (!auth.ok) return { error: auth.error }
|
||||||
|
const { member } = auth
|
||||||
|
|
||||||
|
const v = parseInput(listMemberCertificationsSchema, input)
|
||||||
|
if (!v.ok) return { error: v.error }
|
||||||
|
|
||||||
|
const { data: canManage } = await supabase.rpc('user_has_permission', {
|
||||||
|
uid: member.user_id as string,
|
||||||
|
sid: member.space_id,
|
||||||
|
perm: 'certifications.manage',
|
||||||
|
})
|
||||||
|
let permitted = !!canManage
|
||||||
|
if (!permitted) {
|
||||||
|
const { data: canGrant } = await supabase.rpc('user_has_permission', {
|
||||||
|
uid: member.user_id as string,
|
||||||
|
sid: member.space_id,
|
||||||
|
perm: 'certifications.grant',
|
||||||
|
})
|
||||||
|
permitted = !!canGrant
|
||||||
|
}
|
||||||
|
if (!permitted) return { error: 'You do not have permission to view member certifications' }
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('member_certifications')
|
||||||
|
.select(
|
||||||
|
'id, member_id, certification_id, granted_at, expires_at, revoked_at, revoked_reason, note, certifications(name, validity_months, is_active)',
|
||||||
|
)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
.eq('member_id', v.data.memberId)
|
||||||
|
.order('granted_at', { ascending: false })
|
||||||
|
if (error) return { error: error.message }
|
||||||
|
return { data: data ?? [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The signed-in member's own certifications (the /me view). No params: the
|
||||||
|
// member is resolved from the session, and RLS independently allows a member
|
||||||
|
// to read only their own grants.
|
||||||
|
export async function getMyCertifications() {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const auth = await requireMember(supabase)
|
||||||
|
if (!auth.ok) return { error: auth.error }
|
||||||
|
const { member } = auth
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('member_certifications')
|
||||||
|
.select(
|
||||||
|
'id, certification_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 })
|
||||||
|
if (error) return { error: error.message }
|
||||||
|
return { data: data ?? [] }
|
||||||
|
}
|
||||||
@@ -47,3 +47,6 @@ export * from './permissions'
|
|||||||
|
|
||||||
// Custom forms and waivers
|
// Custom forms and waivers
|
||||||
export * from './forms'
|
export * from './forms'
|
||||||
|
|
||||||
|
// Certifications + Instructor capability
|
||||||
|
export * from './certifications'
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
|
||||||
|
// Server-component guard for the /certifications management pages. Mirrors
|
||||||
|
// the forms guard: resolve the member, check certifications.manage via the
|
||||||
|
// same RPC the RLS uses, redirect non-managers before any data is fetched.
|
||||||
|
// The RLS independently enforces this; the redirect is UX, not the boundary.
|
||||||
|
export async function requireCertificationsManagerPage() {
|
||||||
|
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, user_id, display_name')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.in('status', ['current', 'unverified', 'late'])
|
||||||
|
.single()
|
||||||
|
if (!member) redirect('/login')
|
||||||
|
|
||||||
|
const { data: allowed } = await supabase.rpc('user_has_permission', {
|
||||||
|
uid: user.id,
|
||||||
|
sid: member.space_id,
|
||||||
|
perm: 'certifications.manage',
|
||||||
|
})
|
||||||
|
if (!allowed) redirect('/dashboard')
|
||||||
|
|
||||||
|
return { supabase, member }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user