Skip to content

Commit 6a8fd75

Browse files
authored
Merge pull request #92 from nebari-dev/frontend-mode-awareness
5. feat: frontend mode awareness and settings page
2 parents 7bab575 + 0107ee8 commit 6a8fd75

File tree

8 files changed

+365
-50
lines changed

8 files changed

+365
-50
lines changed

frontend/src/App.tsx

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import { useEffect } from 'react';
12
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
23
import { QueryClientProvider, useQuery } from '@tanstack/react-query';
34
import { queryClient } from './lib/queryClient';
45
import { useAuthStore } from './store/authStore';
6+
import { useModeStore } from './store/modeStore';
57
import { Login } from './pages/Login';
68
import { Workspaces } from './pages/Workspaces';
79
import { WorkspaceDetail } from './pages/WorkspaceDetail';
810
import { Jobs } from './pages/Jobs';
11+
import { Settings } from './pages/Settings';
912
import { AdminDashboard } from './pages/admin/AdminDashboard';
1013
import { UserManagement } from './pages/admin/UserManagement';
1114
import { AuditLogs } from './pages/admin/AuditLogs';
@@ -14,8 +17,31 @@ import { Layout } from './components/layout/Layout';
1417
import { adminApi } from './api/admin';
1518
import { Loader2 } from 'lucide-react';
1619

20+
// Load mode before rendering any routes
21+
const ModeLoader = ({ children }: { children: React.ReactNode }) => {
22+
const { loading, fetchMode } = useModeStore();
23+
24+
useEffect(() => {
25+
fetchMode();
26+
}, [fetchMode]);
27+
28+
if (loading) {
29+
return (
30+
<div className="flex items-center justify-center h-screen">
31+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
32+
</div>
33+
);
34+
}
35+
return <>{children}</>;
36+
};
37+
1738
const PrivateRoute = ({ children }: { children: React.ReactNode }) => {
1839
const isAuthenticated = useAuthStore((state) => state.isAuthenticated());
40+
const isLocalMode = useModeStore((state) => state.isLocalMode());
41+
42+
// In local mode, auth is bypassed
43+
if (isLocalMode) return <>{children}</>;
44+
1945
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />;
2046
};
2147

@@ -52,29 +78,32 @@ function App() {
5278
return (
5379
<QueryClientProvider client={queryClient}>
5480
<BrowserRouter>
55-
<Routes>
56-
<Route path="/login" element={<Login />} />
57-
<Route
58-
path="/"
59-
element={
60-
<PrivateRoute>
61-
<Layout />
62-
</PrivateRoute>
63-
}
64-
>
65-
<Route index element={<Navigate to="/workspaces" replace />} />
66-
<Route path="workspaces" element={<Workspaces />} />
67-
<Route path="workspaces/:id" element={<WorkspaceDetail />} />
68-
<Route path="jobs" element={<Jobs />} />
81+
<ModeLoader>
82+
<Routes>
83+
<Route path="/login" element={<Login />} />
84+
<Route
85+
path="/"
86+
element={
87+
<PrivateRoute>
88+
<Layout />
89+
</PrivateRoute>
90+
}
91+
>
92+
<Route index element={<Navigate to="/workspaces" replace />} />
93+
<Route path="workspaces" element={<Workspaces />} />
94+
<Route path="workspaces/:id" element={<WorkspaceDetail />} />
95+
<Route path="jobs" element={<Jobs />} />
96+
<Route path="settings" element={<Settings />} />
6997

70-
<Route element={<AdminRoute />}>
71-
<Route path="admin" element={<AdminDashboard />} />
72-
<Route path="admin/users" element={<UserManagement />} />
73-
<Route path="admin/audit-logs" element={<AuditLogs />} />
74-
<Route path="admin/registries" element={<RegistryManagement />} />
98+
<Route element={<AdminRoute />}>
99+
<Route path="admin" element={<AdminDashboard />} />
100+
<Route path="admin/users" element={<UserManagement />} />
101+
<Route path="admin/audit-logs" element={<AuditLogs />} />
102+
<Route path="admin/registries" element={<RegistryManagement />} />
103+
</Route>
75104
</Route>
76-
</Route>
77-
</Routes>
105+
</Routes>
106+
</ModeLoader>
78107
</BrowserRouter>
79108
</QueryClientProvider>
80109
);

frontend/src/api/client.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import axios from 'axios';
22
import { queryClient } from '@/lib/queryClient';
3+
import { useModeStore } from '@/store/modeStore';
34

45
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1';
56

@@ -27,10 +28,14 @@ apiClient.interceptors.response.use(
2728
(response) => response,
2829
(error) => {
2930
if (error.response?.status === 401) {
30-
localStorage.removeItem('auth_token');
31-
// Clear all query cache to prevent stale data
32-
queryClient.clear();
33-
window.location.href = '/login';
31+
// In local mode, don't redirect to login
32+
const { mode } = useModeStore.getState();
33+
if (mode !== 'local') {
34+
localStorage.removeItem('auth_token');
35+
// Clear all query cache to prevent stale data
36+
queryClient.clear();
37+
window.location.href = '/login';
38+
}
3439
}
3540
return Promise.reject(error);
3641
}

frontend/src/api/remote.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { apiClient } from '@/api/client';
2+
3+
export interface RemoteServerStatus {
4+
connected: boolean;
5+
server_url: string;
6+
username: string;
7+
}
8+
9+
export interface ConnectServerRequest {
10+
url: string;
11+
username: string;
12+
password: string;
13+
}
14+
15+
export const remoteApi = {
16+
getServer: () =>
17+
apiClient.get<RemoteServerStatus>('/remote/server').then((r) => r.data),
18+
connectServer: (req: ConnectServerRequest) =>
19+
apiClient.post<RemoteServerStatus>('/remote/connect', req).then((r) => r.data),
20+
disconnectServer: () =>
21+
apiClient.delete('/remote/server').then((r) => r.data),
22+
};

frontend/src/components/layout/Layout.tsx

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
22
import { useAuthStore } from '@/store/authStore';
3+
import { useModeStore } from '@/store/modeStore';
34
import { useIsAdmin } from '@/hooks/useAdmin';
45
import { Button } from '@/components/ui/button';
5-
import { LogOut, Boxes, ListTodo, Shield } from 'lucide-react';
6+
import { LogOut, Boxes, ListTodo, Shield, Settings } from 'lucide-react';
67
import { useState } from 'react';
78

89
export const Layout = () => {
910
const { user, clearAuth } = useAuthStore();
11+
const isLocalMode = useModeStore((s) => s.isLocalMode());
1012
const navigate = useNavigate();
1113
const { data: isAdmin } = useIsAdmin();
1214
const [avatarError, setAvatarError] = useState(false);
@@ -63,32 +65,47 @@ export const Layout = () => {
6365
)}
6466
</NavLink>
6567
)}
68+
{isLocalMode && (
69+
<NavLink to="/settings">
70+
{({ isActive }) => (
71+
<Button
72+
variant={isActive ? 'secondary' : 'ghost'}
73+
className="gap-2"
74+
>
75+
<Settings className="h-4 w-4" />
76+
Settings
77+
</Button>
78+
)}
79+
</NavLink>
80+
)}
6681
</nav>
6782
</div>
68-
<div className="flex items-center gap-4">
69-
{user?.avatar_url && !avatarError ? (
70-
<img
71-
src={user.avatar_url}
72-
alt={user.username}
73-
className="h-8 w-8 rounded-full"
74-
referrerPolicy="no-referrer-when-downgrade"
75-
crossOrigin="anonymous"
76-
onError={() => setAvatarError(true)}
77-
/>
78-
) : (
79-
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
80-
<span className="text-sm font-medium text-primary">
81-
{user?.username?.charAt(0).toUpperCase()}
82-
</span>
83-
</div>
84-
)}
85-
<span className="text-sm font-medium text-foreground">
86-
{user?.username}
87-
</span>
88-
<Button variant="ghost" size="icon" onClick={handleLogout}>
89-
<LogOut className="h-4 w-4" />
90-
</Button>
91-
</div>
83+
{!isLocalMode && (
84+
<div className="flex items-center gap-4">
85+
{user?.avatar_url && !avatarError ? (
86+
<img
87+
src={user.avatar_url}
88+
alt={user.username}
89+
className="h-8 w-8 rounded-full"
90+
referrerPolicy="no-referrer-when-downgrade"
91+
crossOrigin="anonymous"
92+
onError={() => setAvatarError(true)}
93+
/>
94+
) : (
95+
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
96+
<span className="text-sm font-medium text-primary">
97+
{user?.username?.charAt(0).toUpperCase()}
98+
</span>
99+
</div>
100+
)}
101+
<span className="text-sm font-medium text-foreground">
102+
{user?.username}
103+
</span>
104+
<Button variant="ghost" size="icon" onClick={handleLogout}>
105+
<LogOut className="h-4 w-4" />
106+
</Button>
107+
</div>
108+
)}
92109
</div>
93110
</div>
94111
</header>

