mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 15:54:27 +02:00
Revert "Added optional authentication and user management"
This commit is contained in:
+22
-88
@@ -1,4 +1,4 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Layout } from './components/Layout';
|
||||
import { PrintersPage } from './pages/PrintersPage';
|
||||
@@ -14,13 +14,9 @@ import { FileManagerPage } from './pages/FileManagerPage';
|
||||
import { CameraPage } from './pages/CameraPage';
|
||||
import { ExternalLinkPage } from './pages/ExternalLinkPage';
|
||||
import { SystemInfoPage } from './pages/SystemInfoPage';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { SetupPage } from './pages/SetupPage';
|
||||
import { UsersPage } from './pages/UsersPage';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -36,95 +32,33 @@ function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { authEnabled, loading, user } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
|
||||
}
|
||||
|
||||
if (authEnabled && !user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||
const { authEnabled, loading, user } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
|
||||
}
|
||||
|
||||
// If auth is not enabled, allow access (backward compatibility)
|
||||
if (!authEnabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// If auth is enabled but no user, redirect to login
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// If user is not admin, redirect to home
|
||||
if (user.role !== 'admin') {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function SetupRoute({ children }: { children: React.ReactNode }) {
|
||||
const { authEnabled, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
|
||||
}
|
||||
|
||||
if (authEnabled) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Setup page - only accessible if auth not enabled */}
|
||||
<Route path="/setup" element={<SetupRoute><SetupPage /></SetupRoute>} />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
|
||||
<Route path="/camera/:printerId" element={<CameraPage />} />
|
||||
|
||||
{/* Login page */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
|
||||
<Route path="/camera/:printerId" element={<CameraPage />} />
|
||||
|
||||
{/* Main app with WebSocket for real-time updates */}
|
||||
<Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>
|
||||
<Route index element={<PrintersPage />} />
|
||||
<Route path="archives" element={<ArchivesPage />} />
|
||||
<Route path="queue" element={<QueuePage />} />
|
||||
<Route path="stats" element={<StatsPage />} />
|
||||
<Route path="profiles" element={<ProfilesPage />} />
|
||||
<Route path="maintenance" element={<MaintenancePage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="projects/:id" element={<ProjectDetailPage />} />
|
||||
<Route path="files" element={<FileManagerPage />} />
|
||||
<Route path="settings" element={<AdminRoute><SettingsPage /></AdminRoute>} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="system" element={<SystemInfoPage />} />
|
||||
<Route path="external/:id" element={<ExternalLinkPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
{/* Main app with WebSocket for real-time updates */}
|
||||
<Route element={<WebSocketProvider><Layout /></WebSocketProvider>}>
|
||||
<Route index element={<PrintersPage />} />
|
||||
<Route path="archives" element={<ArchivesPage />} />
|
||||
<Route path="queue" element={<QueuePage />} />
|
||||
<Route path="stats" element={<StatsPage />} />
|
||||
<Route path="profiles" element={<ProfilesPage />} />
|
||||
<Route path="maintenance" element={<MaintenancePage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="projects/:id" element={<ProjectDetailPage />} />
|
||||
<Route path="files" element={<FileManagerPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="system" element={<SystemInfoPage />} />
|
||||
<Route path="external/:id" element={<ExternalLinkPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -49,9 +49,6 @@ describe('Layout', () => {
|
||||
}),
|
||||
http.get('/api/v1/updates/check', () => {
|
||||
return HttpResponse.json({ update_available: false });
|
||||
}),
|
||||
http.get('/api/v1/auth/status', () => {
|
||||
return HttpResponse.json({ auth_enabled: false, requires_setup: false });
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -60,9 +60,6 @@ describe('SettingsPage', () => {
|
||||
}),
|
||||
http.get('/api/v1/virtual-printer/status', () => {
|
||||
return HttpResponse.json({ running: false });
|
||||
}),
|
||||
http.get('/api/v1/auth/status', () => {
|
||||
return HttpResponse.json({ auth_enabled: false, requires_setup: false });
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ThemeProvider } from '../contexts/ThemeContext';
|
||||
import { ToastProvider } from '../contexts/ToastContext';
|
||||
import { AuthProvider } from '../contexts/AuthContext';
|
||||
|
||||
// Create a new QueryClient for each test
|
||||
function createTestQueryClient() {
|
||||
@@ -37,9 +36,7 @@ function AllProviders({ children }: AllProvidersProps) {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</AuthProvider>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
|
||||
+4
-121
@@ -1,47 +1,19 @@
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
// Auth token storage
|
||||
let authToken: string | null = localStorage.getItem('auth_token');
|
||||
|
||||
export function setAuthToken(token: string | null) {
|
||||
authToken = token;
|
||||
if (token) {
|
||||
localStorage.setItem('auth_token', token);
|
||||
} else {
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers as Record<string, string>,
|
||||
};
|
||||
|
||||
// Add auth token if available
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
cache: 'no-store', // Prevent browser caching of API responses
|
||||
headers,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle 401 Unauthorized - clear token and redirect to login
|
||||
if (response.status === 401) {
|
||||
setAuthToken(null);
|
||||
// Don't throw here - let the auth context handle redirect
|
||||
}
|
||||
const error = await response.json().catch(() => ({}));
|
||||
const detail = error.detail;
|
||||
const message = typeof detail === 'string'
|
||||
@@ -1403,97 +1375,8 @@ export interface ExternalLinkUpdate {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user: UserResponse;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UserCreate {
|
||||
username: string;
|
||||
password: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface UserUpdate {
|
||||
username?: string;
|
||||
password?: string;
|
||||
role?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface SetupRequest {
|
||||
auth_enabled: boolean;
|
||||
admin_username?: string;
|
||||
admin_password?: string;
|
||||
}
|
||||
|
||||
export interface SetupResponse {
|
||||
auth_enabled: boolean;
|
||||
admin_created?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
auth_enabled: boolean;
|
||||
requires_setup: boolean;
|
||||
}
|
||||
|
||||
// API functions
|
||||
export const api = {
|
||||
// Authentication
|
||||
getAuthStatus: () => request<AuthStatus>('/auth/status'),
|
||||
setupAuth: (data: SetupRequest) =>
|
||||
request<SetupResponse>('/auth/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
login: (data: LoginRequest) =>
|
||||
request<LoginResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
logout: () =>
|
||||
request<{ message: string }>('/auth/logout', {
|
||||
method: 'POST',
|
||||
}),
|
||||
getCurrentUser: () => request<UserResponse>('/auth/me'),
|
||||
disableAuth: () =>
|
||||
request<{ message: string; auth_enabled: boolean }>('/auth/disable', {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// Users (admin only)
|
||||
getUsers: () => request<UserResponse[]>('/users/'),
|
||||
getUser: (id: number) => request<UserResponse>(`/users/${id}`),
|
||||
createUser: (data: UserCreate) =>
|
||||
request<UserResponse>('/users/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
updateUser: (id: number, data: UserUpdate) =>
|
||||
request<UserResponse>(`/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteUser: (id: number) =>
|
||||
request<void>(`/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
// Printers
|
||||
getPrinters: () => request<Printer[]>('/printers/'),
|
||||
getPrinter: (id: number) => request<Printer>(`/printers/${id}`),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, type LucideIcon } from 'lucide-react';
|
||||
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, type LucideIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
|
||||
@@ -9,7 +9,6 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { api, supportApi, pendingUploadsApi } from '../api/client';
|
||||
import { getIconByName } from './IconPicker';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
@@ -69,7 +68,6 @@ export function Layout() {
|
||||
const { mode, toggleMode } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const { user, authEnabled, logout } = useAuth();
|
||||
const [sidebarExpanded, setSidebarExpanded] = useState(() => {
|
||||
const stored = localStorage.getItem('sidebarExpanded');
|
||||
return stored !== 'false';
|
||||
@@ -170,20 +168,12 @@ export function Layout() {
|
||||
const extLinksMap = useMemo(() => new Map((externalLinks || []).map(link => [`ext-${link.id}`, link])), [externalLinks]);
|
||||
|
||||
// Compute the ordered sidebar: include stored order + any new items
|
||||
// Filter out 'settings' for users with 'user' role
|
||||
const orderedSidebarIds = (() => {
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Determine if settings should be hidden (user role and auth enabled)
|
||||
const hideSettings = authEnabled && user?.role === 'user';
|
||||
|
||||
// Add items in stored order
|
||||
for (const id of sidebarOrder) {
|
||||
// Skip settings if user is not admin
|
||||
if (hideSettings && id === 'settings') {
|
||||
continue;
|
||||
}
|
||||
if (navItemsMap.has(id) || extLinksMap.has(id)) {
|
||||
result.push(id);
|
||||
seen.add(id);
|
||||
@@ -192,10 +182,6 @@ export function Layout() {
|
||||
|
||||
// Add any new internal nav items not in stored order
|
||||
for (const item of defaultNavItems) {
|
||||
// Skip settings if user is not admin
|
||||
if (hideSettings && item.id === 'settings') {
|
||||
continue;
|
||||
}
|
||||
if (!seen.has(item.id)) {
|
||||
result.push(item.id);
|
||||
seen.add(item.id);
|
||||
@@ -579,15 +565,6 @@ export function Layout() {
|
||||
>
|
||||
{mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
{authEnabled && user && (
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title={t('nav.logout', { defaultValue: 'Logout' })}
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Bottom row: version */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
@@ -665,15 +642,6 @@ export function Layout() {
|
||||
>
|
||||
{mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
{authEnabled && user && (
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title={t('nav.logout', { defaultValue: 'Logout' })}
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { api, getAuthToken, setAuthToken } from '../api/client';
|
||||
import type { UserResponse } from '../api/client';
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserResponse | null;
|
||||
authEnabled: boolean;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
refreshAuth: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<UserResponse | null>(null);
|
||||
const [authEnabled, setAuthEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
const status = await api.getAuthStatus();
|
||||
setAuthEnabled(status.auth_enabled);
|
||||
|
||||
if (status.auth_enabled) {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
try {
|
||||
const currentUser = await api.getCurrentUser();
|
||||
setUser(currentUser);
|
||||
} catch {
|
||||
// Token invalid, clear it
|
||||
setAuthToken(null);
|
||||
setUser(null);
|
||||
}
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
} else {
|
||||
// Auth not enabled, allow access
|
||||
setUser(null);
|
||||
// Check if setup is needed
|
||||
if (status.requires_setup && window.location.pathname !== '/setup') {
|
||||
window.location.href = '/setup';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check auth status:', error);
|
||||
setAuthEnabled(false);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkAuthStatus();
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const response = await api.login({ username, password });
|
||||
setAuthToken(response.access_token);
|
||||
setUser(response.user);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setAuthToken(null);
|
||||
setUser(null);
|
||||
api.logout().catch(() => {
|
||||
// Ignore logout errors
|
||||
});
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
if (authEnabled && getAuthToken()) {
|
||||
try {
|
||||
const currentUser = await api.getCurrentUser();
|
||||
setUser(currentUser);
|
||||
} catch {
|
||||
setAuthToken(null);
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAuth = async () => {
|
||||
await checkAuthStatus();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
authEnabled,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
refreshUser,
|
||||
refreshAuth,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const { mode } = useTheme();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: () => login(username, password),
|
||||
onSuccess: () => {
|
||||
showToast('Logged in successfully');
|
||||
navigate('/');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || 'Login failed', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) {
|
||||
showToast('Please enter username and password', 'error');
|
||||
return;
|
||||
}
|
||||
loginMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
|
||||
<div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<img
|
||||
src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
|
||||
alt="Bambuddy"
|
||||
className="h-16"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-white">
|
||||
Bambuddy Login
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-bambu-gray">
|
||||
Sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-white mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Enter your username"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-white mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Enter your password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loginMutation.isPending}
|
||||
className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green"
|
||||
>
|
||||
{loginMutation.isPending ? 'Logging in...' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock } from 'lucide-react';
|
||||
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi, Home, Video } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { formatDateOnly } from '../utils/date';
|
||||
import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
|
||||
import { Card, CardContent, CardHeader } from '../components/Card';
|
||||
@@ -31,10 +29,8 @@ import { Palette } from 'lucide-react';
|
||||
|
||||
export function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { showToast, showPersistentToast, dismissToast } = useToast();
|
||||
const { authEnabled, user, refreshAuth } = useAuth();
|
||||
const {
|
||||
mode,
|
||||
darkStyle, darkBackground, darkAccent,
|
||||
@@ -50,7 +46,7 @@ export function SettingsPage() {
|
||||
const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
|
||||
const [showLogViewer, setShowLogViewer] = useState(false);
|
||||
const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'network' | 'plugs' | 'notifications' | 'filament' | 'apikeys' | 'virtual-printer' | 'users'>('general');
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'network' | 'plugs' | 'notifications' | 'filament' | 'apikeys' | 'virtual-printer'>('general');
|
||||
const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
|
||||
const [newAPIKeyName, setNewAPIKeyName] = useState('');
|
||||
const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
|
||||
@@ -70,7 +66,6 @@ export function SettingsPage() {
|
||||
const [showRestoreModal, setShowRestoreModal] = useState(false);
|
||||
const [showTelemetryInfo, setShowTelemetryInfo] = useState(false);
|
||||
const [showReleaseNotes, setShowReleaseNotes] = useState(false);
|
||||
const [showDisableAuthConfirm, setShowDisableAuthConfirm] = useState(false);
|
||||
|
||||
// Home Assistant test connection state
|
||||
const [haTestResult, setHaTestResult] = useState<{ success: boolean; message: string | null; error: string | null } | null>(null);
|
||||
@@ -479,7 +474,7 @@ export function SettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-1 mb-6 border-b border-bambu-dark-tertiary overflow-x-auto">
|
||||
<div className="flex gap-1 mb-6 border-b border-bambu-dark-tertiary">
|
||||
<button
|
||||
onClick={() => setActiveTab('general')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
@@ -573,20 +568,6 @@ export function SettingsPage() {
|
||||
Virtual Printer
|
||||
<span className={`w-2 h-2 rounded-full ${virtualPrinterRunning ? 'bg-green-400' : 'bg-gray-500'}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
|
||||
activeTab === 'users'
|
||||
? 'text-bambu-green border-bambu-green'
|
||||
: 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
Users
|
||||
{authEnabled && (
|
||||
<span className={`w-2 h-2 rounded-full ${authEnabled ? 'bg-green-400' : 'bg-gray-500'}`} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* General Tab */}
|
||||
@@ -2885,226 +2866,6 @@ export function SettingsPage() {
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Tab */}
|
||||
{activeTab === 'users' && (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-bambu-green" />
|
||||
User Authentication
|
||||
</h2>
|
||||
<p className="text-sm text-bambu-gray mt-1">
|
||||
Enable authentication to secure your Bambuddy instance and manage user access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
{!authEnabled ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${authEnabled ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>
|
||||
{authEnabled ? (
|
||||
<Lock className="w-6 h-6 text-green-400" />
|
||||
) : (
|
||||
<Unlock className="w-6 h-6 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-white font-medium">Authentication Disabled</h3>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Your Bambuddy instance is currently accessible without authentication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-bambu-dark-tertiary">
|
||||
<p className="text-sm text-bambu-gray mb-4">
|
||||
Enable authentication to:
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-bambu-gray mb-4">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
|
||||
<span>Require login to access the system</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
|
||||
<span>Manage multiple users with different roles</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
|
||||
<span>Control access to printer settings and user management</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
onClick={() => navigate('/setup')}
|
||||
className="w-full"
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
Activate Authentication
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center bg-green-500/20">
|
||||
<Lock className="w-6 h-6 text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-white font-medium">Authentication Enabled</h3>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Your Bambuddy instance is secured with authentication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="pt-4 border-t border-bambu-dark-tertiary">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-sm text-bambu-gray">Current User</p>
|
||||
<p className="text-white font-medium">{user.username}</p>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
Role: <span className="capitalize">{user.role}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-500/20 text-purple-300'
|
||||
: 'bg-blue-500/20 text-blue-300'
|
||||
}`}>
|
||||
{user.role === 'admin' ? 'Admin' : 'User'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 border-t border-bambu-dark-tertiary space-y-3">
|
||||
<Button
|
||||
onClick={() => navigate('/users')}
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
Manage Users
|
||||
</Button>
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<Button
|
||||
onClick={() => setShowDisableAuthConfirm(true)}
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
>
|
||||
<Unlock className="w-4 h-4" />
|
||||
Disable Authentication
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{authEnabled && (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-bambu-green" />
|
||||
Role Permissions
|
||||
</h2>
|
||||
<p className="text-sm text-bambu-gray mt-1">
|
||||
Overview of what each role can do.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 text-purple-300" />
|
||||
</div>
|
||||
<h3 className="text-white font-medium">Admin</h3>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-bambu-gray">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
|
||||
<span>Manage printer settings</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
|
||||
<span>Create, edit, and delete users</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
|
||||
<span>Access all system features</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center">
|
||||
<Users className="w-4 h-4 text-blue-300" />
|
||||
</div>
|
||||
<h3 className="text-white font-medium">User</h3>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-bambu-gray">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
|
||||
<span>Send print jobs</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
|
||||
<span>Manage files and archives</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
|
||||
<span>Manage filament</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disable Authentication Confirmation Modal */}
|
||||
{showDisableAuthConfirm && (
|
||||
<ConfirmModal
|
||||
title="Disable Authentication"
|
||||
message="Are you sure you want to disable authentication? This will make your Bambuddy instance accessible without login. All users will remain in the database but authentication will be disabled."
|
||||
confirmText="Disable Authentication"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await api.disableAuth();
|
||||
showToast('Authentication disabled successfully', 'success');
|
||||
await refreshAuth();
|
||||
setShowDisableAuthConfirm(false);
|
||||
// Refresh the page to ensure all protected routes are accessible
|
||||
window.location.href = '/';
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to disable authentication';
|
||||
showToast(message, 'error');
|
||||
}
|
||||
}}
|
||||
onCancel={() => setShowDisableAuthConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api } from '../api/client';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
export function SetupPage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { mode } = useTheme();
|
||||
const [authEnabled, setAuthEnabled] = useState(false);
|
||||
const [adminUsername, setAdminUsername] = useState('');
|
||||
const [adminPassword, setAdminPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
const setupMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.setupAuth({
|
||||
auth_enabled: authEnabled,
|
||||
admin_username: authEnabled ? adminUsername : undefined,
|
||||
admin_password: authEnabled ? adminPassword : undefined,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
if (data.auth_enabled && data.admin_created) {
|
||||
showToast('Authentication enabled and admin user created');
|
||||
navigate('/login');
|
||||
} else {
|
||||
showToast('Setup completed');
|
||||
navigate('/');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (authEnabled) {
|
||||
if (!adminUsername || !adminPassword) {
|
||||
showToast('Please enter admin username and password', 'error');
|
||||
return;
|
||||
}
|
||||
if (adminPassword !== confirmPassword) {
|
||||
showToast('Passwords do not match', 'error');
|
||||
return;
|
||||
}
|
||||
if (adminPassword.length < 6) {
|
||||
showToast('Password must be at least 6 characters', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setupMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
|
||||
<div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<img
|
||||
src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
|
||||
alt="Bambuddy"
|
||||
className="h-16"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-white">
|
||||
Bambuddy Setup
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-bambu-gray">
|
||||
Configure authentication for your Bambuddy instance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center p-4 bg-bambu-dark-secondary/50 rounded-lg border border-bambu-dark-tertiary">
|
||||
<input
|
||||
id="auth-enabled"
|
||||
type="checkbox"
|
||||
checked={authEnabled}
|
||||
onChange={(e) => setAuthEnabled(e.target.checked)}
|
||||
className="h-4 w-4 text-bambu-green focus:ring-bambu-green border-bambu-dark-tertiary rounded bg-bambu-dark-secondary"
|
||||
/>
|
||||
<label htmlFor="auth-enabled" className="ml-3 block text-sm font-medium text-white">
|
||||
Enable Authentication
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{authEnabled && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div>
|
||||
<label htmlFor="admin-username" className="block text-sm font-medium text-white mb-2">
|
||||
Admin Username
|
||||
</label>
|
||||
<input
|
||||
id="admin-username"
|
||||
type="text"
|
||||
required
|
||||
value={adminUsername}
|
||||
onChange={(e) => setAdminUsername(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Enter admin username"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="admin-password" className="block text-sm font-medium text-white mb-2">
|
||||
Admin Password
|
||||
</label>
|
||||
<input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
required
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Enter admin password"
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirm-password" className="block text-sm font-medium text-white mb-2">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Confirm admin password"
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={setupMutation.isPending}
|
||||
className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green"
|
||||
>
|
||||
{setupMutation.isPending ? 'Setting up...' : 'Complete Setup'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { UserCreate, UserUpdate } from '../api/client';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { Button } from '../components/Button';
|
||||
import { Card, CardContent, CardHeader } from '../components/Card';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
|
||||
export function UsersPage() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<number | null>(null);
|
||||
const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<UserCreate>({
|
||||
username: '',
|
||||
password: '',
|
||||
role: 'user',
|
||||
});
|
||||
|
||||
// Close modal on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && showCreateModal) {
|
||||
setShowCreateModal(false);
|
||||
setFormData({ username: '', password: '', role: 'user' });
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [showCreateModal]);
|
||||
|
||||
const { data: users = [], isLoading } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => api.getUsers(),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: UserCreate) => api.createUser(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
setShowCreateModal(false);
|
||||
setFormData({ username: '', password: '', role: 'user' });
|
||||
showToast('User created successfully');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UserUpdate }) => api.updateUser(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
setEditingUser(null);
|
||||
setFormData({ username: '', password: '', role: 'user' });
|
||||
showToast('User updated successfully');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => api.deleteUser(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
showToast('User deleted successfully');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!formData.username || !formData.password) {
|
||||
showToast('Please fill in all required fields', 'error');
|
||||
return;
|
||||
}
|
||||
createMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const handleUpdate = (id: number) => {
|
||||
const updateData: UserUpdate = {
|
||||
username: formData.username || undefined,
|
||||
password: formData.password || undefined,
|
||||
role: formData.role,
|
||||
};
|
||||
// Remove password if empty
|
||||
if (!updateData.password) {
|
||||
delete updateData.password;
|
||||
}
|
||||
updateMutation.mutate({ id, data: updateData });
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
setDeleteUserId(id);
|
||||
};
|
||||
|
||||
const startEdit = (user: { id: number; username: string; role: string }) => {
|
||||
setEditingUser(user.id);
|
||||
setFormData({
|
||||
username: user.username,
|
||||
password: '',
|
||||
role: user.role,
|
||||
});
|
||||
};
|
||||
|
||||
if (currentUser?.role !== 'admin') {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<Shield className="w-5 h-5" />
|
||||
<p className="text-white">You do not have permission to access this page.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<UsersIcon className="w-6 h-6 text-bambu-green" />
|
||||
User Management
|
||||
</h1>
|
||||
<p className="text-sm text-bambu-gray mt-1">
|
||||
Manage users and their access to your Bambuddy instance
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowCreateModal(true);
|
||||
setFormData({ username: '', password: '', role: 'user' });
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-bambu-dark-tertiary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
|
||||
Username
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-bambu-dark-tertiary">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-bambu-dark-tertiary/50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
|
||||
{editingUser === user.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green"
|
||||
/>
|
||||
) : (
|
||||
user.username
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{editingUser === user.id ? (
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as 'admin' | 'user' })}
|
||||
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-500/20 text-purple-300'
|
||||
: 'bg-blue-500/20 text-blue-300'
|
||||
}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
user.is_active
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'bg-red-500/20 text-red-400'
|
||||
}`}>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
{editingUser === user.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleUpdate(user.id)}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setEditingUser(null);
|
||||
setFormData({ username: '', password: '', role: 'user' });
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => startEdit(user)}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
Edit
|
||||
</Button>
|
||||
{user.id !== currentUser?.id && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create User Modal */}
|
||||
{showCreateModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setFormData({ username: '', password: '', role: 'user' });
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className="w-full max-w-md"
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<UsersIcon className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">Create User</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setFormData({ username: '', password: '', role: 'user' });
|
||||
}}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Enter username"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Enter password"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as 'admin' | 'user' })}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setFormData({ username: '', password: '', role: 'user' });
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={createMutation.isPending || !formData.username || !formData.password}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create User
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteUserId !== null && (
|
||||
<ConfirmModal
|
||||
title="Delete User"
|
||||
message={`Are you sure you want to delete this user? This action cannot be undone.`}
|
||||
confirmText="Delete User"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
deleteMutation.mutate(deleteUserId);
|
||||
setDeleteUserId(null);
|
||||
}}
|
||||
onCancel={() => setDeleteUserId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user