From 3fe20a4b1b707edc309d868f9e839671ee45f07f Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 25 Feb 2026 21:58:23 +0100 Subject: [PATCH 1/8] [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 --- app/admin/layout.tsx | 45 +++++++ app/admin/orgs/[id]/page.tsx | 239 +++++++++++++++++++++++++++++++++++ app/admin/orgs/page.tsx | 89 +++++++++++++ app/admin/page.tsx | 24 ++++ lib/api/admin.ts | 62 +++++++++ 5 files changed, 459 insertions(+) create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/orgs/[id]/page.tsx create mode 100644 app/admin/orgs/page.tsx create mode 100644 app/admin/page.tsx create mode 100644 lib/api/admin.ts 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), + }) +} From 30b450cdb643ffb7da2fb7a63436ac101fd45c34 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 25 Feb 2026 22:02:53 +0100 Subject: [PATCH 2/8] Fix admin dashboard build: remove date-fns, replace Card with native divs, fix Button props Made-with: Cursor --- app/admin/orgs/[id]/page.tsx | 60 +++++++++++++++++++----------------- app/admin/orgs/page.tsx | 25 ++++++++------- app/admin/page.tsx | 22 ++++++------- 3 files changed, 53 insertions(+), 54 deletions(-) diff --git a/app/admin/orgs/[id]/page.tsx b/app/admin/orgs/[id]/page.tsx index 741ad30..3039798 100644 --- a/app/admin/orgs/[id]/page.tsx +++ b/app/admin/orgs/[id]/page.tsx @@ -3,8 +3,24 @@ 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' +import { Button, LoadingOverlay, Select, toast } from '@ciphera-net/ui' + +function formatDate(d: Date) { + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +} +function formatDateTime(d: Date) { + return d.toLocaleDateString('en-US', { dateStyle: 'long' }) + ' ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }) +} +function addMonths(d: Date, months: number) { + const out = new Date(d) + out.setMonth(out.getMonth() + months) + return out +} +function addYears(d: Date, years: number) { + const out = new Date(d) + out.setFullYear(out.getFullYear() + years) + return out +} const PLAN_OPTIONS = [ { value: 'free', label: 'Free' }, @@ -105,12 +121,9 @@ export default function AdminOrgDetailPage() {
{/* Current Status */} - - - Current Status - - -
+
+

Current Status

+
Plan: {org.plan_id} @@ -125,7 +138,7 @@ export default function AdminOrgDetailPage() { Period End: - {org.current_period_end ? format(new Date(org.current_period_end), 'PPP p') : '-'} + {org.current_period_end ? formatDateTime(new Date(org.current_period_end)) : '-'} Stripe Cust: @@ -133,35 +146,27 @@ export default function AdminOrgDetailPage() { Stripe Sub: {org.stripe_subscription_id || '-'} -
- - +
+
{/* Sites */} - - - Sites ({org.sites.length}) - - +
+

Sites ({org.sites.length})

    {org.sites.map((site) => (
  • {site.domain} - {format(new Date(site.created_at), 'MMM d, yyyy')} + {formatDate(new Date(site.created_at))}
  • ))} {org.sites.length === 0 &&
  • No sites found
  • } -
- - + +
{/* Grant Plan Form */} - - - Grant Plan (Manual Override) - - +
+

Grant Plan (Manual Override)

@@ -232,8 +237,7 @@ export default function AdminOrgDetailPage() {
- - +
) } diff --git a/app/admin/orgs/page.tsx b/app/admin/orgs/page.tsx index 06bff18..01ac987 100644 --- a/app/admin/orgs/page.tsx +++ b/app/admin/orgs/page.tsx @@ -3,8 +3,11 @@ 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' +import { Button, LoadingOverlay } from '@ciphera-net/ui' + +function formatDate(d: Date) { + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +} export default function AdminOrgsPage() { const [orgs, setOrgs] = useState([]) @@ -26,12 +29,9 @@ export default function AdminOrgsPage() {

Organizations

- - - All Organizations - - -
+
+

All Organizations

+
@@ -70,20 +70,19 @@ export default function AdminOrgsPage() { {new Intl.NumberFormat().format(org.pageview_limit)} ))}
- {format(new Date(org.updated_at), 'MMM d, yyyy')} + {formatDate(new Date(org.updated_at))} - +
-
- - +
+
) } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 5bcefab..02050a0 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,23 +1,19 @@ '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. -

-
-
+ +

Organizations

+

Manage organization plans and limits