frontend/src/hooks/useRemote.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
2+
import { remoteApi } from '@/api/remote';
3+
import type { ConnectServerRequest } from '@/api/remote';
4+
import { useModeStore } from '@/store/modeStore';
5+
6+
export const useServerStatus = () => {
7+
const isLocal = useModeStore((s) => s.mode === 'local');
8+
return useQuery({
9+
queryKey: ['remote', 'server'],
10+
queryFn: remoteApi.getServer,
11+
enabled: isLocal,
12+
refetchInterval: 30000, // poll every 30s
13+
});
14+
};
15+
16+
export const useConnectServer = () => {
17+
const queryClient = useQueryClient();
18+
return useMutation({
19+
mutationFn: (req: ConnectServerRequest) => remoteApi.connectServer(req),
20+
onSuccess: () => {
21+
queryClient.invalidateQueries({ queryKey: ['remote'] });
22+
},
23+
});
24+
};
25+
26+
export const useDisconnectServer = () => {
27+
const queryClient = useQueryClient();
28+
return useMutation({
29+
mutationFn: () => remoteApi.disconnectServer(),
30+
onSuccess: () => {
31+
queryClient.invalidateQueries({ queryKey: ['remote'] });
32+
},
33+
});
34+
};

frontend/src/pages/Login.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useEffect } from 'react';
22
import { useNavigate, useSearchParams } from 'react-router-dom';
33
import { useAuthStore } from '@/store/authStore';
4+
import { useModeStore } from '@/store/modeStore';
45
import { authApi } from '@/api/auth';
56
import { Button } from '@/components/ui/button';
67
import { Input } from '@/components/ui/input';
@@ -14,8 +15,19 @@ export const Login = () => {
1415
const [searchParams] = useSearchParams();
1516

1617
const setAuth = useAuthStore((state) => state.setAuth);
18+
const isLocalMode = useModeStore((s) => s.isLocalMode());
1719
const navigate = useNavigate();
1820

21+
// In local mode, redirect straight to workspaces
22+
useEffect(() => {
23+
if (isLocalMode) {
24+
navigate('/workspaces');
25+
}
26+
}, [isLocalMode, navigate]);
27+
28+
// Don't render login form in local mode
29+
if (isLocalMode) return null;
30+
1931
// Handle OAuth callback
2032
useEffect(() => {
2133
const token = searchParams.get('token');

0 commit comments

Comments
 (0)