feat: implement basic authentication system

This commit is contained in:
Adrian Acala
2026-01-16 21:34:58 -08:00
parent d1dbde95e4
commit 20ef4ee295
26 changed files with 975 additions and 23 deletions
+16 -10
View File
@@ -4,20 +4,26 @@ import { Editor } from './pages/Editor';
import { Settings } from './pages/Settings';
import { ThemeProvider } from './context/ThemeContext';
import { UploadProvider } from './context/UploadContext';
import { AuthProvider } from './context/AuthContext';
import { AuthGate } from './components/AuthGate';
function App() {
return (
<ThemeProvider>
<UploadProvider>
<Router>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/collections" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/editor/:id" element={<Editor />} />
</Routes>
</Router>
</UploadProvider>
<AuthProvider>
<UploadProvider>
<Router>
<AuthGate>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/collections" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/editor/:id" element={<Editor />} />
</Routes>
</AuthGate>
</Router>
</UploadProvider>
</AuthProvider>
</ThemeProvider>
);
}
+42 -1
View File
@@ -5,8 +5,21 @@ export const API_URL = import.meta.env.VITE_API_URL || "/api";
export const api = axios.create({
baseURL: API_URL,
withCredentials: true,
});
export type AuthStatus = {
enabled: boolean;
authenticated: boolean;
user: { username: string } | null;
};
let unauthorizedHandler: (() => void) | null = null;
export const setUnauthorizedHandler = (handler: (() => void) | null) => {
unauthorizedHandler = handler;
};
// CSRF Token Management
let csrfToken: string | null = null;
let csrfHeaderName: string = "x-csrf-token";
@@ -18,7 +31,8 @@ let csrfTokenPromise: Promise<void> | null = null;
export const fetchCsrfToken = async (): Promise<void> => {
try {
const response = await axios.get<{ token: string; header: string }>(
`${API_URL}/csrf-token`
`${API_URL}/csrf-token`,
{ withCredentials: true }
);
csrfToken = response.data.token;
csrfHeaderName = response.data.header || "x-csrf-token";
@@ -50,6 +64,11 @@ export const clearCsrfToken = (): void => {
csrfToken = null;
};
export const getCsrfHeaders = async (): Promise<Record<string, string>> => {
await ensureCsrfToken();
return csrfToken ? { [csrfHeaderName]: csrfToken } : {};
};
// Add request interceptor to include CSRF token
api.interceptors.request.use(
async (config) => {
@@ -70,6 +89,10 @@ api.interceptors.request.use(
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
unauthorizedHandler?.();
}
// If we get a 403 with CSRF error, clear token and retry once
if (
error.response?.status === 403 &&
@@ -92,6 +115,24 @@ api.interceptors.response.use(
}
);
export const getAuthStatus = async (): Promise<AuthStatus> => {
const response = await api.get<AuthStatus>("/auth/status");
return response.data;
};
export const login = async (username: string, password: string) => {
const response = await api.post<{ authenticated: boolean }>("/auth/login", {
username,
password,
});
return response.data;
};
export const logout = async () => {
const response = await api.post<{ authenticated: boolean }>("/auth/logout");
return response.data;
};
const coerceTimestamp = (value: string | number | Date): number => {
if (typeof value === "number") return value;
if (value instanceof Date) return value.getTime();
+32
View File
@@ -0,0 +1,32 @@
import React from "react";
import { Loader2 } from "lucide-react";
import { useAuth } from "../context/AuthContext";
import { Login } from "../pages/Login";
export const AuthGate: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { state } = useAuth();
if (state.loading) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-[#F3F4F6] dark:bg-neutral-950 text-slate-700 dark:text-neutral-200 transition-colors duration-200">
<Loader2 className="h-8 w-8 animate-spin mb-3" />
<p className="text-sm font-semibold">Loading session...</p>
</div>
);
}
if (state.statusError) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-[#F3F4F6] dark:bg-neutral-950 text-slate-700 dark:text-neutral-200 transition-colors duration-200 px-4 text-center">
<p className="text-sm font-semibold mb-2">{state.statusError}</p>
<p className="text-xs text-slate-500 dark:text-neutral-400">Please refresh to try again.</p>
</div>
);
}
if (state.enabled && !state.authenticated) {
return <Login />;
}
return <>{children}</>;
};
+111
View File
@@ -0,0 +1,111 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import * as api from "../api";
type AuthState = {
enabled: boolean;
authenticated: boolean;
user: { username: string } | null;
loading: boolean;
statusError: string | null;
};
type AuthContextValue = {
state: AuthState;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshStatus: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [state, setState] = useState<AuthState>({
enabled: false,
authenticated: false,
user: null,
loading: true,
statusError: null,
});
const refreshStatus = useCallback(async () => {
setState((prev) => ({
...prev,
loading: true,
}));
try {
const status = await api.getAuthStatus();
setState({
enabled: status.enabled,
authenticated: status.authenticated,
user: status.user,
loading: false,
statusError: null,
});
} catch (error) {
console.error("Failed to fetch auth status:", error);
setState((prev) => ({
...prev,
authenticated: false,
user: null,
loading: false,
statusError: prev.statusError || "Unable to reach authentication service.",
}));
}
}, []);
useEffect(() => {
refreshStatus();
}, [refreshStatus]);
useEffect(() => {
api.setUnauthorizedHandler(() => {
setState((prev) => ({
...prev,
authenticated: false,
user: null,
}));
});
return () => api.setUnauthorizedHandler(null);
}, []);
const login = useCallback(
async (username: string, password: string) => {
await api.login(username, password);
await refreshStatus();
},
[refreshStatus]
);
const logout = useCallback(async () => {
await api.logout();
await refreshStatus();
}, [refreshStatus]);
const value = useMemo<AuthContextValue>(
() => ({
state,
login,
logout,
refreshStatus,
}),
[state, login, logout, refreshStatus]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = (): AuthContextValue => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
+1
View File
@@ -114,6 +114,7 @@ export const Editor: React.FC = () => {
const socket = io(socketUrl, {
path: '/socket.io',
transports: ['websocket', 'polling'],
withCredentials: true,
});
socketRef.current = socket;
+85
View File
@@ -0,0 +1,85 @@
import React, { useState } from "react";
import { Loader2 } from "lucide-react";
import { Logo } from "../components/Logo";
import { useAuth } from "../context/AuthContext";
export const Login: React.FC = () => {
const { login } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await login(username.trim(), password);
} catch (err) {
console.error("Login failed:", err);
setError("Invalid username or password.");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#F3F4F6] dark:bg-neutral-950 px-4 py-12 transition-colors duration-200">
<div className="w-full max-w-md bg-white dark:bg-neutral-900 rounded-3xl border-2 border-black dark:border-neutral-700 shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] p-8">
<div className="flex flex-col items-center text-center">
<Logo className="h-16 w-16 mb-4" />
<h1 className="text-4xl text-slate-900 dark:text-white" style={{ fontFamily: "Excalifont" }}>
ExcaliDash
</h1>
<p className="mt-2 text-sm text-slate-500 dark:text-neutral-400 font-medium">
Sign in to access your drawings
</p>
</div>
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
Username
<input
type="text"
name="username"
autoComplete="username"
required
value={username}
onChange={(event) => setUsername(event.target.value)}
className="mt-2 w-full rounded-xl border-2 border-black dark:border-neutral-700 bg-white dark:bg-neutral-800 px-4 py-3 text-base text-slate-900 dark:text-white shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)] focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</label>
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
Password
<input
type="password"
name="password"
autoComplete="current-password"
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className="mt-2 w-full rounded-xl border-2 border-black dark:border-neutral-700 bg-white dark:bg-neutral-800 px-4 py-3 text-base text-slate-900 dark:text-white shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)] focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</label>
{error && (
<p className="text-sm text-rose-600 dark:text-rose-400 font-semibold">
{error}
</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full flex items-center justify-center gap-2 rounded-xl border-2 border-black dark:border-neutral-700 bg-indigo-600 text-white px-4 py-3 text-base font-bold shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)] transition-all duration-200 hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
>
{isSubmitting ? <Loader2 className="h-5 w-5 animate-spin" /> : "Sign in"}
</button>
</form>
</div>
</div>
);
};
+44 -1
View File
@@ -3,15 +3,17 @@ import { Layout } from '../components/Layout';
import { useNavigate } from 'react-router-dom';
import * as api from '../api';
import type { Collection } from '../types';
import { Database, FileJson, Upload, Moon, Sun, Info, HardDrive } from 'lucide-react';
import { Database, FileJson, Upload, Moon, Sun, Info, HardDrive, LogOut } from 'lucide-react';
import { ConfirmModal } from '../components/ConfirmModal';
import { importDrawings } from '../utils/importUtils';
import { useTheme } from '../context/ThemeContext';
import { useAuth } from '../context/AuthContext';
export const Settings: React.FC = () => {
const [collections, setCollections] = useState<Collection[]>([]);
const navigate = useNavigate();
const { theme, toggleTheme } = useTheme();
const { state: authState, logout, refreshStatus } = useAuth();
const [importConfirmation, setImportConfirmation] = useState<{ isOpen: boolean; file: File | null }>({ isOpen: false, file: null });
const [importError, setImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' });
@@ -91,6 +93,31 @@ export const Settings: React.FC = () => {
</div>
</button>
{authState.enabled && authState.authenticated && (
<button
onClick={async () => {
try {
await logout();
} catch (err) {
console.error('Failed to log out:', err);
}
}}
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group"
>
<div className="w-16 h-16 bg-rose-50 dark:bg-neutral-800 rounded-2xl flex items-center justify-center border-2 border-rose-100 dark:border-neutral-700 group-hover:border-rose-200 dark:group-hover:border-neutral-600 transition-colors">
<LogOut size={32} className="text-rose-600 dark:text-rose-400" />
</div>
<div className="text-center">
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-1">
Log out
</h3>
<p className="text-sm text-slate-500 dark:text-neutral-400 font-medium">
End your current session
</p>
</div>
</button>
)}
<button
onClick={() => window.location.href = `${api.API_URL}/export`}
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group"
@@ -154,11 +181,19 @@ export const Settings: React.FC = () => {
formData.append('db', databaseFile);
try {
const csrfHeaders = await api.getCsrfHeaders();
const res = await fetch(`${api.API_URL}/import/sqlite/verify`, {
method: 'POST',
body: formData,
credentials: 'include',
headers: csrfHeaders,
});
if (res.status === 401) {
await refreshStatus();
return;
}
if (!res.ok) {
const errorData = await res.json();
setImportError({ isOpen: true, message: errorData.error || 'Invalid database file.' });
@@ -244,11 +279,19 @@ export const Settings: React.FC = () => {
formData.append('db', importConfirmation.file);
try {
const csrfHeaders = await api.getCsrfHeaders();
const res = await fetch(`${api.API_URL}/import/sqlite`, {
method: 'POST',
body: formData,
credentials: 'include',
headers: csrfHeaders,
});
if (res.status === 401) {
await refreshStatus();
return;
}
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || 'Import failed');