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.
61 lines
4.0 KiB
TypeScript
61 lines
4.0 KiB
TypeScript
import { redirect } from 'next/navigation'
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import { CustomizeClient } from './customize-client'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
export default async function CustomizePage() {
|
|
const supabase = await createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
if (!user) redirect('/login')
|
|
|
|
const { data: member } = await supabase
|
|
.from('space_members')
|
|
.select('space_id, role')
|
|
.eq('user_id', user.id)
|
|
.in('status', ['current', 'unverified', 'late'])
|
|
.single()
|
|
if (!member) redirect('/login')
|
|
|
|
const isAdmin = member.role === 'admin'
|
|
const canCustomize = member.role === 'admin' || member.role === 'board'
|
|
if (!canCustomize) redirect('/dashboard')
|
|
|
|
const [
|
|
{ data: roleLabels },
|
|
{ data: customRoles },
|
|
{ data: tiers },
|
|
{ data: invites },
|
|
{ data: onboardingSteps },
|
|
{ data: areas },
|
|
{ data: rolePerms },
|
|
{ data: areaLeads },
|
|
{ data: members },
|
|
] = await Promise.all([
|
|
supabase.from('space_role_labels').select('role, display_name, description, color').eq('space_id', member.space_id),
|
|
supabase.from('space_custom_roles').select('id, slug, name, description, color, sort_order').eq('space_id', member.space_id).order('sort_order'),
|
|
supabase.from('space_tiers').select('id, slug, name, description, monthly_price_cents, billing_cadence, is_system, is_archived, sort_order').eq('space_id', member.space_id).order('sort_order'),
|
|
supabase.from('space_invites').select('id, code, label, expires_at, max_uses, uses_count, is_enabled, created_at').eq('space_id', member.space_id).order('created_at', { ascending: false }),
|
|
supabase.from('space_onboarding_steps').select('id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order').eq('space_id', member.space_id).order('sort_order'),
|
|
supabase.from('space_areas').select('id, code, name, icon, sort_order, is_archived').eq('space_id', member.space_id).order('sort_order').order('name'),
|
|
supabase.from('space_role_permissions').select('subject, permission').eq('space_id', member.space_id),
|
|
supabase.from('area_leads').select('id, area_code, area_name, lead_id, lead_handle, status').eq('space_id', member.space_id).order('area_name'),
|
|
supabase.from('space_members').select('id, display_name, handle').eq('space_id', member.space_id).in('status', ['current', 'unverified', 'late']).order('display_name'),
|
|
])
|
|
|
|
return (
|
|
<CustomizeClient
|
|
isAdmin={isAdmin}
|
|
roleLabels={(roleLabels ?? []) as Array<{ role: string; display_name: string | null; description: string | null; color: string | null }>}
|
|
customRoles={(customRoles ?? []) as Array<{ id: string; slug: string; name: string; description: string | null; color: string | null; sort_order: number }>}
|
|
tiers={(tiers ?? []) as Array<{ id: string; slug: string; name: string; description: string | null; monthly_price_cents: number; billing_cadence: string; is_system: boolean; is_archived: boolean; sort_order: number }>}
|
|
invites={(invites ?? []) as Array<{ id: string; code: string; label: string | null; expires_at: string | null; max_uses: number | null; uses_count: number; is_enabled: boolean; created_at: string }>}
|
|
onboardingSteps={(onboardingSteps ?? []) as Array<{ id: string; step_key: string; step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'; title: string; body: string | null; config: Record<string, unknown>; is_enabled: boolean; is_required: boolean; is_system: boolean; sort_order: number }>}
|
|
areas={(areas ?? []) as Array<{ id: string; code: string; name: string; icon: string | null; sort_order: number; is_archived: boolean }>}
|
|
rolePerms={(rolePerms ?? []) as Array<{ subject: string; permission: string }>}
|
|
areaLeads={(areaLeads ?? []) as Array<{ id: string; area_code: string | null; area_name: string; lead_id: string | null; lead_handle: string | null; status: string }>}
|
|
members={(members ?? []) as Array<{ id: string; display_name: string | null; handle: string | null }>}
|
|
/>
|
|
)
|
|
}
|