From 1fc1d6478ae1193e1d11287539af922ff714293b Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 28 Feb 2026 02:01:27 -0800 Subject: [PATCH] feat(web): add auth flow via platform-service + productId header --- web/src/app/layout.tsx | 7 +- web/src/app/providers.tsx | 8 ++ web/src/app/settings/page.tsx | 138 +++++++++++++++++++++++++++++++++- web/src/lib/auth-context.tsx | 112 +++++++++++++++++++++++++++ web/src/lib/platform-sync.ts | 40 ++++++++++ 5 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 web/src/app/providers.tsx create mode 100644 web/src/lib/auth-context.tsx diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index ebe8366..f272378 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next"; import { Inter, JetBrains_Mono } from "next/font/google"; import "./globals.css"; import { ToastContainer } from "@/components/Toast"; +import { Providers } from "./providers"; const inter = Inter({ variable: "--font-inter", @@ -54,8 +55,10 @@ export default function RootLayout({ - {children} - + + {children} + + ); diff --git a/web/src/app/providers.tsx b/web/src/app/providers.tsx new file mode 100644 index 0000000..24cd699 --- /dev/null +++ b/web/src/app/providers.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { AuthProvider } from '@/lib/auth-context'; +import type { ReactNode } from 'react'; + +export function Providers({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/web/src/app/settings/page.tsx b/web/src/app/settings/page.tsx index b61624e..ffe66ce 100644 --- a/web/src/app/settings/page.tsx +++ b/web/src/app/settings/page.tsx @@ -2,12 +2,13 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; -import { ArrowLeft, Volume2, Bell, Palette, Trash2, Minimize2 } from 'lucide-react'; +import { ArrowLeft, Volume2, Bell, Palette, Trash2, Minimize2, User, LogOut } from 'lucide-react'; import { URGENCY_ORDER, getUrgencyConfig } from '@/lib/urgency'; import { previewSound } from '@/lib/sounds'; import { getNotificationPermission, requestNotificationPermission } from '@/lib/notifications'; import { useTimerStore } from '@/lib/store'; import { useTheme } from '@/lib/use-theme'; +import { useAuth } from '@/lib/auth-context'; import type { NotificationPermission as NotifPerm } from '@/lib/notifications'; export default function SettingsPage() { @@ -17,6 +18,12 @@ export default function SettingsPage() { const { theme, toggle: toggleTheme } = useTheme(); const timers = useTimerStore((s) => s.timers); const removeTimer = useTimerStore((s) => s.removeTimer); + const { user, isAuthenticated, isLoading: authLoading, error: authError, login, register, logout, clearError } = useAuth(); + const [authMode, setAuthMode] = useState<'login' | 'register'>('login'); + const [authEmail, setAuthEmail] = useState(''); + const [authPassword, setAuthPassword] = useState(''); + const [authName, setAuthName] = useState(''); + const [authSubmitting, setAuthSubmitting] = useState(false); useEffect(() => { setMounted(true); @@ -31,6 +38,20 @@ export default function SettingsPage() { document.documentElement.setAttribute('data-compact', String(next)); }; + const handleAuthSubmit = async () => { + setAuthSubmitting(true); + clearError(); + const ok = authMode === 'login' + ? await login(authEmail, authPassword) + : await register(authEmail, authPassword, authName); + setAuthSubmitting(false); + if (ok) { + setAuthEmail(''); + setAuthPassword(''); + setAuthName(''); + } + }; + if (!mounted) return null; const completedCount = timers.filter((t) => ['dismissed', 'completed'].includes(t.state)).length; @@ -56,6 +77,121 @@ export default function SettingsPage() { Settings + {/* Account */} +
+

+ Account & Sync +

+
+ {authLoading ? ( +

Loading…

+ ) : isAuthenticated && user ? ( +
+
+
+

+ {user.displayName} +

+

{user.email}

+

+ Plan: {user.plan} · Sync enabled +

+
+ +
+
+ ) : ( +
+

+ Sign in to sync timers across devices via ChronoMind cloud. +

+
+ + +
+
+ {authMode === 'register' && ( + setAuthName(e.target.value)} + className="w-full px-3 py-2 rounded-lg text-sm outline-none" + style={{ + backgroundColor: 'var(--cm-surface-muted)', + color: 'var(--cm-text-primary)', + border: '1px solid var(--cm-border)', + }} + /> + )} + setAuthEmail(e.target.value)} + className="w-full px-3 py-2 rounded-lg text-sm outline-none" + style={{ + backgroundColor: 'var(--cm-surface-muted)', + color: 'var(--cm-text-primary)', + border: '1px solid var(--cm-border)', + }} + /> + setAuthPassword(e.target.value)} + className="w-full px-3 py-2 rounded-lg text-sm outline-none" + style={{ + backgroundColor: 'var(--cm-surface-muted)', + color: 'var(--cm-text-primary)', + border: '1px solid var(--cm-border)', + }} + /> + {authError && ( +

{authError}

+ )} + +
+
+ )} +
+
+ {/* Theme */}

