commit 8e10a05eb1dea66355d9ae1fd1612e10badc390e Author: Usman Baig Date: Fri Jan 16 13:14:19 2026 +0100 Initial commit: Analytics frontend implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45c1abc --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..61e8f5a --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Analytics Frontend + +[![License: Proprietary](https://img.shields.io/badge/License-Proprietary-red.svg)](#) +[![Built with Next.js](https://img.shields.io/badge/Built%20with-Next.js-blue.svg?logo=next.js&logoColor=white)](https://nextjs.org/) + +Analytics Frontend is the dashboard interface for Ciphera Analytics. It provides a simple, intuitive interface for managing sites and viewing analytics data. + +## Features + +- **Privacy-First Dashboard**: Simple, clean interface for viewing analytics +- **Site Management**: Create, edit, and delete sites +- **Real-time Stats**: Live visitor counts and real-time updates +- **Analytics Views**: Pageviews, visitors, top pages, referrers, countries +- **Dark Mode**: Full dark mode support +- **Responsive Design**: Works on desktop and mobile + +## Technology Stack + +- **Framework**: Next.js 16+ (App Router) +- **Styling**: Tailwind CSS with Ciphera design tokens +- **Charts**: Recharts for data visualization +- **Authentication**: OAuth flow with ciphera-auth +- **UI Components**: @ciphera-net/ui for shared components + +## Getting Started + +### Prerequisites + +- Node.js 18+ +- npm or yarn + +### Installation + +1. Install dependencies: +```bash +npm install +``` + +2. Set up environment variables (create `.env.local` file): +```env +NEXT_PUBLIC_API_URL=http://localhost:8082 +NEXT_PUBLIC_AUTH_URL=http://localhost:3000 +NEXT_PUBLIC_AUTH_API_URL=http://localhost:8081 +NEXT_PUBLIC_APP_URL=http://localhost:3003 +``` + +3. Run the development server: +```bash +npm run dev +``` + +4. Open [http://localhost:3003](http://localhost:3003) in your browser. + +### Build for Production + +```bash +npm run build +npm start +``` + +## Design System + +The frontend follows the Ciphera design language: + +- **Brand Color**: Orange (#FD5E0F) - used as accent only +- **Neutral Colors**: Full scale (50-900) for UI elements +- **Dark Mode**: Full support with class-based switching +- **Font**: Plus Jakarta Sans +- **Design Patterns**: + - Rounded corners (rounded-xl, rounded-3xl) + - Smooth transitions (duration-200, duration-300) + - Shadow effects with brand-orange accents + +## License + +Proprietary - All rights reserved diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx new file mode 100644 index 0000000..c09f8e2 --- /dev/null +++ b/app/auth/callback/page.tsx @@ -0,0 +1,139 @@ +'use client' + +import { useEffect, useState, Suspense, useRef } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { useAuth } from '@/lib/auth/context' +import { AUTH_URL } from '@/lib/api/client' + +function AuthCallbackContent() { + const router = useRouter() + const searchParams = useSearchParams() + const { login } = useAuth() + const [error, setError] = useState(null) + const processedRef = useRef(false) + + useEffect(() => { + if (processedRef.current) return + + const token = searchParams.get('token') + const refreshToken = searchParams.get('refresh_token') + + if (token && refreshToken) { + processedRef.current = true + try { + const payload = JSON.parse(atob(token.split('.')[1])) + login(token, refreshToken, { + id: payload.sub, + email: payload.email || 'user@ciphera.net', + totp_enabled: payload.totp_enabled || false + }) + const returnTo = searchParams.get('returnTo') || '/' + router.push(returnTo) + } catch (e) { + setError('Invalid token received') + } + return + } + + const code = searchParams.get('code') + const state = searchParams.get('state') + + if (!code || !state) return + + processedRef.current = true + + const storedState = localStorage.getItem('oauth_state') + const codeVerifier = localStorage.getItem('oauth_code_verifier') + + if (state !== storedState) { + console.error('State mismatch', { received: state, stored: storedState }) + setError('Invalid state') + return + } + + const exchangeCode = async () => { + try { + const authApiUrl = process.env.NEXT_PUBLIC_AUTH_API_URL || 'http://localhost:8081' + const res = await fetch(`${authApiUrl}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code, + client_id: 'analytics-app', + redirect_uri: window.location.origin + '/auth/callback', + code_verifier: codeVerifier, + }), + }) + + if (!res.ok) { + const data = await res.json() + throw new Error(data.error || 'Failed to exchange token') + } + + const data = await res.json() + + const payload = JSON.parse(atob(data.access_token.split('.')[1])) + + login(data.access_token, data.refresh_token, { + id: payload.sub, + email: payload.email || 'user@ciphera.net', + totp_enabled: payload.totp_enabled || false + }) + + localStorage.removeItem('oauth_state') + localStorage.removeItem('oauth_code_verifier') + + router.push('/') + } catch (err: any) { + setError(err.message) + } + } + + exchangeCode() + }, [searchParams, login, router]) + + if (error) { + return ( +
+
+ Error: {error} +
+ +
+
+
+ ) + } + + return ( +
+
+
+

Completing sign in...

+
+
+ ) +} + +export default function AuthCallback() { + return ( + +
+
+

Loading...

+
+ + }> + +
+ ) +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..0457230 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,56 @@ +import Header from '@ciphera-net/ui/Header' +import Footer from '@ciphera-net/ui/Footer' +import { AuthProvider } from '@/lib/auth/context' +import { ThemeProviders } from '@ciphera-net/ui' +import { Toaster } from 'sonner' +import type { Metadata } from 'next' +import { Plus_Jakarta_Sans } from 'next/font/google' +import '../styles/globals.css' + +const plusJakartaSans = Plus_Jakarta_Sans({ + subsets: ['latin'], + variable: '--font-plus-jakarta-sans', + display: 'swap', +}) + +export const metadata: Metadata = { + title: 'Ciphera Analytics - Privacy-First Web Analytics', + description: 'Simple, privacy-focused web analytics. No cookies, no tracking. GDPR compliant.', + keywords: ['analytics', 'privacy', 'web analytics', 'ciphera', 'GDPR'], + authors: [{ name: 'Ciphera' }], + creator: 'Ciphera', + publisher: 'Ciphera', + icons: { + icon: '/ciphera_icon_no_margins.png', + shortcut: '/ciphera_icon_no_margins.png', + apple: '/ciphera_icon_no_margins.png', + }, + robots: { + index: true, + follow: true, + }, + themeColor: '#FD5E0F', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + +
+
+ {children} +
+