diff --git a/_infra/cloud_functions.tf b/_infra/cloud_functions.tf index 6ba511c..919a53d 100644 --- a/_infra/cloud_functions.tf +++ b/_infra/cloud_functions.tf @@ -281,6 +281,13 @@ data "archive_file" "cancel_stripe_subscription_zip" { excludes = ["node_modules"] } +data "archive_file" "admin_api_zip" { + type = "zip" + source_dir = "${path.root}/../functions/admin-api" + output_path = "${path.root}/tmp/admin-api.zip" + excludes = ["node_modules"] +} + # Coach Builder function ZIP archives data "archive_file" "coach_content_processor_zip" { type = "zip" @@ -367,6 +374,12 @@ resource "google_storage_bucket_object" "cancel_stripe_subscription_source" { source = data.archive_file.cancel_stripe_subscription_zip.output_path } +resource "google_storage_bucket_object" "admin_api_source" { + name = "admin-api-${data.archive_file.admin_api_zip.output_md5}.zip" + bucket = google_storage_bucket.function_bucket.name + source = data.archive_file.admin_api_zip.output_path +} + # Coach Builder function sources resource "google_storage_bucket_object" "coach_content_processor_source" { name = "coach-content-processor-${data.archive_file.coach_content_processor_zip.output_md5}.zip" @@ -571,6 +584,29 @@ module "cancel_stripe_subscription_function" { depends_on = [google_storage_bucket_object.cancel_stripe_subscription_source] } +module "admin_api_function" { + source = "./modules/cloud_function" + + name = "admin-api" + description = "Admin API for user management and chat logs" + region = var.region + bucket_name = google_storage_bucket.function_bucket.name + source_object = google_storage_bucket_object.admin_api_source.name + entry_point = "adminApi" + + environment_variables = { + PROJECT_ID = var.project_id + SUPABASE_URL = var.supabase_url + SUPABASE_SERVICE_ROLE_KEY = var.supabase_service_role_key + CONVERSATION_BUCKET_NAME = var.conversation_bucket_name + ADMIN_EMAILS = var.admin_emails + ADMIN_PHONES = var.admin_phones + ALLOWED_ORIGINS = var.allowed_origins + } + + depends_on = [google_storage_bucket_object.admin_api_source] +} + # Coach Builder Cloud Functions module "coach_content_processor_function" { source = "./modules/cloud_function" @@ -738,6 +774,13 @@ resource "google_cloud_run_service_iam_member" "cancel_stripe_subscription_invok member = "allUsers" } +resource "google_cloud_run_service_iam_member" "admin_api_invoker" { + location = module.admin_api_function.function.location + service = module.admin_api_function.function.name + role = "roles/run.invoker" + member = "allUsers" +} + # Coach Builder function invokers resource "google_cloud_run_service_iam_member" "coach_content_processor_invoker" { location = module.coach_content_processor_function.function.location diff --git a/_infra/terraform.tfvars.example b/_infra/terraform.tfvars.example index c2820cd..53153c5 100644 --- a/_infra/terraform.tfvars.example +++ b/_infra/terraform.tfvars.example @@ -33,6 +33,10 @@ stripe_price_id = "price_your-stripe-price-id" # CORS Configuration allowed_origins = "http://localhost:5173,https://your-domain.com" +# Admin Dashboard Configuration +admin_emails = "admin@example.com" +admin_phones = "+15551234567" + # Storage Configuration conversation_bucket_name = "user-conversations" conversation_bucket_location = "US" diff --git a/_infra/variables.tf b/_infra/variables.tf index 71394fe..ecdbc91 100644 --- a/_infra/variables.tf +++ b/_infra/variables.tf @@ -72,6 +72,18 @@ variable "allowed_origins" { default = "http://localhost:5173" } +variable "admin_emails" { + description = "Comma-separated list of admin email addresses allowed to use the admin dashboard" + type = string + default = "" +} + +variable "admin_phones" { + description = "Comma-separated list of admin phone numbers (E.164) allowed to use the admin dashboard" + type = string + default = "" +} + variable "openai_api_key" { description = "API key for OpenAI" type = string diff --git a/functions/admin-api/index.js b/functions/admin-api/index.js new file mode 100644 index 0000000..1a0bd3d --- /dev/null +++ b/functions/admin-api/index.js @@ -0,0 +1,188 @@ +const functions = require('@google-cloud/functions-framework'); +const cors = require('cors')({ origin: true }); +const { Storage } = require('@google-cloud/storage'); +const { createClient } = require('@supabase/supabase-js'); + +// Initialize clients +const storage = new Storage(); +const projectId = process.env.PROJECT_ID || process.env.GOOGLE_CLOUD_PROJECT; +const conversationBucketName = `${projectId}-${process.env.CONVERSATION_BUCKET_NAME}`; + +const supabaseUrl = process.env.SUPABASE_URL; +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; +const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey); + +// Admin authorization: require a valid Supabase session token whose email is in ADMIN_EMAILS +async function requireAdmin(req, res) { + try { + const authHeader = req.headers['authorization'] || ''; + const token = authHeader.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : null; + if (!token) { + res.status(401).json({ error: 'Missing Authorization header' }); + return null; + } + + const { data, error } = await supabaseAdmin.auth.getUser(token); + if (error || !data || !data.user) { + res.status(401).json({ error: 'Invalid token' }); + return null; + } + + const adminEmails = (process.env.ADMIN_EMAILS || '').split(',').map((e) => e.trim().toLowerCase()).filter(Boolean); + const adminPhones = (process.env.ADMIN_PHONES || '').split(',').map((e) => e.trim()).filter(Boolean); + const userEmail = (data.user.email || '').toLowerCase(); + const userPhone = data.user.phone || data.user.phone_number || ''; + if (!adminEmails.includes(userEmail) && !adminPhones.includes(userPhone)) { + res.status(403).json({ error: 'Forbidden' }); + return null; + } + + return data.user; + } catch (err) { + console.error('Admin auth error:', err); + res.status(500).json({ error: 'Auth error' }); + return null; + } +} + +async function listUsers(req, res) { + const { page = '1', pageSize = '50', search = '' } = req.query; + const pageNum = Math.max(parseInt(page, 10) || 1, 1); + const sizeNum = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200); + const from = (pageNum - 1) * sizeNum; + const to = from + sizeNum - 1; + + let query = supabaseAdmin + .from('user_profiles') + .select(`phone_number, full_name, spice_level, coach, coach_type, custom_coach_id, image_preference, email, active, created_at, updated_at, subscription:subscriptions!user_phone(status, trial_start_timestamp)`, { count: 'exact' }) + .order('created_at', { ascending: false }) + .range(from, to); + + if (search) { + // Very simple search across email, phone, name + query = query.or( + `email.ilike.%${search}%,phone_number.ilike.%${search}%,full_name.ilike.%${search}%` + ); + } + + const { data, error, count } = await query; + if (error) { + console.error('Error listing users:', error); + res.status(500).json({ error: 'Failed to list users' }); + return; + } + + const results = (data || []).map((u) => ({ + ...u, + subscriptions: u.subscription ? [u.subscription] : [], + })); + + res.json({ users: results, total: count || 0, page: pageNum, pageSize: sizeNum }); +} + +async function getUserDetail(req, res, phone) { + const { data, error } = await supabaseAdmin + .from('user_profiles') + .select(`phone_number, full_name, spice_level, coach, coach_type, custom_coach_id, image_preference, email, active, created_at, updated_at, subscription:subscriptions!user_phone(status, trial_start_timestamp)`) + .eq('phone_number', phone) + .single(); + + if (error || !data) { + console.error('Error getting user:', error); + res.status(404).json({ error: 'User not found' }); + return; + } + + res.json({ + ...data, + subscriptions: data.subscription ? [data.subscription] : [], + }); +} + +async function updateUser(req, res, phone) { + const allowed = ['full_name', 'spice_level', 'coach', 'coach_type', 'custom_coach_id', 'image_preference', 'active']; + const payload = {}; + for (const key of allowed) { + if (key in req.body) payload[key] = req.body[key]; + } + + if (Object.keys(payload).length === 0) { + res.status(400).json({ error: 'No valid fields to update' }); + return; + } + + const { data, error } = await supabaseAdmin + .from('user_profiles') + .update(payload) + .eq('phone_number', phone) + .select() + .single(); + + if (error) { + console.error('Error updating user:', error); + res.status(500).json({ error: 'Failed to update user' }); + return; + } + + res.json(data); +} + +async function getChat(req, res, phone) { + try { + const file = storage.bucket(conversationBucketName).file(`${phone}/conversation.json`); + const [exists] = await file.exists(); + if (!exists) { + res.json({ conversation: [] }); + return; + } + const [content] = await file.download(); + const conversation = JSON.parse(content.toString()); + res.json({ conversation }); + } catch (err) { + console.error('Error reading conversation:', err); + res.status(500).json({ error: 'Failed to load conversation' }); + } +} + +functions.http('adminApi', (req, res) => { + return cors(req, res, async () => { + // Basic router + const user = await requireAdmin(req, res); + if (!user) return; // response already sent + + const method = req.method.toUpperCase(); + const url = new URL(req.url, 'https://example.com'); + const path = url.pathname || '/'; + + try { + if (method === 'GET' && path === '/users') { + await listUsers(req, res); + return; + } + + if (path.startsWith('/users/')) { + const rest = path.slice('/users/'.length); + const [phone, sub] = rest.split('/'); + if (method === 'GET' && !sub) { + await getUserDetail(req, res, phone); + return; + } + if (method === 'PATCH' && !sub) { + await updateUser(req, res, phone); + return; + } + if (method === 'GET' && sub === 'chat') { + await getChat(req, res, phone); + return; + } + } + + res.status(404).json({ error: 'Not found' }); + } catch (err) { + console.error('Unhandled adminApi error:', err); + res.status(500).json({ error: 'Internal error' }); + } + }); +}); + + diff --git a/functions/admin-api/package.json b/functions/admin-api/package.json new file mode 100644 index 0000000..4dba818 --- /dev/null +++ b/functions/admin-api/package.json @@ -0,0 +1,14 @@ +{ + "name": "admin-api", + "version": "1.0.0", + "description": "Admin API for managing users and conversations", + "main": "index.js", + "dependencies": { + "@google-cloud/functions-framework": "^3.4.0", + "@supabase/supabase-js": "^2.48.1", + "cors": "^2.8.5", + "@google-cloud/storage": "^7.12.1" + } +} + + diff --git a/supabase/migrations/20250815120001_add_is_admin_to_user_profiles.sql b/supabase/migrations/20250815120001_add_is_admin_to_user_profiles.sql new file mode 100644 index 0000000..af25dbb --- /dev/null +++ b/supabase/migrations/20250815120001_add_is_admin_to_user_profiles.sql @@ -0,0 +1,11 @@ +-- Add is_admin flag to user_profiles to control admin access +ALTER TABLE public.user_profiles + ADD COLUMN IF NOT EXISTS is_admin boolean DEFAULT false NOT NULL; + +-- Helpful index if you ever query admins +CREATE INDEX IF NOT EXISTS user_profiles_is_admin_idx ON public.user_profiles(is_admin); + +COMMENT ON COLUMN public.user_profiles.is_admin IS 'Grants access to admin dashboard and admin-only features when true'; + + + diff --git a/webapp/sample.env b/webapp/sample.env index 06d10ef..7ebb284 100644 --- a/webapp/sample.env +++ b/webapp/sample.env @@ -12,4 +12,9 @@ VITE_API_URL= VITE_COACH_CONTENT_PROCESSOR_URL=https://us-central1-cabo-446722.cloudfunctions.net/coach-content-processor VITE_COACH_RESPONSE_GENERATOR_URL=https://us-central1-cabo-446722.cloudfunctions.net/coach-response-generator VITE_COACH_FILE_UPLOADER_URL=https://us-central1-cabo-446722.cloudfunctions.net/coach-file-uploader -VITE_COACH_FILE_UPLOADER_CONFIRM_URL=https://us-central1-cabo-446722.cloudfunctions.net/coach-file-uploader-confirm \ No newline at end of file +VITE_COACH_FILE_UPLOADER_CONFIRM_URL=https://us-central1-cabo-446722.cloudfunctions.net/coach-file-uploader-confirm + +# Function base URL (used to derive admin API URL) +VITE_GCP_FUNCTION_BASE_URL=https://us-central1-cabo-446722.cloudfunctions.net +VITE_ADMIN_EMAILS=admin@example.com +VITE_ADMIN_PHONES=+15551234567 \ No newline at end of file diff --git a/webapp/src/App.jsx b/webapp/src/App.jsx index d3b8a0b..85649cb 100644 --- a/webapp/src/App.jsx +++ b/webapp/src/App.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Routes, Route } from 'react-router-dom'; import { loadStripe } from '@stripe/stripe-js'; -import { toast } from 'react-hot-toast'; +import { Toaster, toast } from 'react-hot-toast'; import { supabase } from './main'; import { LoginPage } from './components/auth/LoginPage'; import { AuthenticatedLayout } from './components/layout/AuthenticatedLayout'; @@ -24,6 +24,8 @@ import CoachDashboard from './components/MyCoaches/CoachDashboard'; import CoachContentManager from './components/MyCoaches/CoachContentManager'; import CoachEdit from './components/MyCoaches/CoachEdit'; import CoachAvatarEdit from './components/MyCoaches/CoachAvatarEdit'; +import AdminDashboard from './components/AdminDashboard'; +import { AdminProtectedRoute } from './components/AdminProtectedRoute'; const stripePromise = loadStripe(STRIPE_PUBLIC_KEY); @@ -89,6 +91,8 @@ export function App() { }, [urlParams]); return ( + <> + } /> } /> @@ -114,6 +118,7 @@ export function App() { } /> } /> } /> + } /> } /> + > ); } \ No newline at end of file diff --git a/webapp/src/components/AdminDashboard.jsx b/webapp/src/components/AdminDashboard.jsx new file mode 100644 index 0000000..cf3c8ec --- /dev/null +++ b/webapp/src/components/AdminDashboard.jsx @@ -0,0 +1,244 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { toast } from 'react-hot-toast'; +import { supabase } from '../main'; + +const FUNCTIONS_BASE_URL = import.meta.env.VITE_GCP_FUNCTION_BASE_URL || import.meta.env.VITE_GCP_FUNCTIONS_URL; // e.g. https://us-central1-.cloudfunctions.net +const ADMIN_API_URL = FUNCTIONS_BASE_URL ? `${FUNCTIONS_BASE_URL}/admin-api` : null; + +function useAdminToken() { + const [token, setToken] = useState(null); + useEffect(() => { + let mounted = true; + supabase.auth.getSession().then(({ data }) => { + if (!mounted) return; + setToken(data?.session?.access_token || null); + }); + const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { + setToken(session?.access_token || null); + }); + return () => subscription.unsubscribe(); + }, []); + return token; +} + +function Table({ columns, rows, onRowClick }) { + return ( + + + + + {columns.map((c) => ( + + {c.label} + + ))} + + + + {rows.map((r) => ( + onRowClick?.(r)}> + {columns.map((c) => ( + + {c.render ? c.render(r[c.key], r) : r[c.key]} + + ))} + + ))} + + + + ); +} + +export default function AdminDashboard() { + const token = useAdminToken(); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [pageSize] = useState(25); + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [selectedUser, setSelectedUser] = useState(null); + const [chat, setChat] = useState([]); + const [saving, setSaving] = useState(false); + + const columns = useMemo(() => ([ + { key: 'full_name', label: 'Name' }, + { key: 'email', label: 'Email' }, + { key: 'phone_number', label: 'Phone' }, + { key: 'coach', label: 'Coach' }, + { key: 'spice_level', label: 'Spice' }, + { key: 'image_preference', label: 'Image Pref' }, + { key: 'active', label: 'Active', render: (v) => (v ? 'Yes' : 'No') }, + { key: 'subscription_status', label: 'Sub Status', render: (_v, row) => row.subscriptions?.[0]?.status || '' }, + ]), []); + + async function callAdmin(path, options = {}) { + if (!ADMIN_API_URL) throw new Error('VITE_GCP_FUNCTION_BASE_URL (or VITE_GCP_FUNCTIONS_URL) is not set'); + const headers = { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + const res = await fetch(`${ADMIN_API_URL}${path}`, { ...options, headers }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } + return res.json(); + } + + async function loadUsers() { + if (!token) return; + setLoading(true); + try { + const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); + if (search) params.set('search', search); + const data = await callAdmin(`/users?${params.toString()}`); + setUsers(data.users || []); + setTotal(data.total || 0); + } catch (e) { + console.error(e); + toast.error('Failed to load users'); + } finally { + setLoading(false); + } + } + + useEffect(() => { + if (token) { + loadUsers(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token, page, pageSize]); + + async function openUser(u) { + setSelectedUser(u); + setChat([]); + try { + const detail = await callAdmin(`/users/${encodeURIComponent(u.phone_number)}`); + setSelectedUser(detail); + const chatResp = await callAdmin(`/users/${encodeURIComponent(u.phone_number)}/chat`); + setChat(chatResp.conversation || []); + } catch (e) { + console.error(e); + toast.error('Failed to load user details'); + } + } + + async function saveUser() { + if (!selectedUser) return; + setSaving(true); + try { + const payload = { + full_name: selectedUser.full_name, + spice_level: selectedUser.spice_level, + coach: selectedUser.coach, + coach_type: selectedUser.coach_type, + custom_coach_id: selectedUser.custom_coach_id, + image_preference: selectedUser.image_preference, + active: selectedUser.active, + }; + await callAdmin(`/users/${encodeURIComponent(selectedUser.phone_number)}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); + toast.success('Saved'); + loadUsers(); + } catch (e) { + console.error(e); + toast.error('Save failed'); + } finally { + setSaving(false); + } + } + + return ( + + Admin Dashboard + + setSearch(e.target.value)} + placeholder="Search name, email, or phone" + className="border px-3 py-2 rounded w-80" + /> + { setPage(1); loadUsers(); }} className="bg-blue-600 text-white px-4 py-2 rounded">Search + + + {loading ? ( + Loading... + ) : ( + + )} + + + setPage((p) => Math.max(1, p - 1))} className="px-3 py-1 border rounded disabled:opacity-50">Prev + Page {page} of {Math.max(1, Math.ceil(total / pageSize))} + = Math.ceil(total / pageSize)} onClick={() => setPage((p) => p + 1)} className="px-3 py-1 border rounded disabled:opacity-50">Next + + + {selectedUser && ( + + + User Details + + Full Name + setSelectedUser({ ...selectedUser, full_name: e.target.value })} className="border px-2 py-1 rounded" /> + + Phone + + + Email + + + Coach + setSelectedUser({ ...selectedUser, coach: e.target.value })} className="border px-2 py-1 rounded" /> + + Coach Type + setSelectedUser({ ...selectedUser, coach_type: e.target.value })} className="border px-2 py-1 rounded"> + predefined + custom + + + Custom Coach ID + setSelectedUser({ ...selectedUser, custom_coach_id: e.target.value })} className="border px-2 py-1 rounded" /> + + Spice Level + setSelectedUser({ ...selectedUser, spice_level: e.target.valueAsNumber })} className="border px-2 py-1 rounded" /> + + Image Preference + setSelectedUser({ ...selectedUser, image_preference: e.target.value })} className="border px-2 py-1 rounded" /> + + Active + setSelectedUser({ ...selectedUser, active: e.target.checked })} /> + + + {saving ? 'Saving...' : 'Save'} + setSelectedUser(null)} className="px-4 py-2 rounded border">Close + + + + + Chat History + + {chat.length === 0 ? ( + No messages + ) : ( + + {chat.map((m, idx) => ( + + {m.timestamp} — {m.role} + {m.content} + + ))} + + )} + + + + )} + + ); +} + + diff --git a/webapp/src/components/AdminProtectedRoute.jsx b/webapp/src/components/AdminProtectedRoute.jsx new file mode 100644 index 0000000..2abc31b --- /dev/null +++ b/webapp/src/components/AdminProtectedRoute.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; + +function isAdminUser(session) { + if (!session || !session.user) return false; + const adminEmails = (import.meta.env.VITE_ADMIN_EMAILS || '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + const adminPhones = (import.meta.env.VITE_ADMIN_PHONES || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + const email = (session.user.email || '').toLowerCase(); + const phone = session.user.phone || session.user.phone_number || ''; + return (email && adminEmails.includes(email)) || (phone && adminPhones.includes(phone)); +} + +export const AdminProtectedRoute = ({ session, children }) => { + const location = useLocation(); + if (!session) { + return ; + } + if (!isAdminUser(session)) { + return ; + } + return children; +}; + + + + diff --git a/webapp/src/components/layout/AuthenticatedLayout.jsx b/webapp/src/components/layout/AuthenticatedLayout.jsx index ec941fd..b422e7b 100644 --- a/webapp/src/components/layout/AuthenticatedLayout.jsx +++ b/webapp/src/components/layout/AuthenticatedLayout.jsx @@ -2,6 +2,27 @@ import React from 'react'; import { Link, Outlet } from 'react-router-dom'; import { supabase } from '../../main'; +function isAdminUser(session) { + if (!session || !session.user) return false; + // Prefer DB flag via JWT claim if available + const jwt = session.user; + const isAdminClaim = (jwt.user_metadata && jwt.user_metadata.is_admin) || (jwt.app_metadata && jwt.app_metadata.is_admin); + if (isAdminClaim === true) return true; + + // Fallback to env allowlist (emails/phones) if DB flag not available + const adminEmails = (import.meta.env.VITE_ADMIN_EMAILS || '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + const adminPhones = (import.meta.env.VITE_ADMIN_PHONES || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const email = (session.user.email || '').toLowerCase(); + const phone = session.user.phone || session.user.phone_number || ''; + return (email && adminEmails.includes(email)) || (phone && adminPhones.includes(phone)); +} + export const AuthenticatedLayout = ({ session }) => { return ( @@ -13,6 +34,7 @@ export const AuthenticatedLayout = ({ session }) => { Settings Billing Coaches + {isAdminUser(session) && Admin} Logged in as: {session.user.email || session.user.phone}