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:
GW_MC
2026-03-03 07:46:49 +00:00
parent 520ab74391
commit 4eddf7e094
24 changed files with 2214 additions and 99 deletions

View File

@@ -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>
)
}

View File

@@ -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 };

View 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}</>;
}

View 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}</>;
}

View File

@@ -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>
)
);
}

View 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>
);
}

View 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 }),
}
)
);

View File

@@ -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'),