mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
feat: implement auth state management with RouteGuards
This commit is contained in:
+58
-20
@@ -1,33 +1,27 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
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 { ROUTES } from "./config/routes";
|
||||||
|
import { useAuth } from "./hooks/useAuth";
|
||||||
import Activate from "./pages/Activate";
|
import Activate from "./pages/Activate";
|
||||||
import Drawer from "./pages/Drawer";
|
import Drawer from "./pages/Drawer";
|
||||||
import Editor from "./pages/Editor";
|
import Editor from "./pages/Editor";
|
||||||
|
// Pages
|
||||||
import Home from "./pages/Home";
|
import Home from "./pages/Home";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Register from "./pages/Register";
|
import Register from "./pages/Register";
|
||||||
import VerifyEmail from "./pages/VerifyEmail";
|
import VerifyEmail from "./pages/VerifyEmail";
|
||||||
import { useAuth } from "./store/useAuth";
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { checkAuth, isInitializing } = useAuth();
|
const { initialize, isInitializing } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAuth();
|
initialize();
|
||||||
}, [checkAuth]);
|
}, [initialize]);
|
||||||
|
|
||||||
if (isInitializing) {
|
if (isInitializing) {
|
||||||
return (
|
return <SplashScreen />;
|
||||||
<div className="min-h-screen bg-base-200 flex flex-col items-center justify-center gap-4">
|
|
||||||
<Logo />
|
|
||||||
<span className="loading loading-dots loading-lg text-primary"></span>
|
|
||||||
<p className="text-sm font-medium opacity-50 uppercase tracking-widest">
|
|
||||||
LOADING...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,12 +29,56 @@ export default function App() {
|
|||||||
<main className="min-h-screen bg-base-200 flex items-center justify-center w-full">
|
<main className="min-h-screen bg-base-200 flex items-center justify-center w-full">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path={ROUTES.HOME} element={<Home />} />
|
<Route path={ROUTES.HOME} element={<Home />} />
|
||||||
<Route path={ROUTES.ONBOARD} element={<Register />} />
|
|
||||||
<Route path={ROUTES.VERIFY_EMAIL} element={<VerifyEmail />} />
|
<Route
|
||||||
<Route path={ROUTES.ACTIVATE} element={<Activate />} />
|
path={ROUTES.ONBOARD}
|
||||||
<Route path={ROUTES.LOGIN} element={<Login />} />
|
element={
|
||||||
<Route path={ROUTES.DRAWER} element={<Drawer />} />
|
<PublicRoute>
|
||||||
<Route path={ROUTES.WRITE} element={<Editor />} />
|
<Register />
|
||||||
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={ROUTES.LOGIN}
|
||||||
|
element={
|
||||||
|
<PublicRoute>
|
||||||
|
<Login />
|
||||||
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={ROUTES.VERIFY_EMAIL}
|
||||||
|
element={
|
||||||
|
<PublicRoute>
|
||||||
|
<VerifyEmail />
|
||||||
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={ROUTES.ACTIVATE}
|
||||||
|
element={
|
||||||
|
<PublicRoute>
|
||||||
|
<Activate />
|
||||||
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path={ROUTES.DRAWER}
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Drawer />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={ROUTES.WRITE}
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Editor />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to={ROUTES.HOME} replace />} />
|
<Route path="*" element={<Navigate to={ROUTES.HOME} replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,64 +1,56 @@
|
|||||||
import axios, { type AxiosError } from "axios";
|
import axios from "axios";
|
||||||
import { endpoints } from "../config/endpoints";
|
import { endpoints } from "../config/endpoints";
|
||||||
import { useAuth } from "../store/useAuth";
|
import { useAuthStore } from "../store/useAuth";
|
||||||
|
|
||||||
const baseURL = import.meta.env.VITE_API_URL;
|
// publicApi for endpoints that don't need authentication (login, refresh, register)
|
||||||
|
export const publicApi = axios.create({
|
||||||
export const preAuthApiClient = axios.create({
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
baseURL,
|
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const postAuthApiClient = axios.create({
|
// api for all authenticated requests
|
||||||
baseURL,
|
export const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// automatically attach access token to requests
|
// auto-attach access token to authenticated requests
|
||||||
postAuthApiClient.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
const token = useAuth.getState().accessToken;
|
const token = useAuthStore.getState().accessToken;
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
// handle 401 & auto token refresh
|
// Handle 401 errors by attempting a silent refresh
|
||||||
postAuthApiClient.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error: AxiosError) => {
|
async (error) => {
|
||||||
const originalRequest = error.config;
|
const originalRequest = error.config;
|
||||||
|
|
||||||
// do not retry refresh request
|
// If 401 and we haven't tried refreshing yet
|
||||||
if (
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
error.response?.status === 401 &&
|
originalRequest._retry = true;
|
||||||
originalRequest &&
|
|
||||||
!originalRequest.url?.includes(endpoints.REFRESH)
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
// refresh the access token
|
// Attempt silent refresh
|
||||||
const response = await preAuthApiClient.post(endpoints.REFRESH);
|
const { data } = await publicApi.post(endpoints.REFRESH);
|
||||||
|
const newAccessToken = data.access;
|
||||||
|
|
||||||
if (response.status === 200) {
|
// Update store
|
||||||
const newAccessToken = response.data.access;
|
const { user, setAuth } = useAuthStore.getState();
|
||||||
|
if (user) {
|
||||||
// update the auth store so the retry uses the new token
|
setAuth(newAccessToken, user);
|
||||||
useAuth.setState({
|
|
||||||
accessToken: newAccessToken,
|
|
||||||
isAuthenticated: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
|
||||||
return postAuthApiClient(originalRequest);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retry the original request with the new token
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||||
|
return api(originalRequest);
|
||||||
} catch (refreshError) {
|
} 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);
|
return Promise.reject(refreshError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <SplashScreen />;
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
// Save the intended location to redirect back after login
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <SplashScreen />;
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to={ROUTES.DRAWER} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import Logo from "./Logo";
|
||||||
|
|
||||||
|
export default function SplashScreen() {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-base-100 flex flex-col items-center justify-center z-9999">
|
||||||
|
<div className="flex flex-col items-center gap-6 animate-pulse">
|
||||||
|
<Logo />
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<span className="loading loading-ring loading-lg text-primary" />
|
||||||
|
<p className="text-xs uppercase tracking-widest opacity-40 font-display">
|
||||||
|
Initializing Identity
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -11,7 +11,9 @@
|
|||||||
--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-200: oklch(18% 0.014 33);
|
||||||
--color-base-300: oklch(22% 0.016 32);
|
--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 ── */
|
/* ── Primary: old lamp gold — warm, incandescent ── */
|
||||||
--color-primary: oklch(67% 0.11 78);
|
--color-primary: oklch(67% 0.11 78);
|
||||||
@@ -56,7 +58,12 @@
|
|||||||
--font-display: "Playwrite HR Lijeva Variable", cursive;
|
--font-display: "Playwrite HR Lijeva Variable", cursive;
|
||||||
--font-sans: "Jost Variable", sans-serif;
|
--font-sans: "Jost Variable", sans-serif;
|
||||||
--font-serif: "Playfair Display Variable", 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);
|
--shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6);
|
||||||
--radius-xl: 1.5rem;
|
--radius-xl: 1.5rem;
|
||||||
--color-paper: oklch(97% 0.008 80);
|
--color-paper: oklch(97% 0.008 80);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CheckCircleIcon, XCircleIcon } from "@phosphor-icons/react";
|
import { CheckCircleIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { preAuthApiClient } from "../api/apiClient";
|
import { publicApi } from "../api/apiClient";
|
||||||
import Logo from "../components/Logo";
|
import Logo from "../components/Logo";
|
||||||
import { endpoints, replacePathParams } from "../config/endpoints";
|
import { endpoints, replacePathParams } from "../config/endpoints";
|
||||||
import { ROUTES } from "../config/routes";
|
import { ROUTES } from "../config/routes";
|
||||||
@@ -26,7 +26,7 @@ export default function Activate() {
|
|||||||
uidb64,
|
uidb64,
|
||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
await preAuthApiClient.get(url);
|
await publicApi.get(url);
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Activation error:", err);
|
console.error("Activation error:", err);
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
import { useEffect } from "react";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useAuth } from "../store/useAuth";
|
|
||||||
|
|
||||||
export default function Drawer() {
|
export default function Drawer() {
|
||||||
const { user, isAuthenticated } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// Redirect to login if not authenticated
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
navigate("/login");
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, navigate]);
|
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card w-full max-w-sm p-8 text-center fade-zoom"></div>
|
<div className="glass-card w-full max-w-sm p-8 text-center flex flex-col gap-6 fade-zoom">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="font-display text-2xl font-bold text-primary">
|
||||||
|
Your Drawer
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm opacity-70">Welcome back, {user.full_name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider opacity-10" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={logout}
|
||||||
|
className="btn btn-link btn-xs opacity-40 hover:opacity-100 no-underline transition-all"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LockIcon, TrayIcon } from "@phosphor-icons/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import ComposeCanvas from "../components/ui/ComposeCanvas";
|
import ComposeCanvas from "../components/ui/ComposeCanvas";
|
||||||
import DateDisplay from "../components/ui/DateDisplay";
|
import DateDisplay from "../components/ui/DateDisplay";
|
||||||
@@ -30,9 +31,30 @@ export default function Editor() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
id="writer-toolbar"
|
id="writer-toolbar"
|
||||||
className="flex items-center justify-center mb-4 min-h-12 bg-white/5 rounded-sm border border-white/5 font-display text-sm tracking-widest text-secondary-content"
|
className="flex items-center justify-between mb-8 h-14 bg-base-100/50 backdrop-blur-md rounded-full border border-base-content/5 px-6"
|
||||||
>
|
>
|
||||||
Toolbar Placeholder
|
<div className="flex gap-4">Toolbar Placeholder</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm text-[10px] tracking-[0.2em] uppercase font-bold text-base-content/60 hover:text-base-content"
|
||||||
|
title="Keep in your private drawer"
|
||||||
|
>
|
||||||
|
<TrayIcon size={18} weight="bold" />
|
||||||
|
<span className="hidden md:inline">Keep</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-4 bg-base-content/10 mx-2" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm rounded-full px-6 text-[10px] tracking-[0.3em] uppercase font-black"
|
||||||
|
>
|
||||||
|
<LockIcon size={14} weight="fill" className="mr-1" />
|
||||||
|
Seal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ComposeCanvas />
|
<ComposeCanvas />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import { useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { api, publicApi } from "../api/apiClient";
|
||||||
import Logo from "../components/Logo";
|
import Logo from "../components/Logo";
|
||||||
import FormField from "../components/ui/FormField";
|
import FormField from "../components/ui/FormField";
|
||||||
|
import { endpoints } from "../config/endpoints";
|
||||||
import { ROUTES } from "../config/routes";
|
import { ROUTES } from "../config/routes";
|
||||||
import { useAuth } from "../store/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
const loginSchema = z.object({
|
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"),
|
password: z.string().min(1, "Password is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -20,7 +22,7 @@ export default function Login() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [apiError, setApiError] = useState<string | null>(null);
|
const [apiError, setApiError] = useState<string | null>(null);
|
||||||
const login = useAuth((state) => state.login);
|
const { login } = useAuth();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -34,7 +36,18 @@ export default function Login() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setApiError(null);
|
setApiError(null);
|
||||||
try {
|
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);
|
navigate(ROUTES.DRAWER);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Login error:", err);
|
console.error("Login error:", err);
|
||||||
@@ -49,6 +62,7 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
|
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
|
||||||
<h1 className="card-title font-display text-2xl font-bold justify-center text-primary tracking-tight">
|
<h1 className="card-title font-display text-2xl font-bold justify-center text-primary tracking-tight">
|
||||||
@@ -103,5 +117,6 @@ export default function Login() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>;
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { preAuthApiClient } from "../api/apiClient";
|
import { publicApi } from "../api/apiClient";
|
||||||
import Logo from "../components/Logo";
|
import Logo from "../components/Logo";
|
||||||
import FormField from "../components/ui/FormField";
|
import FormField from "../components/ui/FormField";
|
||||||
import { endpoints } from "../config/endpoints";
|
import { endpoints } from "../config/endpoints";
|
||||||
@@ -42,7 +42,7 @@ export default function Register() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setApiError(null);
|
setApiError(null);
|
||||||
try {
|
try {
|
||||||
await preAuthApiClient.post(endpoints.REGISTER, {
|
await publicApi.post(endpoints.REGISTER, {
|
||||||
full_name: data.full_name,
|
full_name: data.full_name,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { postAuthApiClient, preAuthApiClient } from "../api/apiClient";
|
|
||||||
import { endpoints } from "../config/endpoints";
|
|
||||||
|
|
||||||
interface UserProfile {
|
interface UserProfile {
|
||||||
public_id: string;
|
public_id: string;
|
||||||
@@ -10,59 +8,20 @@ interface UserProfile {
|
|||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
isAuthenticated: boolean;
|
|
||||||
user: UserProfile | null;
|
user: UserProfile | null;
|
||||||
isInitializing: boolean; // refresh in transit
|
isInitializing: boolean;
|
||||||
login: (credentials: any) => Promise<void>;
|
setAuth: (accessToken: string, user: UserProfile) => void;
|
||||||
logout: () => Promise<void>;
|
clearAuth: () => void;
|
||||||
checkAuth: () => Promise<void>;
|
setInitializing: (v: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuth = create<AuthState>((set, get) => ({
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
isAuthenticated: false,
|
|
||||||
user: null,
|
user: null,
|
||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
|
setAuth: (accessToken, user) =>
|
||||||
login: async (credentials: any) => {
|
set({ accessToken, user, isInitializing: false }),
|
||||||
const response = await preAuthApiClient.post(endpoints.LOGIN, credentials);
|
clearAuth: () =>
|
||||||
set({
|
set({ accessToken: null, user: null, isInitializing: false }),
|
||||||
accessToken: response.data.access,
|
setInitializing: (isInitializing) => set({ isInitializing }),
|
||||||
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 });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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<CryptoKey> {
|
||||||
|
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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user