diff --git a/web/src/lib/auth-context.tsx b/web/src/lib/auth-context.tsx new file mode 100644 index 0000000..3f6edb2 --- /dev/null +++ b/web/src/lib/auth-context.tsx @@ -0,0 +1,112 @@ +// ── Auth Context ────────────────────────────────────────────── +// Provides authentication state and actions for ChronoMind web. +// Calls platform-service /auth/* endpoints for login/register/me. + +'use client'; + +import { createContext, useContext, useCallback, useEffect, useState, type ReactNode } from 'react'; +import { + loginUser, + registerUser, + getMe, + setAuthToken, + isAuthenticated as checkAuth, + type AuthUser, +} from './platform-sync'; + +interface AuthState { + user: AuthUser | null; + isLoading: boolean; + isAuthenticated: boolean; + error: string | null; +} + +interface AuthActions { + login: (email: string, password: string) => Promise; + register: (email: string, password: string, displayName: string) => Promise; + logout: () => void; + clearError: () => void; +} + +type AuthContextValue = AuthState & AuthActions; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Hydrate user from stored token on mount + useEffect(() => { + if (!checkAuth()) { + setIsLoading(false); + return; + } + getMe() + .then((u) => setUser(u)) + .catch(() => { + setAuthToken(null); + }) + .finally(() => setIsLoading(false)); + }, []); + + const login = useCallback(async (email: string, password: string): Promise => { + setError(null); + try { + const result = await loginUser(email, password); + setAuthToken(result.accessToken); + setUser(result.user); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed'); + return false; + } + }, []); + + const register = useCallback( + async (email: string, password: string, displayName: string): Promise => { + setError(null); + try { + const result = await registerUser(email, password, displayName); + setAuthToken(result.accessToken); + setUser(result.user); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Registration failed'); + return false; + } + }, + [] + ); + + const logout = useCallback(() => { + setAuthToken(null); + setUser(null); + }, []); + + const clearError = useCallback(() => setError(null), []); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} diff --git a/web/src/lib/platform-sync.ts b/web/src/lib/platform-sync.ts index 69aaed3..f64381a 100644 --- a/web/src/lib/platform-sync.ts +++ b/web/src/lib/platform-sync.ts @@ -62,6 +62,8 @@ const STORAGE_KEYS = { syncEnabled: 'chronomind-platform-sync-enabled', } as const; +export const PRODUCT_ID = 'chronomind'; + function getBaseUrl(): string { if (typeof window !== 'undefined' && (window as unknown as Record).__PLATFORM_URL__) { return (window as unknown as Record).__PLATFORM_URL__ as string; @@ -83,6 +85,7 @@ async function apiRequest( const headers: Record = { 'Content-Type': 'application/json', 'x-request-id': crypto.randomUUID(), + 'x-product-id': PRODUCT_ID, }; if (token) headers['Authorization'] = `Bearer ${token}`; @@ -145,6 +148,43 @@ function setLastSyncDate(date: string): void { localStorage.setItem(STORAGE_KEYS.lastSync, date); } +// ── Auth Operations ────────────────────────────────────────── + +export interface AuthUser { + id: string; + email: string; + displayName: string; + role: string; + plan: string; +} + +export interface AuthResult { + accessToken: string; + refreshToken: string; + user: AuthUser; +} + +export async function loginUser(email: string, password: string): Promise { + return apiRequest('/auth/login', 'POST', { email, password, productId: PRODUCT_ID }); +} + +export async function registerUser( + email: string, + password: string, + displayName: string +): Promise { + return apiRequest('/auth/register', 'POST', { + email, + password, + displayName, + productId: PRODUCT_ID, + }); +} + +export async function getMe(): Promise { + return apiRequest('/auth/me', 'GET'); +} + // ── Sync Operations ─────────────────────────────────────────── export async function pullDelta(since?: string): Promise {