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:
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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++;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user