diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d1fc5..c4adb0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,8 +33,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed -- **Seamless sign-in from the Ciphera portal.** When you click "Sign in" on Pulse and authenticate through the Ciphera Auth portal, you now return to Pulse fully logged in without any loading loops or error messages. Previously, you might see console errors or have to sign in twice—now the handoff between apps is smooth and reliable. -- **Sign in after inactivity.** Clicking "Sign in" after a period of inactivity no longer does nothing. Previously, you could get stuck in a redirect loop and never reach the login page; now you can always complete sign-in, even when your session has expired. +- **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. - **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. - **More accurate visitor tracking.** We fixed rare edge cases where visitor counts could be slightly off during busy traffic spikes. Previously, the timestamp-based session ID generation could occasionally create overlapping identifiers. Every visitor now gets a truly unique UUID that never overlaps with others, ensuring your analytics are always precise. diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..a777657 --- /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..3039798 --- /dev/null +++ b/app/admin/orgs/[id]/page.tsx @@ -0,0 +1,243 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { getAdminOrg, grantPlan, type AdminOrgDetail } from '@/lib/api/admin' +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' }, + { 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 ? formatDateTime(new Date(org.current_period_end)) : '-'} + + + Stripe Cust: + {org.stripe_customer_id || '-'} + + Stripe Sub: + {org.stripe_subscription_id || '-'} +
+
+ + {/* Sites */} +
+

Sites ({org.sites.length})

+
    + {org.sites.map((site) => ( +
  • + {site.domain} + {formatDate(new Date(site.created_at))} +
  • + ))} + {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..288107d --- /dev/null +++ b/app/admin/orgs/page.tsx @@ -0,0 +1,108 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import Link from 'next/link' +import { listAdminOrgs, type AdminOrgSummary } from '@/lib/api/admin' +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) + + useEffect(() => { + listAdminOrgs() + .then(setOrgs) + .finally(() => setLoading(false)) + }, []) + + if (loading) { + return + } + + return ( +
+
+

Organizations

+
+ +
+

All Organizations

+
+ + + + + + + + + + + + + + {orgs.map((org) => ( + + + + + + + + + + ))} + +
NameOrg IDPlanStatusLimitUpdatedActions
+ {org.business_name || 'N/A'} + + + + + {org.plan_id} + + + {org.subscription_status || '-'} + + {new Intl.NumberFormat().format(org.pageview_limit)} + + {formatDate(new Date(org.updated_at))} + + + + +
+
+
+
+ ) +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..02050a0 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,20 @@ +'use client' + +import Link from 'next/link' + +export default function AdminDashboard() { + return ( +
+ +

Organizations

+

Manage organization plans and limits

+

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

+ +
+ ) +} 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 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), + }) +}