mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Update
This commit is contained in:
1 parent
b10eae1bf3
commit
a15089f607
20 files changed
+3557
-3645
No files matched your search
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
@@ -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>
|
||||||
)
|
);
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
Generated
+206
-446
File diff suppressed because it is too large.
Load diff
@@ -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": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Generated
+3114
-2952
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user