feat: implement basic authentication system
This commit is contained in:
+16
-10
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}</>;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -114,6 +114,7 @@ export const Editor: React.FC = () => {
|
||||
const socket = io(socketUrl, {
|
||||
path: '/socket.io',
|
||||
transports: ['websocket', 'polling'],
|
||||
withCredentials: true,
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user