+

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

) From 72745bd41ac0794d5544400b539170e70c9ba6c5 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 25 Feb 2026 22:18:21 +0100 Subject: [PATCH 3/8] [Fix] Admin organizations list - document org visibility fix Made-with: Cursor --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e1f285..98e5ee5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- **Admin organizations list.** Organizations that created a site but never subscribed now appear in the admin list. Previously only orgs with a billing row were shown. - **Sign in after inactivity.** Clicking "Sign in" after a period of inactivity no longer does nothing. Previously, stale refresh cookies caused the middleware to redirect away from the login page; now only a valid access token triggers that redirect, so you can complete OAuth sign-in when your session has expired. - **Frequent re-login.** You no longer have to sign in multiple times a day. When the access token expires after 15 minutes of inactivity, the app now automatically refreshes it using your refresh token on the next page load, so you stay logged in for up to 30 days. - **2FA disable now requires password confirmation.** Disabling 2FA sends the derived password to the backend for verification. This prevents an attacker with a hijacked session from stripping 2FA. From c89d9ce48529a029dbc70c8945cc99548b39963a Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 26 Feb 2026 12:16:07 +0100 Subject: [PATCH 4/8] fix: add click-to-copy for org ID, show site name fallback in admin list Made-with: Cursor --- app/admin/orgs/page.tsx | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/app/admin/orgs/page.tsx b/app/admin/orgs/page.tsx index 01ac987..288107d 100644 --- a/app/admin/orgs/page.tsx +++ b/app/admin/orgs/page.tsx @@ -1,14 +1,34 @@ 'use client' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import Link from 'next/link' import { listAdminOrgs, type AdminOrgSummary } from '@/lib/api/admin' -import { Button, LoadingOverlay } from '@ciphera-net/ui' +import { Button, LoadingOverlay, toast } from '@ciphera-net/ui' function formatDate(d: Date) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) } +function CopyableOrgId({ id }: { id: string }) { + const [copied, setCopied] = useState(false) + const copy = useCallback(() => { + navigator.clipboard.writeText(id) + setCopied(true) + toast.success('Org ID copied to clipboard') + setTimeout(() => setCopied(false), 2000) + }, [id]) + return ( + + ) +} + export default function AdminOrgsPage() { const [orgs, setOrgs] = useState([]) const [loading, setLoading] = useState(true) @@ -35,7 +55,7 @@ export default function AdminOrgsPage() { - + @@ -50,8 +70,8 @@ export default function AdminOrgsPage() { -
Business NameName Org ID Plan Status {org.business_name || 'N/A'} - {org.organization_id.substring(0, 8)}... + + Date: Thu, 26 Feb 2026 12:18:32 +0100 Subject: [PATCH 5/8] style: update admin layout for improved responsiveness --- app/admin/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 1483715..a777657 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -35,7 +35,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) } return ( -
+

Pulse Admin

From 1edd78672e82702e3d9de73503134900f31c7567 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 26 Feb 2026 14:26:54 +0100 Subject: [PATCH 6/8] fix: treat session-flow callback (no state) as valid when coming from auth hub Made-with: Cursor --- app/auth/callback/page.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx index 1e69707..2359b75 100644 --- a/app/auth/callback/page.tsx +++ b/app/auth/callback/page.tsx @@ -56,12 +56,15 @@ function AuthCallbackContent() { const storedState = localStorage.getItem('oauth_state') const codeVerifier = localStorage.getItem('oauth_code_verifier') - // * Full OAuth flow (app-initiated): validate state + use PKCE - // * Session-authorized flow (from auth hub): no stored state or verifier - const isFullOAuth = !!storedState && !!codeVerifier - - if (isFullOAuth) { - if (state !== storedState) { + // * Session flow (from auth hub): redirect has code but no state. Clear stale PKCE + // * data from any previous app-initiated OAuth so exchange proceeds without validation. + if (!state) { + localStorage.removeItem('oauth_state') + localStorage.removeItem('oauth_code_verifier') + } else { + // * Full OAuth flow (app-initiated): validate state + use PKCE + const isFullOAuth = !!storedState && !!codeVerifier + if (isFullOAuth && state !== storedState) { logger.error('State mismatch', { received: state, stored: storedState }) setError('Invalid state') return From 86cc27a10ccaafc56d7e74c1d16ccb17a2ad3462 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 26 Feb 2026 14:40:42 +0100 Subject: [PATCH 7/8] fix: resolve OAuth session flow issue when opening Pulse from the Ciphera hub --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98e5ee5..726edc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- **Opening Pulse from the Ciphera hub.** Clicking Pulse on the auth apps page (auth.ciphera.net/apps) now signs you in correctly instead of showing "Invalid state". Previously, leftover OAuth data from a past login attempt could block the session flow; the callback now detects redirects from the hub (no `state` in the URL), clears stale PKCE storage, and completes token exchange. - **Admin organizations list.** Organizations that created a site but never subscribed now appear in the admin list. Previously only orgs with a billing row were shown. - **Sign in after inactivity.** Clicking "Sign in" after a period of inactivity no longer does nothing. Previously, stale refresh cookies caused the middleware to redirect away from the login page; now only a valid access token triggers that redirect, so you can complete OAuth sign-in when your session has expired. - **Frequent re-login.** You no longer have to sign in multiple times a day. When the access token expires after 15 minutes of inactivity, the app now automatically refreshes it using your refresh token on the next page load, so you stay logged in for up to 30 days. From f994141d64d7f480477f7eaa230171dc3b87087a Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 26 Feb 2026 14:44:24 +0100 Subject: [PATCH 8/8] fix: improve Pulse tracking script for embedded sites like Shopify and WooCommerce --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 726edc0..fbfa26a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- **Shopify and embedded site tracking.** The Pulse tracking script now loads correctly when embedded on third-party sites like Shopify stores, WooCommerce, or custom storefronts. Previously, tracking failed because the script was redirected to the login page instead of loading. - **Opening Pulse from the Ciphera hub.** Clicking Pulse on the auth apps page (auth.ciphera.net/apps) now signs you in correctly instead of showing "Invalid state". Previously, leftover OAuth data from a past login attempt could block the session flow; the callback now detects redirects from the hub (no `state` in the URL), clears stale PKCE storage, and completes token exchange. - **Admin organizations list.** Organizations that created a site but never subscribed now appear in the admin list. Previously only orgs with a billing row were shown. - **Sign in after inactivity.** Clicking "Sign in" after a period of inactivity no longer does nothing. Previously, stale refresh cookies caused the middleware to redirect away from the login page; now only a valid access token triggers that redirect, so you can complete OAuth sign-in when your session has expired.