diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..1483715 --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,45 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { getAdminMe } from '@/lib/api/admin' +import { LoadingOverlay } from '@ciphera-net/ui' + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + const [isAdmin, setIsAdmin] = useState(null) + const router = useRouter() + + useEffect(() => { + getAdminMe() + .then((res) => { + if (res.is_admin) { + setIsAdmin(true) + } else { + setIsAdmin(false) + // Redirect to home if not admin + router.push('/') + } + }) + .catch(() => { + setIsAdmin(false) + router.push('/') + }) + }, [router]) + + if (isAdmin === null) { + return + } + + if (!isAdmin) { + return null // Will redirect + } + + return ( +
+
+

Pulse Admin

+
+ {children} +
+ ) +} diff --git a/app/admin/orgs/[id]/page.tsx b/app/admin/orgs/[id]/page.tsx new file mode 100644 index 0000000..741ad30 --- /dev/null +++ b/app/admin/orgs/[id]/page.tsx @@ -0,0 +1,239 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { getAdminOrg, grantPlan, type AdminOrgDetail } from '@/lib/api/admin' +import { Card, CardHeader, CardTitle, CardContent, Button, LoadingOverlay, Select, toast } from '@ciphera-net/ui' +import { format, addMonths, addYears } from 'date-fns' + +const PLAN_OPTIONS = [ + { value: 'free', label: 'Free' }, + { value: 'solo', label: 'Solo' }, + { value: 'team', label: 'Team' }, + { value: 'business', label: 'Business' }, +] + +const INTERVAL_OPTIONS = [ + { value: 'month', label: 'Monthly' }, + { value: 'year', label: 'Yearly' }, +] + +const LIMIT_OPTIONS = [ + { value: '1000', label: '1k (Free)' }, + { value: '10000', label: '10k (Solo)' }, + { value: '100000', label: '100k (Team)' }, + { value: '1000000', label: '1M (Business)' }, + { value: '5000000', label: '5M' }, + { value: '10000000', label: '10M' }, +] + +export default function AdminOrgDetailPage() { + const params = useParams() + const router = useRouter() + const orgId = params.id as string + + const [org, setOrg] = useState(null) + const [loading, setLoading] = useState(true) + const [submitting, setSubmitting] = useState(false) + + // Form state + const [planId, setPlanId] = useState('free') + const [interval, setInterval] = useState('month') + const [limit, setLimit] = useState('1000') + const [periodEnd, setPeriodEnd] = useState('') + + useEffect(() => { + if (orgId) { + getAdminOrg(orgId) + .then((data) => { + setOrg({ ...data.billing, sites: data.sites }) + setPlanId(data.billing.plan_id) + setInterval(data.billing.billing_interval || 'month') + setLimit(data.billing.pageview_limit.toString()) + + // Format date for input type="datetime-local" or similar + if (data.billing.current_period_end) { + setPeriodEnd(new Date(data.billing.current_period_end).toISOString().slice(0, 16)) + } else { + // Default to 1 month from now + setPeriodEnd(addMonths(new Date(), 1).toISOString().slice(0, 16)) + } + }) + .catch(() => { + toast.error('Failed to load organization') + router.push('/admin/orgs') + }) + .finally(() => setLoading(false)) + } + }, [orgId, router]) + + const handleGrantPlan = async (e: React.FormEvent) => { + e.preventDefault() + if (!org) return + + setSubmitting(true) + try { + await grantPlan(org.organization_id, { + plan_id: planId, + billing_interval: interval, + pageview_limit: parseInt(limit), + period_end: new Date(periodEnd).toISOString(), + }) + toast.success('Plan granted successfully') + router.refresh() + // Reload data to show updates + const data = await getAdminOrg(orgId) + setOrg({ ...data.billing, sites: data.sites }) + } catch (error) { + toast.error('Failed to grant plan') + } finally { + setSubmitting(false) + } + } + + if (loading) return + if (!org) return
Organization not found
+ + return ( +
+
+

+ {org.business_name || 'Unnamed Organization'} +

+ {org.organization_id} +
+ +
+ {/* Current Status */} + + + Current Status + + +
+ Plan: + {org.plan_id} + + Status: + {org.subscription_status} + + Limit: + {new Intl.NumberFormat().format(org.pageview_limit)} + + Interval: + {org.billing_interval} + + Period End: + + {org.current_period_end ? format(new Date(org.current_period_end), 'PPP p') : '-'} + + + Stripe Cust: + {org.stripe_customer_id || '-'} + + Stripe Sub: + {org.stripe_subscription_id || '-'} +
+
+
+ + {/* Sites */} + + + Sites ({org.sites.length}) + + +
    + {org.sites.map((site) => ( +
  • + {site.domain} + {format(new Date(site.created_at), 'MMM d, yyyy')} +
  • + ))} + {org.sites.length === 0 &&
  • No sites found
  • } +
+
+
+
+ + {/* Grant Plan Form */} + + + Grant Plan (Manual Override) + + +
+
+
+ + +
+ +
+ + setPeriodEnd(e.target.value)} + className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2" + required + /> +
+ + + +
+
+
+ +
+ +
+
+
+
+
+ ) +} diff --git a/app/admin/orgs/page.tsx b/app/admin/orgs/page.tsx new file mode 100644 index 0000000..06bff18 --- /dev/null +++ b/app/admin/orgs/page.tsx @@ -0,0 +1,89 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { listAdminOrgs, type AdminOrgSummary } from '@/lib/api/admin' +import { Card, CardHeader, CardTitle, CardContent, Button, LoadingOverlay } from '@ciphera-net/ui' +import { format } from 'date-fns' + +export default function AdminOrgsPage() { + const [orgs, setOrgs] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + listAdminOrgs() + .then(setOrgs) + .finally(() => setLoading(false)) + }, []) + + if (loading) { + return + } + + return ( +
+
+

Organizations

+
+ + + + All Organizations + + +
+ + + + + + + + + + + + + + {orgs.map((org) => ( + + + + + + + + + + ))} + +
Business NameOrg IDPlanStatusLimitUpdatedActions
+ {org.business_name || 'N/A'} + + {org.organization_id.substring(0, 8)}... + + + {org.plan_id} + + + {org.subscription_status || '-'} + + {new Intl.NumberFormat().format(org.pageview_limit)} + + {format(new Date(org.updated_at), 'MMM d, yyyy')} + + + + +
+
+
+
+
+ ) +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..5bcefab --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,24 @@ +'use client' + +import Link from 'next/link' +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@ciphera-net/ui' + +export default function AdminDashboard() { + return ( +
+ + + + Organizations + Manage organization plans and limits + + +

+ View all organizations, check billing status, and manually grant plans. +

+
+
+ +
+ ) +} diff --git a/lib/api/admin.ts b/lib/api/admin.ts new file mode 100644 index 0000000..01c009f --- /dev/null +++ b/lib/api/admin.ts @@ -0,0 +1,62 @@ +import { authFetch } from './client' + +export interface AdminOrgSummary { + organization_id: string + stripe_customer_id: string + stripe_subscription_id: string + plan_id: string + billing_interval: string + pageview_limit: number + subscription_status: string + current_period_end: string + business_name: string + last_payment_at?: string + created_at: string + updated_at: string +} + +export interface Site { + id: string + domain: string + name: string + created_at: string +} + +export interface AdminOrgDetail extends AdminOrgSummary { + sites: Site[] +} + +export interface GrantPlanParams { + plan_id: string + billing_interval: string + pageview_limit: number + period_end: string // ISO date string +} + +// Check if current user is admin +export async function getAdminMe(): Promise<{ is_admin: boolean }> { + try { + return await authFetch<{ is_admin: boolean }>('/api/admin/me') + } catch (e) { + return { is_admin: false } + } +} + +// List all organizations (admin view) +export async function listAdminOrgs(): Promise { + const data = await authFetch<{ organizations: AdminOrgSummary[] }>('/api/admin/orgs') + return data.organizations || [] +} + +// Get details for a specific organization +export async function getAdminOrg(orgId: string): Promise<{ billing: AdminOrgSummary; sites: Site[] }> { + return await authFetch<{ billing: AdminOrgSummary; sites: Site[] }>(`/api/admin/orgs/${orgId}`) +} + +// Grant a plan to an organization manually +export async function grantPlan(orgId: string, params: GrantPlanParams): Promise { + await authFetch(`/api/admin/orgs/${orgId}/grant-plan`, { + method: 'POST', + body: JSON.stringify(params), + }) +}