[Feat] Pulse Admin Dashboard - manually grant plans to organizations
- Add admin layout with /api/admin/me check and access denial - Add admin pages: dashboard, orgs list, org detail with grant form - Add lib/api/admin.ts client for admin endpoints Made-with: Cursor
This commit is contained in:
45
app/admin/layout.tsx
Normal file
45
app/admin/layout.tsx
Normal file
@@ -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<boolean | null>(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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Checking access..." />
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return null // Will redirect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Pulse Admin</h1>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
239
app/admin/orgs/[id]/page.tsx
Normal file
239
app/admin/orgs/[id]/page.tsx
Normal file
@@ -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<AdminOrgDetail | null>(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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Loading organization..." />
|
||||
if (!org) return <div>Organization not found</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
{org.business_name || 'Unnamed Organization'}
|
||||
</h2>
|
||||
<span className="text-sm font-mono text-neutral-500">{org.organization_id}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Current Status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Current Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<span className="text-neutral-500">Plan:</span>
|
||||
<span className="font-medium">{org.plan_id}</span>
|
||||
|
||||
<span className="text-neutral-500">Status:</span>
|
||||
<span className="font-medium">{org.subscription_status}</span>
|
||||
|
||||
<span className="text-neutral-500">Limit:</span>
|
||||
<span className="font-medium">{new Intl.NumberFormat().format(org.pageview_limit)}</span>
|
||||
|
||||
<span className="text-neutral-500">Interval:</span>
|
||||
<span className="font-medium">{org.billing_interval}</span>
|
||||
|
||||
<span className="text-neutral-500">Period End:</span>
|
||||
<span className="font-medium">
|
||||
{org.current_period_end ? format(new Date(org.current_period_end), 'PPP p') : '-'}
|
||||
</span>
|
||||
|
||||
<span className="text-neutral-500">Stripe Cust:</span>
|
||||
<span className="font-mono text-xs">{org.stripe_customer_id || '-'}</span>
|
||||
|
||||
<span className="text-neutral-500">Stripe Sub:</span>
|
||||
<span className="font-mono text-xs">{org.stripe_subscription_id || '-'}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sites */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sites ({org.sites.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{org.sites.map((site) => (
|
||||
<li key={site.id} className="flex justify-between items-center text-sm p-2 bg-neutral-50 dark:bg-neutral-900 rounded">
|
||||
<span className="font-medium">{site.domain}</span>
|
||||
<span className="text-neutral-500 text-xs">{format(new Date(site.created_at), 'MMM d, yyyy')}</span>
|
||||
</li>
|
||||
))}
|
||||
{org.sites.length === 0 && <li className="text-neutral-500 text-sm">No sites found</li>}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Grant Plan Form */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Grant Plan (Manual Override)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleGrantPlan} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Plan Tier</label>
|
||||
<Select
|
||||
value={planId}
|
||||
onChange={setPlanId}
|
||||
options={PLAN_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Billing Interval</label>
|
||||
<Select
|
||||
value={interval}
|
||||
onChange={setInterval}
|
||||
options={INTERVAL_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Pageview Limit</label>
|
||||
<Select
|
||||
value={limit}
|
||||
onChange={setLimit}
|
||||
options={LIMIT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Period End Date (UTC)</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={periodEnd}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodEnd(addMonths(new Date(), 1).toISOString().slice(0, 16))}
|
||||
className="text-xs text-blue-500 hover:underline"
|
||||
>
|
||||
+1 Month
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodEnd(addYears(new Date(), 1).toISOString().slice(0, 16))}
|
||||
className="text-xs text-blue-500 hover:underline"
|
||||
>
|
||||
+1 Year
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodEnd(addYears(new Date(), 100).toISOString().slice(0, 16))}
|
||||
className="text-xs text-blue-500 hover:underline"
|
||||
>
|
||||
Forever
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end">
|
||||
<Button type="submit" disabled={submitting} variant="primary">
|
||||
{submitting ? 'Granting...' : 'Grant Plan'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
app/admin/orgs/page.tsx
Normal file
89
app/admin/orgs/page.tsx
Normal file
@@ -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<AdminOrgSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
listAdminOrgs()
|
||||
.then(setOrgs)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Loading organizations..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">Organizations</h2>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Organizations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-neutral-200 dark:border-neutral-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Business Name</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Org ID</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Plan</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Status</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Limit</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Updated</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{orgs.map((org) => (
|
||||
<tr key={org.organization_id} className="hover:bg-neutral-50 dark:hover:bg-neutral-900/50">
|
||||
<td className="px-4 py-3 text-neutral-900 dark:text-white font-medium">
|
||||
{org.business_name || 'N/A'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-500 font-mono text-xs">
|
||||
{org.organization_id.substring(0, 8)}...
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
org.plan_id === 'business' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' :
|
||||
org.plan_id === 'team' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
|
||||
org.plan_id === 'solo' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
|
||||
'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-400'
|
||||
}`}>
|
||||
{org.plan_id}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-300">
|
||||
{org.subscription_status || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-300">
|
||||
{new Intl.NumberFormat().format(org.pageview_limit)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-500 text-xs">
|
||||
{format(new Date(org.updated_at), 'MMM d, yyyy')}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/admin/orgs/${org.organization_id}`}>
|
||||
<Button variant="ghost" size="sm">Manage</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
app/admin/page.tsx
Normal file
24
app/admin/page.tsx
Normal file
@@ -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 (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Link href="/admin/orgs" className="block transition-transform hover:scale-[1.02]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Organizations</CardTitle>
|
||||
<CardDescription>Manage organization plans and limits</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
View all organizations, check billing status, and manually grant plans.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
lib/api/admin.ts
Normal file
62
lib/api/admin.ts
Normal file
@@ -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<AdminOrgSummary[]> {
|
||||
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<void> {
|
||||
await authFetch(`/api/admin/orgs/${orgId}/grant-plan`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user