Revert "Added optional authentication and user management"

This commit is contained in:
MartinNYHC
2026-01-21 15:58:24 +01:00
committed by GitHub
parent c3cbdd8d73
commit a9f340f2c9
30 changed files with 8775 additions and 10808 deletions
+22 -88
View File
@@ -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 });
})
);
});
+1 -4
View File
@@ -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
View File
@@ -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 -33
View File
@@ -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>
-116
View File
@@ -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;
}
-103
View File
@@ -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>
);
}
+3 -242
View File
@@ -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>
);
}
-161
View File
@@ -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>
);
}
-398
View File
@@ -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>
);
}