feat(web): add auth flow via platform-service + productId header
This commit is contained in:
parent
375d3a7ec0
commit
1fc1d6478a
@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
|
|||||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ToastContainer } from "@/components/Toast";
|
import { ToastContainer } from "@/components/Toast";
|
||||||
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
@ -54,8 +55,10 @@ export default function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${inter.variable} ${jetbrainsMono.variable} antialiased`}
|
className={`${inter.variable} ${jetbrainsMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
{children}
|
<Providers>
|
||||||
<ToastContainer />
|
{children}
|
||||||
|
<ToastContainer />
|
||||||
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
8
web/src/app/providers.tsx
Normal file
8
web/src/app/providers.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AuthProvider } from '@/lib/auth-context';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
|
return <AuthProvider>{children}</AuthProvider>;
|
||||||
|
}
|
||||||
@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
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 { URGENCY_ORDER, getUrgencyConfig } from '@/lib/urgency';
|
||||||
import { previewSound } from '@/lib/sounds';
|
import { previewSound } from '@/lib/sounds';
|
||||||
import { getNotificationPermission, requestNotificationPermission } from '@/lib/notifications';
|
import { getNotificationPermission, requestNotificationPermission } from '@/lib/notifications';
|
||||||
import { useTimerStore } from '@/lib/store';
|
import { useTimerStore } from '@/lib/store';
|
||||||
import { useTheme } from '@/lib/use-theme';
|
import { useTheme } from '@/lib/use-theme';
|
||||||
|
import { useAuth } from '@/lib/auth-context';
|
||||||
import type { NotificationPermission as NotifPerm } from '@/lib/notifications';
|
import type { NotificationPermission as NotifPerm } from '@/lib/notifications';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@ -17,6 +18,12 @@ export default function SettingsPage() {
|
|||||||
const { theme, toggle: toggleTheme } = useTheme();
|
const { theme, toggle: toggleTheme } = useTheme();
|
||||||
const timers = useTimerStore((s) => s.timers);
|
const timers = useTimerStore((s) => s.timers);
|
||||||
const removeTimer = useTimerStore((s) => s.removeTimer);
|
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(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@ -31,6 +38,20 @@ export default function SettingsPage() {
|
|||||||
document.documentElement.setAttribute('data-compact', String(next));
|
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;
|
if (!mounted) return null;
|
||||||
|
|
||||||
const completedCount = timers.filter((t) => ['dismissed', 'completed'].includes(t.state)).length;
|
const completedCount = timers.filter((t) => ['dismissed', 'completed'].includes(t.state)).length;
|
||||||
@ -56,6 +77,121 @@ export default function SettingsPage() {
|
|||||||
Settings
|
Settings
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
{/* Account */}
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="flex items-center gap-2 text-base font-semibold mb-4" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
<User size={18} /> Account & Sync
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-4"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||||
|
>
|
||||||
|
{authLoading ? (
|
||||||
|
<p className="text-sm" style={{ color: 'var(--cm-text-tertiary)' }}>Loading…</p>
|
||||||
|
) : isAuthenticated && user ? (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
{user.displayName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>{user.email}</p>
|
||||||
|
<p className="text-xs mt-1" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
Plan: {user.plan} · Sync enabled
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium cursor-pointer"
|
||||||
|
style={{ backgroundColor: 'rgba(255,71,87,0.15)', color: 'var(--cm-danger)' }}
|
||||||
|
>
|
||||||
|
<LogOut size={14} /> Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs mb-3" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
Sign in to sync timers across devices via ChronoMind cloud.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => { setAuthMode('login'); clearError(); }}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-xs font-medium cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: authMode === 'login' ? 'var(--cm-accent)' : 'var(--cm-surface-muted)',
|
||||||
|
color: authMode === 'login' ? '#fff' : 'var(--cm-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setAuthMode('register'); clearError(); }}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-xs font-medium cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: authMode === 'register' ? 'var(--cm-accent)' : 'var(--cm-surface-muted)',
|
||||||
|
color: authMode === 'register' ? '#fff' : 'var(--cm-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{authMode === 'register' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Display name"
|
||||||
|
value={authName}
|
||||||
|
onChange={(e) => 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)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={authEmail}
|
||||||
|
onChange={(e) => 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)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={authPassword}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<p className="text-xs" style={{ color: 'var(--cm-danger)' }}>{authError}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleAuthSubmit}
|
||||||
|
disabled={authSubmitting || !authEmail || !authPassword || (authMode === 'register' && !authName)}
|
||||||
|
className="w-full px-4 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:opacity-40"
|
||||||
|
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{authSubmitting ? 'Please wait…' : authMode === 'login' ? 'Sign In' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Theme */}
|
{/* Theme */}
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="flex items-center gap-2 text-base font-semibold mb-4" style={{ color: 'var(--cm-text-primary)' }}>
|
<h2 className="flex items-center gap-2 text-base font-semibold mb-4" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
|||||||
112
web/src/lib/auth-context.tsx
Normal file
112
web/src/lib/auth-context.tsx
Normal file
@ -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<boolean>;
|
||||||
|
register: (email: string, password: string, displayName: string) => Promise<boolean>;
|
||||||
|
logout: () => void;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthContextValue = AuthState & AuthActions;
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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<boolean> => {
|
||||||
|
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<boolean> => {
|
||||||
|
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 (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated: user !== null,
|
||||||
|
error,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
clearError,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@ -62,6 +62,8 @@ const STORAGE_KEYS = {
|
|||||||
syncEnabled: 'chronomind-platform-sync-enabled',
|
syncEnabled: 'chronomind-platform-sync-enabled',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const PRODUCT_ID = 'chronomind';
|
||||||
|
|
||||||
function getBaseUrl(): string {
|
function getBaseUrl(): string {
|
||||||
if (typeof window !== 'undefined' && (window as unknown as Record<string, unknown>).__PLATFORM_URL__) {
|
if (typeof window !== 'undefined' && (window as unknown as Record<string, unknown>).__PLATFORM_URL__) {
|
||||||
return (window as unknown as Record<string, unknown>).__PLATFORM_URL__ as string;
|
return (window as unknown as Record<string, unknown>).__PLATFORM_URL__ as string;
|
||||||
@ -83,6 +85,7 @@ async function apiRequest<T>(
|
|||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-request-id': crypto.randomUUID(),
|
'x-request-id': crypto.randomUUID(),
|
||||||
|
'x-product-id': PRODUCT_ID,
|
||||||
};
|
};
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
@ -145,6 +148,43 @@ function setLastSyncDate(date: string): void {
|
|||||||
localStorage.setItem(STORAGE_KEYS.lastSync, date);
|
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<AuthResult> {
|
||||||
|
return apiRequest<AuthResult>('/auth/login', 'POST', { email, password, productId: PRODUCT_ID });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerUser(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
displayName: string
|
||||||
|
): Promise<AuthResult> {
|
||||||
|
return apiRequest<AuthResult>('/auth/register', 'POST', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
displayName,
|
||||||
|
productId: PRODUCT_ID,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(): Promise<AuthUser> {
|
||||||
|
return apiRequest<AuthUser>('/auth/me', 'GET');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Sync Operations ───────────────────────────────────────────
|
// ── Sync Operations ───────────────────────────────────────────
|
||||||
|
|
||||||
export async function pullDelta(since?: string): Promise<SyncTimerDTO[]> {
|
export async function pullDelta(since?: string): Promise<SyncTimerDTO[]> {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user