feat: Implement initial setup service for admin user creation
- Added `SetupService` to handle the generation and validation of setup tokens. - Integrated setup token generation during application startup if no admin users exist. - Created API endpoints for checking setup status and completing the initial setup. - Updated `AuthService` to include functionality for creating the initial admin user. - Enhanced error handling for setup and authentication processes. - Added frontend components for login and protected routes. - Implemented Zustand store for managing authentication state. - Updated Vite configuration to check setup status and serve the setup page if required. - Documented the initial setup process in `setup.md`.
This commit is contained in:
@@ -1,22 +1,79 @@
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { Layout } from './components/layout/Layout'
|
||||
import { ProtectedRoute } from './components/auth/ProtectedRoute'
|
||||
import { PublicRoute } from './components/auth/PublicRoute'
|
||||
import { Dashboard } from './pages/Dashboard/Dashboard'
|
||||
import { Agents } from './pages/Agents/Agents'
|
||||
import { Configurations } from './pages/Configurations/Configurations'
|
||||
import { Certificates } from './pages/Certificates/Certificates'
|
||||
import { Settings } from './pages/Settings/Settings'
|
||||
import { Login } from './pages/Login/Login'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/agents" element={<Agents />} />
|
||||
<Route path="/configurations" element={<Configurations />} />
|
||||
<Route path="/certificates" element={<Certificates />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Login />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/agents"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Agents />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/configurations"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Configurations />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/certificates"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Certificates />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Settings />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,22 +23,26 @@ client.use({
|
||||
});
|
||||
|
||||
// Type aliases for request/response bodies
|
||||
type LoginRequest = components['schemas']['LoginRequest'];
|
||||
type LoginResponse = components['schemas']['LoginResponse'];
|
||||
type OrganizationResponse = components['schemas']['OrganizationResponse'];
|
||||
type CreateOrganizationRequest = components['schemas']['CreateOrganizationRequest'];
|
||||
type WorkspaceResponse = components['schemas']['WorkspaceResponse'];
|
||||
type CreateWorkspaceRequest = components['schemas']['CreateWorkspaceRequest'];
|
||||
type AgentResponse = components['schemas']['AgentResponse'];
|
||||
type CreateAgentTokenRequest = components['schemas']['CreateAgentTokenRequest'];
|
||||
type VirtualHostResponse = components['schemas']['VirtualHostResponse'];
|
||||
type CreateVirtualHostRequest = components['schemas']['CreateVirtualHostRequest'];
|
||||
type UpstreamResponse = components['schemas']['UpstreamResponse'];
|
||||
type CreateUpstreamRequest = components['schemas']['CreateUpstreamRequest'];
|
||||
type CertificateResponse = components['schemas']['CertificateResponse'];
|
||||
type CreateCertificateRequest = components['schemas']['CreateCertificateRequest'];
|
||||
export type LoginRequest = components['schemas']['LoginRequest'];
|
||||
export type LoginResponse = components['schemas']['LoginResponse'];
|
||||
export type OrganizationResponse = components['schemas']['OrganizationResponse'];
|
||||
export type CreateOrganizationRequest = components['schemas']['CreateOrganizationRequest'];
|
||||
export type WorkspaceResponse = components['schemas']['WorkspaceResponse'];
|
||||
export type CreateWorkspaceRequest = components['schemas']['CreateWorkspaceRequest'];
|
||||
export type AgentResponse = components['schemas']['AgentResponse'];
|
||||
export type CreateAgentTokenRequest = components['schemas']['CreateAgentTokenRequest'];
|
||||
export type VirtualHostResponse = components['schemas']['VirtualHostResponse'];
|
||||
export type CreateVirtualHostRequest = components['schemas']['CreateVirtualHostRequest'];
|
||||
export type UpstreamResponse = components['schemas']['UpstreamResponse'];
|
||||
export type CreateUpstreamRequest = components['schemas']['CreateUpstreamRequest'];
|
||||
export type CertificateResponse = components['schemas']['CertificateResponse'];
|
||||
export type CreateCertificateRequest = components['schemas']['CreateCertificateRequest'];
|
||||
|
||||
export const api = {
|
||||
// Auth
|
||||
login: (body: { email: string; password: string }) =>
|
||||
client.POST('/api/v1/auth/login', { body }),
|
||||
|
||||
// Organizations
|
||||
listOrganizations: () =>
|
||||
client.GET('/api/v1/organizations'),
|
||||
@@ -108,21 +112,6 @@ export const api = {
|
||||
client.POST('/api/v1/workspaces/{id}/certificates', { params: { path: { id: wsId } }, body }),
|
||||
deleteCertificate: (id: string) =>
|
||||
client.DELETE('/api/v1/certificates/{id}', { params: { path: { id } } }),
|
||||
|
||||
// Auth
|
||||
login: (body: LoginRequest) =>
|
||||
client.POST('/api/v1/auth/login', { body }),
|
||||
};
|
||||
|
||||
export type {
|
||||
components,
|
||||
paths,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
OrganizationResponse,
|
||||
WorkspaceResponse,
|
||||
AgentResponse,
|
||||
VirtualHostResponse,
|
||||
UpstreamResponse,
|
||||
CertificateResponse,
|
||||
};
|
||||
export type { paths, components };
|
||||
|
||||
18
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
18
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { token } = useAuthStore();
|
||||
const location = useLocation();
|
||||
|
||||
// Not authenticated - redirect to login
|
||||
if (!token) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
17
frontend/src/components/auth/PublicRoute.tsx
Normal file
17
frontend/src/components/auth/PublicRoute.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
interface PublicRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PublicRoute({ children }: PublicRouteProps) {
|
||||
const { token } = useAuthStore();
|
||||
|
||||
// Already authenticated - redirect to dashboard
|
||||
if (token) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Bell, User } from 'lucide-react'
|
||||
import { Bell, User, LogOut } from 'lucide-react';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export function Header() {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
@@ -8,14 +18,36 @@ export function Header() {
|
||||
NxMesh Admin
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Notifications */}
|
||||
<button className="p-2 text-gray-600 hover:bg-gray-100 rounded-full dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<Bell className="w-5 h-5" />
|
||||
</button>
|
||||
<button className="flex items-center gap-2 p-2 text-gray-600 hover:bg-gray-100 rounded-full dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<User className="w-5 h-5" />
|
||||
|
||||
{/* User Info */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{user?.name || user?.email}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||
{user?.role}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 rounded-full bg-blue-600 flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logout Button */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 text-gray-600 hover:bg-red-100 hover:text-red-600 rounded-full dark:text-gray-300 dark:hover:bg-red-900/30 dark:hover:text-red-400 transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
138
frontend/src/pages/Login/Login.tsx
Normal file
138
frontend/src/pages/Login/Login.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { Loader2, Shield, AlertCircle, ExternalLink } from 'lucide-react';
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { login, isLoading, error, clearError } = useAuthStore();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
|
||||
const success = await login(email, password);
|
||||
|
||||
if (success) {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-xl bg-blue-600">
|
||||
<Shield className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Sign in to NxMesh
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Distributed Nginx Management System
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-300">
|
||||
{error}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm dark:bg-gray-800"
|
||||
placeholder="Email address"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm dark:bg-gray-800"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* First time setup hint */}
|
||||
<div className="mt-6">
|
||||
<div className="rounded-md bg-blue-50 dark:bg-blue-900/20 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
First Time?
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-blue-700 dark:text-blue-200">
|
||||
<p>
|
||||
If this is a fresh installation, you need to complete the initial setup.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
className="mt-2 inline-flex items-center font-medium underline hover:text-blue-600"
|
||||
>
|
||||
Go to Setup Page
|
||||
<ExternalLink className="ml-1 h-3 w-3" />
|
||||
</a>
|
||||
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
(Check server logs for the setup token)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
frontend/src/stores/authStore.ts
Normal file
101
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { api } from '../api/client';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
organization_id: string | null;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
// State
|
||||
token: string | null;
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
login: (email: string, password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Initial state
|
||||
token: null,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
// Login action - returns true if successful, false otherwise
|
||||
login: async (email: string, password: string): Promise<boolean> => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const { data, error: apiError } = await api.login({
|
||||
email,
|
||||
password
|
||||
});
|
||||
|
||||
if (apiError) {
|
||||
const errorData = apiError as { error?: string };
|
||||
set({
|
||||
isLoading: false,
|
||||
error: errorData?.error || 'Login failed. Please check your credentials.'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
set({ isLoading: false, error: 'No response from server' });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store token and user info
|
||||
const { token, user } = data as { token: string; user: User };
|
||||
localStorage.setItem('token', token);
|
||||
|
||||
set({
|
||||
token,
|
||||
user,
|
||||
isLoading: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: 'An unexpected error occurred. Please try again.'
|
||||
});
|
||||
console.error('Login error:', err);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Logout action
|
||||
logout: () => {
|
||||
localStorage.removeItem('token');
|
||||
set({
|
||||
token: null,
|
||||
user: null,
|
||||
error: null
|
||||
});
|
||||
},
|
||||
|
||||
// Clear error
|
||||
clearError: () => {
|
||||
set({ error: null });
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({ token: state.token }),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,10 +1,67 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import http from 'http'
|
||||
|
||||
// Plugin to check setup status and serve setup page from backend if needed
|
||||
const setupCheckPlugin = () => ({
|
||||
name: 'setup-check',
|
||||
configureServer(server) {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
// Only check root paths
|
||||
if (req.url !== '/' && req.url !== '/index.html') {
|
||||
return next()
|
||||
}
|
||||
|
||||
try {
|
||||
// Check setup status from backend
|
||||
const setupStatus = await new Promise<{ setup_required: boolean }>((resolve, reject) => {
|
||||
const request = http.get('http://localhost:8080/api/v1/auth/setup-status', (response) => {
|
||||
let data = ''
|
||||
response.on('data', chunk => data += chunk)
|
||||
response.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data))
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
})
|
||||
request.on('error', reject)
|
||||
request.setTimeout(3000, () => reject(new Error('Timeout')))
|
||||
})
|
||||
|
||||
// If setup required, proxy the setup page from backend
|
||||
if (setupStatus.setup_required) {
|
||||
// Fetch setup page from backend
|
||||
const setupPage = await new Promise<string>((resolve, reject) => {
|
||||
const request = http.get('http://localhost:8080/', (response) => {
|
||||
let data = ''
|
||||
response.on('data', chunk => data += chunk)
|
||||
response.on('end', () => resolve(data))
|
||||
})
|
||||
request.on('error', reject)
|
||||
request.setTimeout(5000, () => reject(new Error('Timeout')))
|
||||
})
|
||||
|
||||
// Serve the setup page
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
res.end(setupPage)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
// If backend is not running or error occurs, continue to app
|
||||
console.warn('[setup-check] Could not check setup status, assuming setup complete')
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), setupCheckPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
|
||||
Reference in New Issue
Block a user