This commit is contained in:
jjbrunton committed 2025-12-30 11:09:03 +00:00
1 parent b10eae1bf3
commit a15089f607
20 files changed
+3557 -3645

No files matched your search

+3
View File
@@ -1,2 +1,5 @@
VITE_SUPABASE_URL=your_supabase_url VITE_SUPABASE_URL=your_supabase_url
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
VITE_OPENAI_API_KEY=your_openai_api_key
VITE_OPENROUTER_API_KEY=your_openrouter_api_key
VITE_AI_MODEL=openai/gpt-4o-mini
+3
View File
@@ -8,6 +8,9 @@ services:
environment: environment:
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL} - VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY} - VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
- VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY}
- VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY}
- VITE_AI_MODEL=${VITE_AI_MODEL}
volumes: volumes:
- ./src:/app/apps/admin/src - ./src:/app/apps/admin/src
- ./index.html:/app/apps/admin/index.html - ./index.html:/app/apps/admin/index.html
+25 -4
View File
@@ -2,23 +2,44 @@
"name": "@sauci/admin", "name": "@sauci/admin",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.39.0", "@radix-ui/react-avatar": "^1.0.4",
"ra-supabase": "^1.0.0", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@sauci/shared": "*", "@sauci/shared": "*",
"@supabase/supabase-js": "^2.39.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"date-fns": "^3.2.0",
"lucide-react": "^0.309.0",
"openai": "^4.24.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-admin": "^4.16.0", "react-dom": "^18.2.0",
"react-dom": "^18.2.0" "react-router-dom": "^6.21.0",
"recharts": "^3.6.0",
"sonner": "^1.3.1",
"tailwind-merge": "^2.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.0", "@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0", "@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0", "@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^5.0.0" "vite": "^5.0.0"
} }
+84 -72
View File
@@ -1,81 +1,93 @@
import { Admin, Resource } from "react-admin"; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { auditDataProvider } from "./providers/auditDataProvider"; import { AuthProvider } from '@/contexts/AuthContext';
import { PackList, PackEdit, PackCreate } from "./resources/packs"; import { ProtectedRoute } from '@/components/ProtectedRoute';
import { QuestionList, QuestionEdit, QuestionCreate } from "./resources/questions"; import { AppLayout } from '@/components/layout/AppLayout';
import { ProfileList, ProfileShow, ProfileEdit, ResponseList } from "./resources/users"; import { LoginPage } from '@/pages/LoginPage';
import { CoupleList } from "./resources/couples"; import { DashboardPage } from '@/pages/DashboardPage';
import { MatchList, MatchShow } from "./resources/matches"; import { CategoriesPage } from '@/pages/content/CategoriesPage';
import { AuditLogList } from "./resources/auditLogs"; import { PacksPage } from '@/pages/content/PacksPage';
import { authProvider, AdminPermissions } from "./providers/authProvider"; import { QuestionsPage } from '@/pages/content/QuestionsPage';
import { UsersPage } from '@/pages/users/UsersPage';
import { UserDetailPage } from '@/pages/users/UserDetailPage';
import { MatchChatPage } from '@/pages/users/MatchChatPage';
import { AuditLogsPage } from '@/pages/AuditLogsPage';
import { AdminsPage } from '@/pages/admins/AdminsPage';
const App = () => ( function App() {
<Admin return (
dataProvider={auditDataProvider} <BrowserRouter>
authProvider={authProvider} <AuthProvider>
requireAuth <Routes>
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
{/* Protected routes */}
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
> >
{(permissions: AdminPermissions) => ( {/* Dashboard */}
<> <Route path="/" element={<DashboardPage />} />
{/* Question Packs - accessible to all admins */}
<Resource {/* Content management */}
name="question_packs" <Route path="/categories" element={<CategoriesPage />} />
list={PackList} <Route path="/categories/:categoryId/packs" element={<PacksPage />} />
edit={PackEdit} <Route path="/packs/:packId/questions" element={<QuestionsPage />} />
create={PackCreate}
recordRepresentation="name" {/* User management (super_admin only) */}
options={{ label: 'Question Packs' }} <Route
path="/users"
element={
<ProtectedRoute requireSuperAdmin>
<UsersPage />
</ProtectedRoute>
}
/>
<Route
path="/users/:userId"
element={
<ProtectedRoute requireSuperAdmin>
<UserDetailPage />
</ProtectedRoute>
}
/>
<Route
path="/users/:userId/matches/:matchId"
element={
<ProtectedRoute requireSuperAdmin>
<MatchChatPage />
</ProtectedRoute>
}
/> />
{/* Questions - accessible to all admins */} {/* System (super_admin only) */}
<Resource <Route
name="questions" path="/admins"
list={QuestionList} element={
edit={QuestionEdit} <ProtectedRoute requireSuperAdmin>
create={QuestionCreate} <AdminsPage />
options={{ label: 'Questions' }} </ProtectedRoute>
}
/> />
<Route
path="/audit-logs"
element={
<ProtectedRoute requireSuperAdmin>
<AuditLogsPage />
</ProtectedRoute>
}
/>
</Route>
{/* Super admin only resources */} {/* Catch all - redirect to dashboard */}
{permissions?.role === 'super_admin' && ( <Route path="*" element={<Navigate to="/" replace />} />
<> </Routes>
<Resource </AuthProvider>
name="profiles" </BrowserRouter>
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>
); );
}
export default App; export default App;
+6 -5
View File
@@ -1,9 +1,10 @@
import React from 'react' import React from 'react';
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client';
import App from './App' import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </React.StrictMode>
) );
-136
View File
@@ -1,136 +0,0 @@
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({
email: username,
password,
});
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();
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,
};
},
};
+15 -3
View File
@@ -2,7 +2,11 @@
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
@@ -14,7 +18,15 @@
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
}, },
"include": ["src"] "include": [
"src"
]
} }
+6
View File
@@ -1,8 +1,14 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: { server: {
host: true, host: true,
port: 3001, port: 3001,
+1 -1
View File
@@ -2,7 +2,7 @@ EXPO_PUBLIC_SUPABASE_URL=your_supabase_url
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
# Sentry error tracking (get DSN from https://sentry.io) # Sentry error tracking (get DSN from https://sentry.io)
EXPO_PUBLIC_SENTRY_DSN=https://03a91f8a1b1ecdce2d5f38a4c23ca9d5@o4510613894070272.ingest.de.sentry.io/4510613896560720 EXPO_PUBLIC_SENTRY_DSN=https://your-public-key@your-org.ingest.sentry.io/your-project-id
# RevenueCat (for Apple subscriptions) # RevenueCat (for Apple subscriptions)
# Get your API key from https://app.revenuecat.com # Get your API key from https://app.revenuecat.com
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-all.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+3 -1
View File
@@ -63,7 +63,9 @@
"organization": "molten-prints", "organization": "molten-prints",
"project": "sauci" "project": "sauci"
} }
] ],
"@sentry/react-native",
"expo-font"
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true
+5 -3
View File
@@ -85,8 +85,10 @@ export default function PairingScreen() {
}; };
const handleJoinCouple = async () => { const handleJoinCouple = async () => {
if (inviteCode.length < 8) { // Validate invite code format (8 alphanumeric characters)
Alert.alert("Error", "Please enter a valid invite code"); const sanitizedCode = inviteCode.trim().toUpperCase();
if (!/^[A-Z0-9]{8}$/.test(sanitizedCode)) {
Alert.alert("Error", "Please enter a valid 8-character invite code");
return; return;
} }
@@ -100,7 +102,7 @@ export default function PairingScreen() {
const { data, error } = await supabase.functions.invoke("manage-couple", { const { data, error } = await supabase.functions.invoke("manage-couple", {
method: "POST", method: "POST",
body: { invite_code: inviteCode }, body: { invite_code: sanitizedCode },
}); });
if (error) throw error; if (error) throw error;
+2 -2
View File
@@ -190,8 +190,8 @@ export default function LoginScreen() {
showError("Please enter your password"); showError("Please enter your password");
return; return;
} }
if (isSignUp && password.length < 6) { if (isSignUp && password.length < 8) {
showError("Password must be at least 6 characters"); showError("Password must be at least 8 characters");
return; return;
} }
+57
View File
@@ -45,6 +45,7 @@ export default function PackDetailsScreen() {
{item.partner_text && ( {item.partner_text && (
<Text style={styles.partnerText}>Partner role: {item.partner_text}</Text> <Text style={styles.partnerText}>Partner role: {item.partner_text}</Text>
)} )}
<View style={styles.footerContainer}>
<View style={styles.intensityContainer}> <View style={styles.intensityContainer}>
{[...Array(5)].map((_, index) => ( {[...Array(5)].map((_, index) => (
<Ionicons <Ionicons
@@ -56,6 +57,41 @@ export default function PackDetailsScreen() {
/> />
))} ))}
</View> </View>
{item.allowed_couple_genders && item.allowed_couple_genders.length > 0 ? (
<View style={styles.targetsContainer}>
{item.allowed_couple_genders.map((g) => {
const icons = [];
if (g === 'male+male') {
icons.push('male', 'male');
} else if (g === 'female+female') {
icons.push('female', 'female');
} else {
icons.push('male', 'female');
}
return (
<View key={g} style={styles.targetBadge}>
{icons.map((icon, i) => (
<Ionicons
key={i}
name={icon as any}
size={10}
color="#ccc"
style={i > 0 ? { marginLeft: 2 } : {}}
/>
))}
</View>
);
})}
</View>
) : (
<View style={styles.targetsContainer}>
<View style={styles.targetBadge}>
<Ionicons name="people" size={10} color="#ccc" />
</View>
</View>
)}
</View>
</View> </View>
); );
@@ -188,4 +224,25 @@ const styles = StyleSheet.create({
fontStyle: "italic", fontStyle: "italic",
marginBottom: 8, marginBottom: 8,
}, },
footerContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 8,
},
targetsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 4,
},
targetBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.2)',
},
}); });
+206 -446
View File
File diff suppressed because it is too large. Load diff
+8 -7
View File
@@ -13,12 +13,12 @@
}, },
"dependencies": { "dependencies": {
"@expo/ngrok": "^4.1.0", "@expo/ngrok": "^4.1.0",
"@expo/vector-icons": "^14.0.0", "@expo/vector-icons": "~14.0.4",
"@react-native-async-storage/async-storage": "^2.2.0", "@react-native-async-storage/async-storage": "1.23.1",
"@react-navigation/bottom-tabs": "^7.0.0", "@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0", "@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0", "@react-navigation/native-stack": "^7.0.0",
"@sentry/react-native": "^7.8.0", "@sentry/react-native": "~6.10.0",
"@supabase/supabase-js": "^2.39.0", "@supabase/supabase-js": "^2.39.0",
"@tanstack/react-query": "^5.17.0", "@tanstack/react-query": "^5.17.0",
"base64-arraybuffer": "^1.0.2", "base64-arraybuffer": "^1.0.2",
@@ -29,25 +29,26 @@
"expo-constants": "~17.0.0", "expo-constants": "~17.0.0",
"expo-dev-client": "~5.0.20", "expo-dev-client": "~5.0.20",
"expo-file-system": "~18.0.12", "expo-file-system": "~18.0.12",
"expo-font": "~13.0.4",
"expo-haptics": "~14.0.1", "expo-haptics": "~14.0.1",
"expo-image-picker": "~16.0.6", "expo-image-picker": "~16.0.6",
"expo-linear-gradient": "~14.0.2", "expo-linear-gradient": "~14.0.2",
"expo-linking": "~7.0.0", "expo-linking": "~7.0.0",
"expo-local-authentication": "^17.0.8", "expo-local-authentication": "~15.0.2",
"expo-notifications": "~0.29.0", "expo-notifications": "~0.29.0",
"expo-router": "~4.0.0", "expo-router": "~4.0.0",
"expo-secure-store": "~14.0.0", "expo-secure-store": "~14.0.0",
"expo-status-bar": "~2.0.0", "expo-status-bar": "~2.0.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "0.76.5", "react-native": "0.76.9",
"react-native-gesture-handler": "~2.20.0", "react-native-gesture-handler": "~2.20.0",
"react-native-purchases": "^9.6.12", "react-native-purchases": "^9.6.12",
"react-native-reanimated": "~3.16.0", "react-native-reanimated": "~3.16.0",
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.1.0", "react-native-screens": "~4.4.0",
"react-native-url-polyfill": "^2.0.0", "react-native-url-polyfill": "^2.0.0",
"react-native-web": "^0.21.2", "react-native-web": "~0.19.13",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },
"devDependencies": { "devDependencies": {
-3
View File
@@ -50,9 +50,6 @@ const ExpoStorageAdapter = {
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!; const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!;
// DEBUG: Log which key is being used
console.log("DEBUG - Anon key preview:", supabaseAnonKey?.substring(0, 30) + "...");
export const supabase = createClient(supabaseUrl, supabaseAnonKey, { export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: { auth: {
storage: ExpoStorageAdapter, storage: ExpoStorageAdapter,
+1
View File
@@ -56,6 +56,7 @@ export interface Question {
text: string; text: string;
partner_text?: string | null; partner_text?: string | null;
intensity: 1 | 2 | 3 | 4 | 5; intensity: 1 | 2 | 3 | 4 | 5;
allowed_couple_genders?: string[] | null;
created_at: string; created_at: string;
} }
+17 -9
View File
@@ -66,20 +66,28 @@ Deno.serve(async (req) => {
// Join existing couple // Join existing couple
const { invite_code }: JoinCoupleBody = body; const { invite_code }: JoinCoupleBody = body;
// Find couple by invite code // Validate invite code format (alphanumeric, 8 chars)
const { data: couple, error: coupleError } = await supabase const sanitizedCode = invite_code.trim().toUpperCase();
.from("couples") if (!/^[A-Z0-9]{8}$/.test(sanitizedCode)) {
.select("id")
.eq("invite_code", invite_code.toLowerCase())
.single();
if (coupleError || !couple) {
return new Response( return new Response(
JSON.stringify({ error: "Invalid invite code" }), JSON.stringify({ error: "Invalid invite code format" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Use secure RPC function for invite code verification
const { data: verifyResult, error: verifyError } = await supabase
.rpc("verify_invite_code", { code: sanitizedCode });
if (verifyError || !verifyResult?.valid) {
return new Response(
JSON.stringify({ error: verifyResult?.error || "Invalid invite code" }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } } { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
); );
} }
const couple = { id: verifyResult.couple_id };
// Check if couple already has 2 members // Check if couple already has 2 members
const { count } = await supabase const { count } = await supabase
.from("profiles") .from("profiles")
+3114 -2952
View File
File diff suppressed because it is too large. Load diff