feat(auth): enhance authentication system with multi-user support and admin role management

- Implemented multi-user authentication with role-based access control.
- Added environment variables for initial admin user setup.
- Updated README and example environment file with new authentication options.
- Introduced user and system configuration models in the database schema.
- Enhanced authentication middleware to support user registration and role management.
- Updated frontend to handle new authentication flows, including admin user creation and role updates.
This commit is contained in:
Adrian Acala
2026-01-18 09:43:32 -08:00
parent 20ef4ee295
commit 1a52fe80f3
27 changed files with 1692 additions and 237 deletions
+40 -1
View File
@@ -11,7 +11,9 @@ export const api = axios.create({
export type AuthStatus = {
enabled: boolean;
authenticated: boolean;
user: { username: string } | null;
registrationEnabled: boolean;
bootstrapRequired: boolean;
user: { id: string; username: string | null; email: string | null; role: "ADMIN" | "USER" } | null;
};
let unauthorizedHandler: (() => void) | null = null;
@@ -133,6 +135,43 @@ export const logout = async () => {
return response.data;
};
export const register = async (payload: {
username?: string;
email?: string;
password: string;
}) => {
const response = await api.post<{ user: AuthStatus["user"] }>("/auth/register", payload);
return response.data;
};
export const bootstrapAdmin = async (payload: {
username?: string;
email?: string;
password: string;
}) => {
const response = await api.post<{ user: AuthStatus["user"]; authenticated: boolean }>(
"/auth/bootstrap",
payload
);
return response.data;
};
export const setRegistrationEnabled = async (enabled: boolean) => {
const response = await api.post<{ registrationEnabled: boolean }>(
"/auth/registration/toggle",
{ enabled }
);
return response.data;
};
export const updateUserRole = async (identifier: string, role: "ADMIN" | "USER") => {
const response = await api.post<{ user: AuthStatus["user"] }>("/auth/admins", {
identifier,
role,
});
return response.data;
};
const coerceTimestamp = (value: string | number | Date): number => {
if (typeof value === "number") return value;
if (value instanceof Date) return value.getTime();
+57 -2
View File
@@ -12,7 +12,9 @@ import * as api from "../api";
type AuthState = {
enabled: boolean;
authenticated: boolean;
user: { username: string } | null;
registrationEnabled: boolean;
bootstrapRequired: boolean;
user: { id: string; username: string | null; email: string | null; role: "ADMIN" | "USER" } | null;
loading: boolean;
statusError: string | null;
};
@@ -21,6 +23,10 @@ type AuthContextValue = {
state: AuthState;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (payload: { username?: string; email?: string; password: string }) => Promise<void>;
bootstrapAdmin: (payload: { username?: string; email?: string; password: string }) => Promise<void>;
setRegistrationEnabled: (enabled: boolean) => Promise<void>;
updateUserRole: (identifier: string, role: "ADMIN" | "USER") => Promise<void>;
refreshStatus: () => Promise<void>;
};
@@ -30,6 +36,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const [state, setState] = useState<AuthState>({
enabled: false,
authenticated: false,
registrationEnabled: false,
bootstrapRequired: false,
user: null,
loading: true,
statusError: null,
@@ -45,6 +53,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setState({
enabled: status.enabled,
authenticated: status.authenticated,
registrationEnabled: status.registrationEnabled,
bootstrapRequired: status.bootstrapRequired,
user: status.user,
loading: false,
statusError: null,
@@ -89,14 +99,59 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
await refreshStatus();
}, [refreshStatus]);
const register = useCallback(
async (payload: { username?: string; email?: string; password: string }) => {
await api.register(payload);
await refreshStatus();
},
[refreshStatus]
);
const bootstrapAdmin = useCallback(
async (payload: { username?: string; email?: string; password: string }) => {
await api.bootstrapAdmin(payload);
await refreshStatus();
},
[refreshStatus]
);
const setRegistrationEnabled = useCallback(
async (enabled: boolean) => {
await api.setRegistrationEnabled(enabled);
await refreshStatus();
},
[refreshStatus]
);
const updateUserRole = useCallback(
async (identifier: string, role: "ADMIN" | "USER") => {
await api.updateUserRole(identifier, role);
await refreshStatus();
},
[refreshStatus]
);
const value = useMemo<AuthContextValue>(
() => ({
state,
login,
logout,
register,
bootstrapAdmin,
setRegistrationEnabled,
updateUserRole,
refreshStatus,
}),
[state, login, logout, refreshStatus]
[
state,
login,
logout,
register,
bootstrapAdmin,
setRegistrationEnabled,
updateUserRole,
refreshStatus,
]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+89 -12
View File
@@ -4,22 +4,54 @@ import { Logo } from "../components/Logo";
import { useAuth } from "../context/AuthContext";
export const Login: React.FC = () => {
const { login } = useAuth();
const [username, setUsername] = useState("");
const { state, login, register, bootstrapAdmin } = useAuth();
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showRegister, setShowRegister] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const isBootstrap = state.bootstrapRequired;
const canRegister = state.registrationEnabled;
const parseIdentifier = () => {
const trimmed = identifier.trim();
if (!trimmed) return { username: "", email: "" };
if (trimmed.includes("@")) {
return { email: trimmed, username: "" };
}
return { username: trimmed, email: "" };
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await login(username.trim(), password);
if (showRegister || isBootstrap) {
if (password !== confirmPassword) {
setError("Passwords do not match.");
return;
}
const { username, email } = parseIdentifier();
if (!username && !email) {
setError("Enter a username or email address.");
return;
}
if (isBootstrap) {
await bootstrapAdmin({ username: username || undefined, email: email || undefined, password });
} else {
await register({ username: username || undefined, email: email || undefined, password });
await login(identifier.trim(), password);
}
} else {
await login(identifier.trim(), password);
}
} catch (err) {
console.error("Login failed:", err);
setError("Invalid username or password.");
console.error("Auth failed:", err);
setError("Unable to complete authentication.");
} finally {
setIsSubmitting(false);
}
@@ -34,20 +66,24 @@ export const Login: React.FC = () => {
ExcaliDash
</h1>
<p className="mt-2 text-sm text-slate-500 dark:text-neutral-400 font-medium">
Sign in to access your drawings
{isBootstrap
? "Create the initial admin account"
: showRegister
? "Create a new account"
: "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
Username or Email
<input
type="text"
name="username"
name="identifier"
autoComplete="username"
required
value={username}
onChange={(event) => setUsername(event.target.value)}
value={identifier}
onChange={(event) => setIdentifier(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>
@@ -57,7 +93,7 @@ export const Login: React.FC = () => {
<input
type="password"
name="password"
autoComplete="current-password"
autoComplete={showRegister || isBootstrap ? "new-password" : "current-password"}
required
value={password}
onChange={(event) => setPassword(event.target.value)}
@@ -65,6 +101,21 @@ export const Login: React.FC = () => {
/>
</label>
{(showRegister || isBootstrap) && (
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
Confirm Password
<input
type="password"
name="confirm-password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(event) => setConfirmPassword(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}
@@ -76,8 +127,34 @@ export const Login: React.FC = () => {
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"}
{isSubmitting ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : isBootstrap ? (
"Create Admin"
) : showRegister ? (
"Create account"
) : (
"Sign in"
)}
</button>
{!isBootstrap && (
<button
type="button"
onClick={() => {
setError(null);
setShowRegister((prev) => !prev);
}}
disabled={!canRegister && !showRegister}
className="w-full text-sm font-semibold text-slate-600 dark:text-neutral-300 disabled:opacity-50"
>
{showRegister
? "Back to sign in"
: canRegister
? "Need an account? Register"
: "Registration is disabled"}
</button>
)}
</form>
</div>
</div>
+111 -2
View File
@@ -3,7 +3,7 @@ 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, LogOut } from 'lucide-react';
import { Database, FileJson, Upload, Moon, Sun, Info, HardDrive, LogOut, UserCog } from 'lucide-react';
import { ConfirmModal } from '../components/ConfirmModal';
import { importDrawings } from '../utils/importUtils';
import { useTheme } from '../context/ThemeContext';
@@ -13,7 +13,11 @@ export const Settings: React.FC = () => {
const [collections, setCollections] = useState<Collection[]>([]);
const navigate = useNavigate();
const { theme, toggleTheme } = useTheme();
const { state: authState, logout, refreshStatus } = useAuth();
const { state: authState, logout, refreshStatus, setRegistrationEnabled, updateUserRole } = useAuth();
const [adminIdentifier, setAdminIdentifier] = useState('');
const [isAdminSubmitting, setIsAdminSubmitting] = useState(false);
const [adminError, setAdminError] = useState<string | null>(null);
const [importConfirmation, setImportConfirmation] = useState<{ isOpen: boolean; file: File | null }>({ isOpen: false, file: null });
const [importError, setImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' });
@@ -118,6 +122,111 @@ export const Settings: React.FC = () => {
</button>
)}
{authState.authenticated && authState.user?.role === 'ADMIN' && (
<div 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)]">
<div className="w-16 h-16 bg-purple-50 dark:bg-neutral-800 rounded-2xl flex items-center justify-center border-2 border-purple-100 dark:border-neutral-700">
<UserCog size={32} className="text-purple-600 dark:text-purple-400" />
</div>
<div className="text-center">
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-1">
User Registration
</h3>
<p className="text-sm text-slate-500 dark:text-neutral-400 font-medium">
{authState.registrationEnabled ? 'Registration is enabled' : 'Registration is disabled'}
</p>
</div>
<button
onClick={async () => {
try {
await setRegistrationEnabled(!authState.registrationEnabled);
} catch (err) {
console.error('Failed to toggle registration:', err);
}
}}
className="w-full rounded-xl border-2 border-black dark:border-neutral-700 px-4 py-2 text-sm font-bold text-slate-900 dark:text-white bg-white dark:bg-neutral-800 shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)]"
>
{authState.registrationEnabled ? 'Disable registration' : 'Enable registration'}
</button>
</div>
)}
{authState.authenticated && authState.user?.role === 'ADMIN' && (
<div 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)]">
<div className="text-center">
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-1">
Admin Access
</h3>
<p className="text-sm text-slate-500 dark:text-neutral-400 font-medium">
Promote or demote users by username/email
</p>
</div>
<input
type="text"
value={adminIdentifier}
onChange={(event) => setAdminIdentifier(event.target.value)}
placeholder="username or email"
className="w-full rounded-xl border-2 border-black dark:border-neutral-700 bg-white dark:bg-neutral-800 px-4 py-3 text-sm 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)]"
/>
<div className="flex w-full gap-3">
<button
type="button"
disabled={isAdminSubmitting}
onClick={async () => {
const identifier = adminIdentifier.trim();
if (!identifier) {
setAdminError('Enter a username or email.');
return;
}
setAdminError(null);
setIsAdminSubmitting(true);
try {
await updateUserRole(identifier, 'ADMIN');
setAdminIdentifier('');
} catch (err) {
console.error('Failed to promote user:', err);
setAdminError('Unable to update user role.');
} finally {
setIsAdminSubmitting(false);
}
}}
className="flex-1 rounded-xl border-2 border-black dark:border-neutral-700 bg-indigo-600 text-white px-4 py-2 text-sm font-bold shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)]"
>
Make admin
</button>
<button
type="button"
disabled={isAdminSubmitting}
onClick={async () => {
const identifier = adminIdentifier.trim();
if (!identifier) {
setAdminError('Enter a username or email.');
return;
}
setAdminError(null);
setIsAdminSubmitting(true);
try {
await updateUserRole(identifier, 'USER');
setAdminIdentifier('');
} catch (err) {
console.error('Failed to update user:', err);
setAdminError('Unable to update user role.');
} finally {
setIsAdminSubmitting(false);
}
}}
className="flex-1 rounded-xl border-2 border-black dark:border-neutral-700 bg-slate-100 dark:bg-neutral-800 text-slate-900 dark:text-white px-4 py-2 text-sm font-bold shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)]"
>
Remove admin
</button>
</div>
{adminError && (
<p className="text-sm text-rose-600 dark:text-rose-400 font-semibold">
{adminError}
</p>
)}
</div>
)}
<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"
+3 -8
View File
@@ -75,17 +75,12 @@ export const importDrawings = async (
headers: {
// Backend uses this header to apply stricter validation for imported files.
"X-Imported-File": "true",
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
onProgress(fileIndex, 'uploading', percentCompleted);
}
"Content-Type": "application/json",
},
});
if (onProgress) onProgress(fileIndex, 'uploading', 100);
if (onProgress) onProgress(fileIndex, 'success', 100);
successCount++;