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 (
-
- );
+ 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() {
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() {
}
};
-