This commit is contained in:
jjbrunton committed 2025-12-29 23:10:12 +00:00
1 parent 358c2be0ee
commit b10eae1bf3
26 files changed
+2509 -53

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
COPY turbo.json ./
COPY apps/admin/package.json ./apps/admin/
COPY packages/shared/package.json ./packages/shared/
RUN npm install
COPY . .
RUN npx turbo run build --filter=@sauci/shared
WORKDIR /app/apps/admin
EXPOSE 3001
CMD ["npm", "run", "dev"]
+16
View File
@@ -0,0 +1,16 @@
services:
admin:
build:
context: ../..
dockerfile: apps/admin/Dockerfile.dev
ports:
- "3000:3001"
environment:
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
volumes:
- ./src:/app/apps/admin/src
- ./index.html:/app/apps/admin/index.html
- ./vite.config.ts:/app/apps/admin/vite.config.ts
- ./tsconfig.json:/app/apps/admin/tsconfig.json
restart: unless-stopped
+72 -26
View File
@@ -1,34 +1,80 @@
import { Admin, Resource } from "react-admin";
import { dataProvider } from "./providers/supabaseProvider";
import { auditDataProvider } from "./providers/auditDataProvider";
import { PackList, PackEdit, PackCreate } from "./resources/packs";
import { QuestionList, QuestionEdit, QuestionCreate } from "./resources/questions";
import { ProfileList, ResponseList } from "./resources/users";
import { authProvider } from "./providers/authProvider";
import { ProfileList, ProfileShow, ProfileEdit, ResponseList } from "./resources/users";
import { CoupleList } from "./resources/couples";
import { MatchList, MatchShow } from "./resources/matches";
import { AuditLogList } from "./resources/auditLogs";
import { authProvider, AdminPermissions } from "./providers/authProvider";
const App = () => (
<Admin dataProvider={dataProvider} authProvider={authProvider} requireAuth>
<Resource
name="question_packs"
list={PackList}
edit={PackEdit}
create={PackCreate}
recordRepresentation="name"
/>
<Resource
name="questions"
list={QuestionList}
edit={QuestionEdit}
create={QuestionCreate}
/>
<Resource
name="profiles"
list={ProfileList}
/>
<Resource
name="responses"
list={ResponseList}
/>
<Resource name="couples" />
<Admin
dataProvider={auditDataProvider}
authProvider={authProvider}
requireAuth
>
{(permissions: AdminPermissions) => (
<>
{/* Question Packs - accessible to all admins */}
<Resource
name="question_packs"
list={PackList}
edit={PackEdit}
create={PackCreate}
recordRepresentation="name"
options={{ label: 'Question Packs' }}
/>
{/* Questions - accessible to all admins */}
<Resource
name="questions"
list={QuestionList}
edit={QuestionEdit}
create={QuestionCreate}
options={{ label: 'Questions' }}
/>
{/* Super admin only resources */}
{permissions?.role === 'super_admin' && (
<>
<Resource
name="profiles"
list={ProfileList}
show={ProfileShow}
edit={ProfileEdit}
recordRepresentation="name"
options={{ label: 'Users' }}
/>
<Resource
name="couples"
list={CoupleList}
options={{ label: 'Relationships' }}
/>
<Resource
name="matches"
list={MatchList}
show={MatchShow}
options={{ label: 'Matches & Chats' }}
/>
<Resource
name="responses"
list={ResponseList}
options={{ label: 'Responses' }}
/>
<Resource
name="messages"
options={{ label: 'Messages' }}
/>
<Resource
name="audit_logs"
list={AuditLogList}
options={{ label: 'Audit Logs' }}
/>
</>
)}
</>
)}
</Admin>
);
+7
View File
@@ -1,4 +1,11 @@
import { createClient } from '@supabase/supabase-js';
export const supabaseConfig = {
url: import.meta.env.VITE_SUPABASE_URL,
anonKey: import.meta.env.VITE_SUPABASE_ANON_KEY,
};
export const supabase = createClient(
supabaseConfig.url,
supabaseConfig.anonKey
);
@@ -0,0 +1,122 @@
import { DataProvider, CreateParams, UpdateParams, DeleteParams, DeleteManyParams } from 'react-admin';
import { dataProvider as baseDataProvider, supabase } from './supabaseProvider';
// Tables to audit
const AUDITED_TABLES = [
'question_packs',
'questions',
'profiles',
'couples',
'responses',
'matches',
'messages',
'subscriptions',
'feedback',
];
const logAction = async (
tableName: string,
recordId: string,
action: 'INSERT' | 'UPDATE' | 'DELETE',
oldValues?: Record<string, unknown> | null,
newValues?: Record<string, unknown> | null
) => {
try {
await supabase.rpc('log_admin_action', {
p_table_name: tableName,
p_record_id: recordId,
p_action: action,
p_old_values: oldValues || null,
p_new_values: newValues || null,
});
} catch (error) {
console.error('Failed to log audit action:', error);
// Don't throw - logging failure shouldn't block the operation
}
};
export const auditDataProvider: DataProvider = {
...baseDataProvider,
create: async <RecordType extends { id: string } = { id: string }>(
resource: string,
params: CreateParams
) => {
const result = await baseDataProvider.create<RecordType>(resource, params);
if (AUDITED_TABLES.includes(resource)) {
await logAction(resource, result.data.id, 'INSERT', null, result.data as Record<string, unknown>);
}
return result;
},
update: async <RecordType extends { id: string } = { id: string }>(
resource: string,
params: UpdateParams
) => {
// Fetch old values before update
let oldValues: Record<string, unknown> | null = null;
if (AUDITED_TABLES.includes(resource)) {
const { data } = await supabase
.from(resource)
.select('*')
.eq('id', params.id)
.single();
oldValues = data;
}
const result = await baseDataProvider.update<RecordType>(resource, params);
if (AUDITED_TABLES.includes(resource)) {
await logAction(resource, params.id as string, 'UPDATE', oldValues, result.data as Record<string, unknown>);
}
return result;
},
delete: async <RecordType extends { id: string } = { id: string }>(
resource: string,
params: DeleteParams
) => {
// Fetch old values before delete
let oldValues: Record<string, unknown> | null = null;
if (AUDITED_TABLES.includes(resource)) {
const { data } = await supabase
.from(resource)
.select('*')
.eq('id', params.id)
.single();
oldValues = data;
}
const result = await baseDataProvider.delete<RecordType>(resource, params);
if (AUDITED_TABLES.includes(resource)) {
await logAction(resource, params.id as string, 'DELETE', oldValues, null);
}
return result;
},
deleteMany: async (resource: string, params: DeleteManyParams) => {
// Fetch old values before delete
if (AUDITED_TABLES.includes(resource)) {
const { data: oldRecords } = await supabase
.from(resource)
.select('*')
.in('id', params.ids);
const result = await baseDataProvider.deleteMany(resource, params);
// Log each deletion
for (const record of oldRecords || []) {
await logAction(resource, record.id, 'DELETE', record, null);
}
return result;
}
return baseDataProvider.deleteMany(resource, params);
},
};
+108 -2
View File
@@ -1,6 +1,37 @@
import { AuthProvider } from 'react-admin';
import { supabase } from '../config';
// Define admin roles
export type AdminRole = 'pack_creator' | 'super_admin';
// Permission structure
export interface AdminPermissions {
role: AdminRole;
permissions: Array<{ action: string; resource: string }>;
}
// Define permissions by role
const ROLE_PERMISSIONS: Record<AdminRole, Array<{ action: string; resource: string }>> = {
pack_creator: [
{ action: 'list', resource: 'question_packs' },
{ action: 'create', resource: 'question_packs' },
{ action: 'edit', resource: 'question_packs' },
{ action: 'delete', resource: 'question_packs' },
{ action: 'show', resource: 'question_packs' },
{ action: 'list', resource: 'questions' },
{ action: 'create', resource: 'questions' },
{ action: 'edit', resource: 'questions' },
{ action: 'delete', resource: 'questions' },
{ action: 'show', resource: 'questions' },
],
super_admin: [
// Full access to all resources
{ action: '*', resource: '*' },
],
};
const PERMISSIONS_KEY = 'admin_permissions';
export const authProvider: AuthProvider = {
login: async ({ username, password }) => {
const { data, error } = await supabase.auth.signInWithPassword({
@@ -10,21 +41,96 @@ export const authProvider: AuthProvider = {
if (error) {
throw new Error(error.message);
}
// Fetch admin role
const { data: adminData, error: adminError } = await supabase
.from('admin_users')
.select('role')
.eq('user_id', data.user.id)
.single();
if (adminError || !adminData) {
await supabase.auth.signOut();
throw new Error('Access denied. You are not an administrator.');
}
// Store permissions
const permissions: AdminPermissions = {
role: adminData.role as AdminRole,
permissions: ROLE_PERMISSIONS[adminData.role as AdminRole],
};
localStorage.setItem(PERMISSIONS_KEY, JSON.stringify(permissions));
return Promise.resolve();
},
logout: async () => {
localStorage.removeItem(PERMISSIONS_KEY);
await supabase.auth.signOut();
return Promise.resolve();
},
checkError: async (error) => {
if (error.status === 401 || error.status === 403) {
localStorage.removeItem(PERMISSIONS_KEY);
return Promise.reject();
}
return Promise.resolve();
},
checkAuth: async () => {
const session = await supabase.auth.getSession();
return session.data.session ? Promise.resolve() : Promise.reject();
const permissions = localStorage.getItem(PERMISSIONS_KEY);
if (!session.data.session || !permissions) {
return Promise.reject();
}
return Promise.resolve();
},
getPermissions: async () => {
const permissions = localStorage.getItem(PERMISSIONS_KEY);
if (!permissions) {
return Promise.reject();
}
return JSON.parse(permissions) as AdminPermissions;
},
canAccess: async ({ resource, action }) => {
const permissionsStr = localStorage.getItem(PERMISSIONS_KEY);
if (!permissionsStr) {
return false;
}
const { permissions } = JSON.parse(permissionsStr) as AdminPermissions;
// Check for wildcard (super_admin)
if (permissions.some(p => p.action === '*' && p.resource === '*')) {
return true;
}
// Check specific permission
return permissions.some(
p => p.resource === resource && (p.action === action || p.action === '*')
);
},
getIdentity: async () => {
const { data } = await supabase.auth.getUser();
if (!data.user) {
throw new Error('No user');
}
const permissionsStr = localStorage.getItem(PERMISSIONS_KEY);
const role = permissionsStr
? (JSON.parse(permissionsStr) as AdminPermissions).role
: 'unknown';
return {
id: data.user.id,
fullName: data.user.email,
avatar: undefined,
role,
};
},
getPermissions: () => Promise.resolve(),
};
+18 -20
View File
@@ -1,22 +1,20 @@
import { createClient } from '@supabase/supabase-js';
import { supabaseConfig } from '../config';
export const supabase = createClient(
supabaseConfig.url,
supabaseConfig.anonKey
);
// Custom data provider wrapper if needed for ra-data-supabase
import { supabaseDataProvider } from 'ra-supabase';
import { supabase } from '../config';
export const dataProvider = supabaseDataProvider(
supabase,
{
resources: {
question_packs: 'question_packs',
questions: 'questions',
profiles: 'profiles',
couples: 'couples'
}
}
);
export { supabase };
const resources = {
question_packs: ['id', 'name', 'description', 'icon', 'is_premium', 'is_public', 'sort_order', 'created_at'],
questions: ['id', 'pack_id', 'text', 'partner_text', 'intensity', 'created_at'],
profiles: ['id', 'name', 'avatar_url', 'is_premium', 'couple_id', 'created_at'],
couples: ['id', 'invite_code', 'created_at'],
responses: ['id', 'user_id', 'question_id', 'couple_id', 'answer', 'created_at'],
matches: ['id', 'couple_id', 'question_id', 'match_type', 'is_new', 'created_at'],
messages: ['id', 'match_id', 'user_id', 'content', 'read_at', 'created_at'],
subscriptions: ['id', 'user_id', 'status', 'product_id', 'expires_at', 'created_at'],
feedback: ['id', 'user_id', 'type', 'title', 'description', 'status', 'created_at'],
audit_logs: ['id', 'table_name', 'record_id', 'action', 'old_values', 'new_values', 'changed_fields', 'admin_user_id', 'admin_role', 'created_at'],
admin_users: ['id', 'user_id', 'role', 'created_at'],
};
export const dataProvider = supabaseDataProvider(supabase, resources);
+64
View File
@@ -0,0 +1,64 @@
import {
List,
Datagrid,
TextField,
DateField,
FunctionField,
SelectInput,
TextInput,
DateInput,
} from 'react-admin';
const auditLogFilters = [
<TextInput key="table" label="Table" source="table_name" alwaysOn />,
<SelectInput
key="action"
source="action"
choices={[
{ id: 'INSERT', name: 'Create' },
{ id: 'UPDATE', name: 'Update' },
{ id: 'DELETE', name: 'Delete' },
]}
/>,
<DateInput key="from" source="created_at@gte" label="From Date" />,
<DateInput key="to" source="created_at@lte" label="To Date" />,
];
export const AuditLogList = () => (
<List
filters={auditLogFilters}
sort={{ field: 'created_at', order: 'DESC' }}
>
<Datagrid bulkActionButtons={false}>
<DateField source="created_at" showTime label="Timestamp" />
<TextField source="table_name" label="Table" />
<FunctionField
label="Action"
render={(record: { action: string }) => {
const actionMap: Record<string, string> = {
INSERT: 'Create',
UPDATE: 'Update',
DELETE: 'Delete',
};
return actionMap[record.action] || record.action;
}}
/>
<TextField source="record_id" label="Record ID" />
<TextField source="admin_role" label="Role" />
<FunctionField
label="Changed Fields"
render={(record: { changed_fields?: string[] }) =>
record.changed_fields?.join(', ') || '-'
}
/>
<FunctionField
label="Summary"
render={(record: { action: string; changed_fields?: string[] }) => {
if (record.action === 'INSERT') return 'New record created';
if (record.action === 'DELETE') return 'Record deleted';
return `${record.changed_fields?.length || 0} fields changed`;
}}
/>
</Datagrid>
</List>
);
+21
View File
@@ -0,0 +1,21 @@
import {
List,
Datagrid,
TextField,
DateField,
TextInput,
} from 'react-admin';
const coupleFilters = [
<TextInput key="invite" label="Invite Code" source="invite_code" alwaysOn />,
];
export const CoupleList = () => (
<List filters={coupleFilters}>
<Datagrid bulkActionButtons={false}>
<TextField source="id" label="Couple ID" />
<TextField source="invite_code" label="Invite Code" />
<DateField source="created_at" label="Created" />
</Datagrid>
</List>
);
+59
View File
@@ -0,0 +1,59 @@
import {
Show,
SimpleShowLayout,
TextField,
DateField,
ReferenceField,
ReferenceManyField,
List,
Datagrid,
BooleanField,
Labeled,
} from 'react-admin';
export const MatchShow = () => (
<Show>
<SimpleShowLayout>
<TextField source="id" label="Match ID" />
<ReferenceField source="question_id" reference="questions" label="Question">
<TextField source="text" />
</ReferenceField>
<TextField source="match_type" />
<BooleanField source="is_new" label="Unread" />
<DateField source="created_at" />
<Labeled label="Chat Messages" fullWidth>
<ReferenceManyField
reference="messages"
target="match_id"
sort={{ field: 'created_at', order: 'ASC' }}
>
<Datagrid bulkActionButtons={false}>
<ReferenceField source="user_id" reference="profiles" link={false}>
<TextField source="name" />
</ReferenceField>
<TextField source="content" label="Message" />
<DateField source="created_at" showTime label="Sent" />
<DateField source="read_at" showTime label="Read" emptyText="-" />
</Datagrid>
</ReferenceManyField>
</Labeled>
</SimpleShowLayout>
</Show>
);
export const MatchList = () => (
<List>
<Datagrid rowClick="show" bulkActionButtons={false}>
<ReferenceField source="couple_id" reference="couples" link={false}>
<TextField source="id" />
</ReferenceField>
<ReferenceField source="question_id" reference="questions">
<TextField source="text" />
</ReferenceField>
<TextField source="match_type" />
<BooleanField source="is_new" label="Unread" />
<DateField source="created_at" />
</Datagrid>
</List>
);
+25
View File
@@ -0,0 +1,25 @@
import {
List,
Datagrid,
TextField,
DateField,
ReferenceField,
TextInput,
} from 'react-admin';
const messageFilters = [
<TextInput key="content" label="Search content" source="content@ilike" alwaysOn />,
];
export const MessageList = () => (
<List filters={messageFilters} sort={{ field: 'created_at', order: 'DESC' }}>
<Datagrid bulkActionButtons={false}>
<ReferenceField source="user_id" reference="profiles" label="Sender">
<TextField source="name" />
</ReferenceField>
<TextField source="content" />
<DateField source="created_at" showTime label="Sent At" />
<DateField source="read_at" showTime label="Read At" />
</Datagrid>
</List>
);
+84 -5
View File
@@ -1,12 +1,29 @@
import { List, Datagrid, TextField, BooleanField, DateField, ReferenceField } from 'react-admin';
import {
List,
Datagrid,
TextField,
BooleanField,
DateField,
ReferenceField,
Edit,
SimpleForm,
TextInput,
BooleanInput,
ReferenceInput,
SelectInput,
Show,
SimpleShowLayout,
ReferenceManyField,
useRecordContext,
TabbedShowLayout,
} from 'react-admin';
export const ProfileList = () => (
<List>
<Datagrid>
<Datagrid rowClick="show">
<TextField source="name" />
<TextField source="email" />
<BooleanField source="is_premium" />
<ReferenceField source="couple_id" reference="couples">
<ReferenceField source="couple_id" reference="couples" link={false}>
<TextField source="id" />
</ReferenceField>
<DateField source="created_at" />
@@ -14,9 +31,71 @@ export const ProfileList = () => (
</List>
);
export const ProfileShow = () => (
<Show>
<TabbedShowLayout>
<TabbedShowLayout.Tab label="Profile">
<TextField source="id" />
<TextField source="name" />
<BooleanField source="is_premium" />
<ReferenceField source="couple_id" reference="couples">
<TextField source="invite_code" />
</ReferenceField>
<DateField source="created_at" />
</TabbedShowLayout.Tab>
<TabbedShowLayout.Tab label="Matches & Chats">
<ReferenceManyField
label="Matches"
reference="matches"
target="couple_id"
source="couple_id"
>
<Datagrid rowClick="show" bulkActionButtons={false}>
<ReferenceField source="question_id" reference="questions">
<TextField source="text" />
</ReferenceField>
<TextField source="match_type" />
<BooleanField source="is_new" label="Unread" />
<DateField source="created_at" />
</Datagrid>
</ReferenceManyField>
</TabbedShowLayout.Tab>
<TabbedShowLayout.Tab label="Responses">
<ReferenceManyField
label="Responses"
reference="responses"
target="user_id"
>
<Datagrid bulkActionButtons={false}>
<ReferenceField source="question_id" reference="questions">
<TextField source="text" />
</ReferenceField>
<TextField source="answer" />
<DateField source="created_at" />
</Datagrid>
</ReferenceManyField>
</TabbedShowLayout.Tab>
</TabbedShowLayout>
</Show>
);
export const ProfileEdit = () => (
<Edit>
<SimpleForm>
<TextInput source="name" />
<BooleanInput source="is_premium" label="Premium Status" />
<ReferenceInput source="couple_id" reference="couples">
<SelectInput optionText="id" />
</ReferenceInput>
</SimpleForm>
</Edit>
);
export const ResponseList = () => (
<List>
<Datagrid>
<Datagrid bulkActionButtons={false}>
<ReferenceField source="user_id" reference="profiles">
<TextField source="name" />
</ReferenceField>
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+3
View File
@@ -6,5 +6,8 @@ export default defineConfig({
server: {
host: true,
port: 3001,
watch: {
usePolling: true,
},
},
});
@@ -0,0 +1,69 @@
-- Admin Role-Based Access Control
-- Creates admin_users table and helper functions for role management
-- Create admin role enum
CREATE TYPE public.admin_role AS ENUM ('pack_creator', 'super_admin');
-- Admin users table (tracks which auth.users are admins and their role)
CREATE TABLE public.admin_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE,
role public.admin_role NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index for quick lookups
CREATE INDEX idx_admin_users_user_id ON public.admin_users(user_id);
CREATE INDEX idx_admin_users_role ON public.admin_users(role);
-- Updated_at trigger
CREATE TRIGGER update_admin_users_updated_at
BEFORE UPDATE ON public.admin_users
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
-- Enable RLS
ALTER TABLE public.admin_users ENABLE ROW LEVEL SECURITY;
-- Policy: Allow admins to read their own record
CREATE POLICY "Admin users can read own record"
ON public.admin_users FOR SELECT
USING (auth.uid() = user_id);
-- Helper function to check if user is admin
CREATE OR REPLACE FUNCTION public.is_admin(check_user_id UUID DEFAULT auth.uid())
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM public.admin_users WHERE user_id = check_user_id
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
-- Helper function to get admin role
CREATE OR REPLACE FUNCTION public.get_admin_role(check_user_id UUID DEFAULT auth.uid())
RETURNS public.admin_role AS $$
DECLARE
user_role public.admin_role;
BEGIN
SELECT role INTO user_role FROM public.admin_users WHERE user_id = check_user_id;
RETURN user_role;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
-- Helper function to check if user is super admin
CREATE OR REPLACE FUNCTION public.is_super_admin(check_user_id UUID DEFAULT auth.uid())
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM public.admin_users
WHERE user_id = check_user_id AND role = 'super_admin'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
-- Grant execute permissions on helper functions
GRANT EXECUTE ON FUNCTION public.is_admin TO authenticated;
GRANT EXECUTE ON FUNCTION public.get_admin_role TO authenticated;
GRANT EXECUTE ON FUNCTION public.is_super_admin TO authenticated;
@@ -0,0 +1,81 @@
-- Audit Logging System
-- Creates audit_logs table and RPC function for logging admin actions
-- Create action type enum for audit logging
CREATE TYPE public.audit_action AS ENUM ('INSERT', 'UPDATE', 'DELETE');
-- Audit log table
CREATE TABLE public.audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
table_name TEXT NOT NULL,
record_id UUID NOT NULL,
action public.audit_action NOT NULL,
old_values JSONB,
new_values JSONB,
changed_fields TEXT[],
admin_user_id UUID NOT NULL REFERENCES auth.users(id),
admin_role public.admin_role NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes for querying audit logs
CREATE INDEX idx_audit_logs_table_name ON public.audit_logs(table_name);
CREATE INDEX idx_audit_logs_record_id ON public.audit_logs(record_id);
CREATE INDEX idx_audit_logs_admin_user_id ON public.audit_logs(admin_user_id);
CREATE INDEX idx_audit_logs_created_at ON public.audit_logs(created_at DESC);
CREATE INDEX idx_audit_logs_action ON public.audit_logs(action);
-- Enable RLS (super_admins only can view logs)
ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY;
-- Policy: Only super admins can view audit logs
CREATE POLICY "Super admins can view audit logs"
ON public.audit_logs FOR SELECT
USING (public.is_super_admin());
-- Function to log admin actions (called from frontend via RPC)
CREATE OR REPLACE FUNCTION public.log_admin_action(
p_table_name TEXT,
p_record_id UUID,
p_action public.audit_action,
p_old_values JSONB DEFAULT NULL,
p_new_values JSONB DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
log_id UUID;
admin_role_val public.admin_role;
changed_fields_arr TEXT[];
BEGIN
-- Get admin role
SELECT role INTO admin_role_val FROM public.admin_users WHERE user_id = auth.uid();
IF admin_role_val IS NULL THEN
RAISE EXCEPTION 'User is not an admin';
END IF;
-- Calculate changed fields for updates
IF p_action = 'UPDATE' AND p_old_values IS NOT NULL AND p_new_values IS NOT NULL THEN
SELECT ARRAY(
SELECT key FROM jsonb_each(p_new_values)
WHERE p_old_values->key IS DISTINCT FROM p_new_values->key
) INTO changed_fields_arr;
END IF;
-- Insert audit log
INSERT INTO public.audit_logs (
table_name, record_id, action, old_values, new_values,
changed_fields, admin_user_id, admin_role
)
VALUES (
p_table_name, p_record_id, p_action, p_old_values, p_new_values,
changed_fields_arr, auth.uid(), admin_role_val
)
RETURNING id INTO log_id;
RETURN log_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Grant execute permission on logging function
GRANT EXECUTE ON FUNCTION public.log_admin_action TO authenticated;
@@ -0,0 +1,79 @@
-- Admin RLS Policies
-- Adds policies for admin access to tables
-- Question Packs: All admins can manage (CRUD)
CREATE POLICY "Admins can view all question packs"
ON public.question_packs FOR SELECT
USING (public.is_admin());
CREATE POLICY "Admins can insert question packs"
ON public.question_packs FOR INSERT
WITH CHECK (public.is_admin());
CREATE POLICY "Admins can update question packs"
ON public.question_packs FOR UPDATE
USING (public.is_admin());
CREATE POLICY "Admins can delete question packs"
ON public.question_packs FOR DELETE
USING (public.is_admin());
-- Questions: All admins can manage (CRUD)
CREATE POLICY "Admins can view all questions"
ON public.questions FOR SELECT
USING (public.is_admin());
CREATE POLICY "Admins can insert questions"
ON public.questions FOR INSERT
WITH CHECK (public.is_admin());
CREATE POLICY "Admins can update questions"
ON public.questions FOR UPDATE
USING (public.is_admin());
CREATE POLICY "Admins can delete questions"
ON public.questions FOR DELETE
USING (public.is_admin());
-- Profiles: Super admins can view and update all
CREATE POLICY "Super admins can view all profiles"
ON public.profiles FOR SELECT
USING (public.is_super_admin());
CREATE POLICY "Super admins can update all profiles"
ON public.profiles FOR UPDATE
USING (public.is_super_admin());
-- Couples: Super admins can view all
CREATE POLICY "Super admins can view all couples"
ON public.couples FOR SELECT
USING (public.is_super_admin());
-- Responses: Super admins can view all
CREATE POLICY "Super admins can view all responses"
ON public.responses FOR SELECT
USING (public.is_super_admin());
-- Matches: Super admins can view all
CREATE POLICY "Super admins can view all matches"
ON public.matches FOR SELECT
USING (public.is_super_admin());
-- Messages: Super admins can view all
CREATE POLICY "Super admins can view all messages"
ON public.messages FOR SELECT
USING (public.is_super_admin());
-- Subscriptions: Super admins can view all
CREATE POLICY "Super admins can view all subscriptions"
ON public.subscriptions FOR SELECT
USING (public.is_super_admin());
-- Feedback: Super admins can view and manage all
CREATE POLICY "Super admins can view all feedback"
ON public.feedback FOR SELECT
USING (public.is_super_admin());
CREATE POLICY "Super admins can update all feedback"
ON public.feedback FOR UPDATE
USING (public.is_super_admin());