Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions _infra/cloud_functions.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions _infra/terraform.tfvars.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions _infra/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
188 changes: 188 additions & 0 deletions functions/admin-api/index.js
Original file line number Diff line number Diff line change
@@ -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' });
}
});
});


14 changes: 14 additions & 0 deletions functions/admin-api/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}


Original file line number Diff line number Diff line change
@@ -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';



7 changes: 6 additions & 1 deletion webapp/sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
8 changes: 7 additions & 1 deletion webapp/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);

Expand Down Expand Up @@ -89,6 +91,8 @@ export function App() {
}, [urlParams]);

return (
<>
<Toaster position="top-center" />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/coaches" element={<HeroCoachPage />} />
Expand All @@ -114,6 +118,7 @@ export function App() {
<Route path="/my-coaches/:coachId/content" element={<CoachContentManager />} />
<Route path="/my-coaches/:coachId/edit" element={<CoachEdit />} />
<Route path="/my-coaches/:coachId/avatar" element={<CoachAvatarEdit />} />
<Route path="/admin" element={<AdminProtectedRoute session={session}><AdminDashboard /></AdminProtectedRoute>} />
</Route>

<Route path="/*" element={
Expand All @@ -135,5 +140,6 @@ export function App() {
/>
} />
</Routes>
</>
);
}
Loading