diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 707017f..da0c788 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,33 +1,27 @@ import { useEffect } from "react"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; -import Logo from "./components/Logo"; +import { ProtectedRoute, PublicRoute } from "./components/RouteGuards"; +import SplashScreen from "./components/SplashScreen"; import { ROUTES } from "./config/routes"; +import { useAuth } from "./hooks/useAuth"; import Activate from "./pages/Activate"; import Drawer from "./pages/Drawer"; import Editor from "./pages/Editor"; +// Pages import Home from "./pages/Home"; import Login from "./pages/Login"; import Register from "./pages/Register"; import VerifyEmail from "./pages/VerifyEmail"; -import { useAuth } from "./store/useAuth"; export default function App() { - const { checkAuth, isInitializing } = useAuth(); + const { initialize, isInitializing } = useAuth(); useEffect(() => { - checkAuth(); - }, [checkAuth]); + initialize(); + }, [initialize]); if (isInitializing) { - return ( -
- - -

- LOADING... -

-
- ); + return ; } return ( @@ -35,12 +29,56 @@ export default function App() {
} /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + } + /> + + + + } + /> } />
diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 1559663..4953a33 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -1,64 +1,56 @@ -import axios, { type AxiosError } from "axios"; +import axios from "axios"; import { endpoints } from "../config/endpoints"; -import { useAuth } from "../store/useAuth"; +import { useAuthStore } from "../store/useAuth"; -const baseURL = import.meta.env.VITE_API_URL; - -export const preAuthApiClient = axios.create({ - baseURL, +// publicApi for endpoints that don't need authentication (login, refresh, register) +export const publicApi = axios.create({ + baseURL: import.meta.env.VITE_API_URL, withCredentials: true, - headers: { - "Content-Type": "application/json", - }, }); -export const postAuthApiClient = axios.create({ - baseURL, +// api for all authenticated requests +export const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL, withCredentials: true, - headers: { - "Content-Type": "application/json", - }, }); -// automatically attach access token to requests -postAuthApiClient.interceptors.request.use((config) => { - const token = useAuth.getState().accessToken; +// auto-attach access token to authenticated requests +api.interceptors.request.use((config) => { + const token = useAuthStore.getState().accessToken; if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); -// handle 401 & auto token refresh -postAuthApiClient.interceptors.response.use( +// Handle 401 errors by attempting a silent refresh +api.interceptors.response.use( (response) => response, - async (error: AxiosError) => { + async (error) => { const originalRequest = error.config; - // do not retry refresh request - if ( - error.response?.status === 401 && - originalRequest && - !originalRequest.url?.includes(endpoints.REFRESH) - ) { + // If 401 and we haven't tried refreshing yet + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + try { - // refresh the access token - const response = await preAuthApiClient.post(endpoints.REFRESH); + // Attempt silent refresh + const { data } = await publicApi.post(endpoints.REFRESH); + const newAccessToken = data.access; - if (response.status === 200) { - const newAccessToken = response.data.access; - - // update the auth store so the retry uses the new token - useAuth.setState({ - accessToken: newAccessToken, - isAuthenticated: true, - }); - - originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; - return postAuthApiClient(originalRequest); + // Update store + const { user, setAuth } = useAuthStore.getState(); + if (user) { + setAuth(newAccessToken, user); } + + // Retry the original request with the new token + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + return api(originalRequest); } catch (refreshError) { - useAuth.getState().logout(); + // Refresh failed, perform logout to clear tokens + console.error("Session expired, logging out..."); + useAuthStore.getState().clearAuth(); return Promise.reject(refreshError); } } diff --git a/frontend/src/components/RouteGuards.tsx b/frontend/src/components/RouteGuards.tsx new file mode 100644 index 0000000..d67a067 --- /dev/null +++ b/frontend/src/components/RouteGuards.tsx @@ -0,0 +1,38 @@ +import { Navigate, useLocation } from "react-router-dom"; +import { ROUTES } from "../config/routes"; +import { useAuth } from "../hooks/useAuth"; +import SplashScreen from "./SplashScreen"; + +/** + * Post-login routes. + * Redirects to /login if not already authenticated. + */ +export function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isInitializing } = useAuth(); + const location = useLocation(); + + if (isInitializing) return ; + + if (!isAuthenticated) { + // Save the intended location to redirect back after login + return ; + } + + return <>{children}; +} + +/** + * Pre-login flows. + * Redirects to /drawer if already authenticated. + */ +export function PublicRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isInitializing } = useAuth(); + + if (isInitializing) return ; + + if (isAuthenticated) { + return ; + } + + return <>{children}; +} diff --git a/frontend/src/components/SplashScreen.tsx b/frontend/src/components/SplashScreen.tsx new file mode 100644 index 0000000..9a31bd5 --- /dev/null +++ b/frontend/src/components/SplashScreen.tsx @@ -0,0 +1,17 @@ +import Logo from "./Logo"; + +export default function SplashScreen() { + return ( +
+
+ +
+ +

+ Initializing Identity +

+
+
+
+ ); +} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..13a219c --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,67 @@ +import { api, publicApi } from "../api/apiClient"; +import { endpoints } from "../config/endpoints"; +import { useAuthStore } from "../store/useAuth"; + +interface UserProfile { + public_id: string; + email: string; + full_name: string; +} + +export const useAuth = () => { + const { + accessToken, + user, + isInitializing, + setAuth, + clearAuth, + setInitializing, + } = useAuthStore(); + + const isAuthenticated = !!accessToken; + + const login = async (access: string, profile: UserProfile) => { + setAuth(access, profile); + }; + + const logout = async () => { + try { + await api.post(endpoints.LOGOUT); + } finally { + clearAuth(); + } + }; + + const initialize = async () => { + // If we already have a session in memory, don't trigger refresh/me again + if (accessToken && user) { + setInitializing(false); + return; + } + + try { + // try refresh + const { data: refreshData } = await publicApi.post(endpoints.REFRESH); + + // fetch user profile with the new access token + const { data: userData } = await api.get(endpoints.ME, { + headers: { Authorization: `Bearer ${refreshData.access}` }, + }); + + // update auth details in memory + setAuth(refreshData.access, userData); + } catch (err) { + console.error("Initialization failed:", err); + clearAuth(); + } + }; + + return { + isAuthenticated, + user, + isInitializing, + login, + logout, + initialize, + }; +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index 0ae1535..a6ea54f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -8,10 +8,12 @@ color-scheme: dark; /* ── Base surfaces ── */ - --color-base-100: oklch(14% 0.012 35); /* was 0.018 hue 50 */ + --color-base-100: oklch(14% 0.012 35); /* was 0.018 hue 50 */ --color-base-200: oklch(18% 0.014 33); --color-base-300: oklch(22% 0.016 32); - --color-base-content: oklch(82% 0.02 70); /* aged parchment, not crisp white */ + --color-base-content: oklch( + 82% 0.02 70 + ); /* aged parchment, not crisp white */ /* ── Primary: old lamp gold — warm, incandescent ── */ --color-primary: oklch(67% 0.11 78); @@ -34,7 +36,7 @@ --color-info-content: oklch(95% 0.01 240); --color-success: oklch(60% 0.08 150); --color-success-content: oklch(16% 0.03 150); - --color-warning: oklch(68% 0.08 72); /* honey, not caution-sign amber */ + --color-warning: oklch(68% 0.08 72); /* honey, not caution-sign amber */ --color-warning-content: oklch(18% 0.03 60); --color-error: oklch(55% 0.1 22); --color-error-content: oklch(92% 0.01 22); @@ -56,7 +58,12 @@ --font-display: "Playwrite HR Lijeva Variable", cursive; --font-sans: "Jost Variable", sans-serif; --font-serif: "Playfair Display Variable", serif; - --color-glass-bg: rgba(28, 22, 16, 0.45); /* slightly deeper to match new base */ + --color-glass-bg: rgba( + 28, + 22, + 16, + 0.45 + ); /* slightly deeper to match new base */ --shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6); --radius-xl: 1.5rem; --color-paper: oklch(97% 0.008 80); diff --git a/frontend/src/pages/Activate.tsx b/frontend/src/pages/Activate.tsx index 908186d..fd66eee 100644 --- a/frontend/src/pages/Activate.tsx +++ b/frontend/src/pages/Activate.tsx @@ -1,7 +1,7 @@ import { CheckCircleIcon, XCircleIcon } from "@phosphor-icons/react"; import { useEffect, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { preAuthApiClient } from "../api/apiClient"; +import { publicApi } from "../api/apiClient"; import Logo from "../components/Logo"; import { endpoints, replacePathParams } from "../config/endpoints"; import { ROUTES } from "../config/routes"; @@ -26,7 +26,7 @@ export default function Activate() { uidb64, token, }); - await preAuthApiClient.get(url); + await publicApi.get(url); setStatus("success"); } catch (err) { console.error("Activation error:", err); diff --git a/frontend/src/pages/Drawer.tsx b/frontend/src/pages/Drawer.tsx index 123e5ae..16912a6 100644 --- a/frontend/src/pages/Drawer.tsx +++ b/frontend/src/pages/Drawer.tsx @@ -1,21 +1,28 @@ -import { useEffect } from "react"; -import { useNavigate } from "react-router-dom"; -import { useAuth } from "../store/useAuth"; +import { useAuth } from "../hooks/useAuth"; export default function Drawer() { - const { user, isAuthenticated } = useAuth(); - const navigate = useNavigate(); - - // Redirect to login if not authenticated - useEffect(() => { - if (!isAuthenticated) { - navigate("/login"); - } - }, [isAuthenticated, navigate]); + const { user, logout } = useAuth(); if (!user) return null; return ( -
+
+
+

+ Your Drawer +

+

Welcome back, {user.full_name}

+
+ +
+ + +
); } diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 61b84a7..2f8b22a 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -1,3 +1,4 @@ +import { LockIcon, TrayIcon } from "@phosphor-icons/react"; import { useState } from "react"; import ComposeCanvas from "../components/ui/ComposeCanvas"; import DateDisplay from "../components/ui/DateDisplay"; @@ -30,9 +31,30 @@ export default function Editor() {
- Toolbar Placeholder +
Toolbar Placeholder
+ +
+ + +
+ + +
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 37cc7eb..1ff4eab 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -4,13 +4,15 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { z } from "zod"; +import { api, publicApi } from "../api/apiClient"; import Logo from "../components/Logo"; import FormField from "../components/ui/FormField"; +import { endpoints } from "../config/endpoints"; import { ROUTES } from "../config/routes"; -import { useAuth } from "../store/useAuth"; +import { useAuth } from "../hooks/useAuth"; const loginSchema = z.object({ - email: z.email("Please enter a valid email"), + email: z.string().email("Please enter a valid email"), password: z.string().min(1, "Password is required"), }); @@ -20,7 +22,7 @@ export default function Login() { const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(false); const [apiError, setApiError] = useState(null); - const login = useAuth((state) => state.login); + const { login } = useAuth(); const { register, @@ -34,7 +36,18 @@ export default function Login() { setIsLoading(true); setApiError(null); try { - await login(data); + // 1. Authenticate + const { data: authData } = await publicApi.post(endpoints.LOGIN, data); + + // 2. Fetch User Profile with the fresh token + // We pass the header explicitly to avoid any race conditions with interceptors + const { data: userData } = await api.get(endpoints.ME, { + headers: { Authorization: `Bearer ${authData.access}` }, + }); + + // 3. Update store using the hook method + await login(authData.access, userData); + navigate(ROUTES.DRAWER); } catch (err) { console.error("Login error:", err); @@ -49,59 +62,61 @@ export default function Login() { } }; -
-
-

- Sign in to -

+ return ( +
+ +

+ Sign in to +

- {apiError && ( -
- {apiError} + {apiError && ( +
+ {apiError} +
+ )} + + + + + +
+
- )} - - - - -
- -
- -
- Don't have an account?{" "} - -
- -
; +
+ Don't have an account?{" "} + +
+ +
+ ); } diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 932674e..efb1d92 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { z } from "zod"; -import { preAuthApiClient } from "../api/apiClient"; +import { publicApi } from "../api/apiClient"; import Logo from "../components/Logo"; import FormField from "../components/ui/FormField"; import { endpoints } from "../config/endpoints"; @@ -42,7 +42,7 @@ export default function Register() { setIsLoading(true); setApiError(null); try { - await preAuthApiClient.post(endpoints.REGISTER, { + await publicApi.post(endpoints.REGISTER, { full_name: data.full_name, email: data.email, password: data.password, diff --git a/frontend/src/store/useAuth.ts b/frontend/src/store/useAuth.ts index cd997f5..b805a59 100644 --- a/frontend/src/store/useAuth.ts +++ b/frontend/src/store/useAuth.ts @@ -1,6 +1,4 @@ import { create } from "zustand"; -import { postAuthApiClient, preAuthApiClient } from "../api/apiClient"; -import { endpoints } from "../config/endpoints"; interface UserProfile { public_id: string; @@ -10,59 +8,20 @@ interface UserProfile { interface AuthState { accessToken: string | null; - isAuthenticated: boolean; user: UserProfile | null; - isInitializing: boolean; // refresh in transit - login: (credentials: any) => Promise; - logout: () => Promise; - checkAuth: () => Promise; + isInitializing: boolean; + setAuth: (accessToken: string, user: UserProfile) => void; + clearAuth: () => void; + setInitializing: (v: boolean) => void; } -export const useAuth = create((set, get) => ({ +export const useAuthStore = create((set) => ({ accessToken: null, - isAuthenticated: false, user: null, isInitializing: true, - - login: async (credentials: any) => { - const response = await preAuthApiClient.post(endpoints.LOGIN, credentials); - set({ - accessToken: response.data.access, - isAuthenticated: true, - user: response.data.user, - }); - }, - - logout: async () => { - try { - const token = get().accessToken; - if (token) { - await preAuthApiClient.post(endpoints.LOGOUT); - } - } finally { - set({ - accessToken: null, - isAuthenticated: false, - user: null, - }); - } - }, - - checkAuth: async () => { - try { - const response = await postAuthApiClient.get(endpoints.ME); - set({ - user: response.data, - isAuthenticated: true, - }); - } catch (err) { - console.error("Check auth error:", err); - set({ - user: null, - isAuthenticated: false, - }); - } finally { - set({ isInitializing: false }); - } - }, + setAuth: (accessToken, user) => + set({ accessToken, user, isInitializing: false }), + clearAuth: () => + set({ accessToken: null, user: null, isInitializing: false }), + setInitializing: (isInitializing) => set({ isInitializing }), })); diff --git a/frontend/src/utils/crypto.ts b/frontend/src/utils/crypto.ts new file mode 100644 index 0000000..bdd6b1e --- /dev/null +++ b/frontend/src/utils/crypto.ts @@ -0,0 +1,82 @@ +/** + * 0 knowledge cryptography. No Server involved in encryption/decryption + */ + +const ITERATIONS = 100000; +const KEY_ALGO = { name: "AES-GCM", length: 256 }; + +/** + * Derives a Master Encryption Key from a password and email (salt). + */ +export async function deriveMasterKey( + password: string, + email: string, +): Promise { + const encoder = new TextEncoder(); + const passwordKey = await crypto.subtle.importKey( + "raw", + encoder.encode(password), + "PBKDF2", + false, + ["deriveKey"], + ); + + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: encoder.encode(email.toLowerCase()), + iterations: ITERATIONS, + hash: "SHA-256", + }, + passwordKey, + KEY_ALGO, + false, + ["encrypt", "decrypt", "wrapKey", "unwrapKey"], + ); +} + +/** + * Encrypts a letter using Envelope Encryption. + */ +export async function encryptLetter(plaintext: string, masterKey: CryptoKey) { + const encoder = new TextEncoder(); + + // Generate random Data Encryption Key (DEK) + const dek = await crypto.subtle.generateKey(KEY_ALGO, true, [ + "encrypt", + "decrypt", + ]); + + // Encrypt the content with the DEK + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + dek, + encoder.encode(plaintext), + ); + + // encrpyt the DEK using the Master Key for the self access + const keyIv = crypto.getRandomValues(new Uint8Array(12)); + const wrappedKey = await crypto.subtle.wrapKey("raw", dek, masterKey, { + name: "AES-GCM", + iv: keyIv, + }); + + // for recipients (link share), export DEK in raw format + const rawKey = await crypto.subtle.exportKey("raw", dek); + + // conversion to base64 for transit + const toBase64 = (buffer: Uint8Array) => btoa(String.fromCharCode(...buffer)); + + return { + // This goes to the server + encryptedPayload: { + ciphertext: toBase64(new Uint8Array(ciphertext)), + iv: toBase64(new Uint8Array(iv)), + wrappedKey: toBase64(new Uint8Array(wrappedKey)), + keyIv: toBase64(new Uint8Array(keyIv)), + }, + // This goes into the url for the recipient + sharingKey: toBase64(new Uint8Array(rawKey)), + }; +}