Skip to content

Commit 8ca8e5c

Browse files
barckcodeclaude
andcommitted
feat: add complete authentication system with JWT, role-based access, and org management
- Auth context provider with JWT token management and auto-refresh on 401 - Login, Register, and Accept Invite pages with client-side validation - Protected routes with auth-aware routing (noop mode backward compatible) - User Profile page with name edit, password change, and must-change-password banner - Organization Settings page with member list, role selector (admin/member), invite management - Password reset flow with temporary password modal and forced change guard - Invite creation with one-time link modal and copy-to-clipboard from list - WebSocket auth via JWT token query parameter injection - 18 friendly error message mappings for auth-related backend errors - localStorage token storage, ProtectedRoute component, GuardedRoute for forced password change Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a98c163 commit 8ca8e5c

File tree

17 files changed

+1828
-30
lines changed

17 files changed

+1828
-30
lines changed

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.2.10
1+
0.3.0

src/App.tsx

Lines changed: 92 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { BrowserRouter, Routes, Route } from 'react-router-dom';
1+
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
2+
import { AuthProvider, useAuth } from './context/AuthContext';
23
import { Layout } from './components/Layout';
4+
import { ProtectedRoute } from './components/ProtectedRoute';
35
import { ToastContainer } from './components/Toast';
46
import { TeamsListPage } from './pages/TeamsListPage';
57
import { TeamBuilderPage } from './pages/TeamBuilderPage';
@@ -14,28 +16,99 @@ import { WebhookDetailPage } from './pages/WebhookDetailPage';
1416
import { PostActionsListPage } from './pages/PostActionsListPage';
1517
import { PostActionBuilderPage } from './pages/PostActionBuilderPage';
1618
import { PostActionDetailPage } from './pages/PostActionDetailPage';
19+
import { LoginPage } from './pages/LoginPage';
20+
import { RegisterPage } from './pages/RegisterPage';
21+
import { InvitePage } from './pages/InvitePage';
22+
import { UserProfilePage } from './pages/UserProfilePage';
23+
import { OrgSettingsPage } from './pages/OrgSettingsPage';
24+
25+
function GuardedRoute({ children }: { children: React.ReactNode }) {
26+
const { mustChangePassword } = useAuth();
27+
if (mustChangePassword) return <Navigate to="/settings/profile" replace />;
28+
return <>{children}</>;
29+
}
30+
31+
function AppRoutes() {
32+
const { authConfig, isAuthenticated, isLoading, refreshUser } = useAuth();
33+
34+
if (isLoading) {
35+
return (
36+
<div className="flex min-h-screen items-center justify-center bg-slate-950">
37+
<div className="flex flex-col items-center gap-3">
38+
<div className="h-8 w-8 animate-spin rounded-full border-2 border-slate-600 border-t-blue-500" />
39+
<p className="text-sm text-slate-400">Loading...</p>
40+
</div>
41+
</div>
42+
);
43+
}
44+
45+
const showAuthPages = authConfig?.provider !== 'noop';
46+
47+
return (
48+
<Routes>
49+
{/* Public auth routes — only when auth is active */}
50+
{showAuthPages && (
51+
<>
52+
<Route
53+
path="/login"
54+
element={
55+
isAuthenticated
56+
? <Navigate to="/" replace />
57+
: <LoginPage authConfig={authConfig!} onLoginSuccess={refreshUser} />
58+
}
59+
/>
60+
<Route
61+
path="/register"
62+
element={
63+
isAuthenticated
64+
? <Navigate to="/" replace />
65+
: authConfig!.registration_enabled
66+
? <RegisterPage onRegisterSuccess={refreshUser} />
67+
: <Navigate to="/login" replace />
68+
}
69+
/>
70+
<Route path="/invite/:token" element={<InvitePage />} />
71+
</>
72+
)}
73+
74+
{/* Protected routes */}
75+
<Route
76+
element={
77+
<ProtectedRoute>
78+
<Layout />
79+
</ProtectedRoute>
80+
}
81+
>
82+
<Route path="/" element={<GuardedRoute><TeamsListPage /></GuardedRoute>} />
83+
<Route path="/teams/new" element={<GuardedRoute><TeamBuilderPage /></GuardedRoute>} />
84+
<Route path="/teams/:id" element={<GuardedRoute><TeamMonitorPage /></GuardedRoute>} />
85+
<Route path="/schedules" element={<GuardedRoute><SchedulesListPage /></GuardedRoute>} />
86+
<Route path="/schedules/new" element={<GuardedRoute><ScheduleBuilderPage /></GuardedRoute>} />
87+
<Route path="/schedules/:id" element={<GuardedRoute><ScheduleDetailPage /></GuardedRoute>} />
88+
<Route path="/webhooks" element={<GuardedRoute><WebhooksListPage /></GuardedRoute>} />
89+
<Route path="/webhooks/new" element={<GuardedRoute><WebhookBuilderPage /></GuardedRoute>} />
90+
<Route path="/webhooks/:id" element={<GuardedRoute><WebhookDetailPage /></GuardedRoute>} />
91+
<Route path="/post-actions" element={<GuardedRoute><PostActionsListPage /></GuardedRoute>} />
92+
<Route path="/post-actions/new" element={<GuardedRoute><PostActionBuilderPage /></GuardedRoute>} />
93+
<Route path="/post-actions/:id" element={<GuardedRoute><PostActionDetailPage /></GuardedRoute>} />
94+
<Route path="/settings" element={<GuardedRoute><SettingsPage /></GuardedRoute>} />
95+
<Route path="/settings/profile" element={<UserProfilePage />} />
96+
<Route path="/settings/organization" element={<GuardedRoute><OrgSettingsPage /></GuardedRoute>} />
97+
</Route>
98+
99+
{/* Catch-all */}
100+
<Route path="*" element={<Navigate to="/" replace />} />
101+
</Routes>
102+
);
103+
}
17104

18105
export default function App() {
19106
return (
20107
<BrowserRouter>
21-
<Routes>
22-
<Route element={<Layout />}>
23-
<Route path="/" element={<TeamsListPage />} />
24-
<Route path="/teams/new" element={<TeamBuilderPage />} />
25-
<Route path="/teams/:id" element={<TeamMonitorPage />} />
26-
<Route path="/schedules" element={<SchedulesListPage />} />
27-
<Route path="/schedules/new" element={<ScheduleBuilderPage />} />
28-
<Route path="/schedules/:id" element={<ScheduleDetailPage />} />
29-
<Route path="/webhooks" element={<WebhooksListPage />} />
30-
<Route path="/webhooks/new" element={<WebhookBuilderPage />} />
31-
<Route path="/webhooks/:id" element={<WebhookDetailPage />} />
32-
<Route path="/post-actions" element={<PostActionsListPage />} />
33-
<Route path="/post-actions/new" element={<PostActionBuilderPage />} />
34-
<Route path="/post-actions/:id" element={<PostActionDetailPage />} />
35-
<Route path="/settings" element={<SettingsPage />} />
36-
</Route>
37-
</Routes>
38-
<ToastContainer />
108+
<AuthProvider>
109+
<AppRoutes />
110+
<ToastContainer />
111+
</AuthProvider>
39112
</BrowserRouter>
40113
);
41114
}

src/components/Layout.test.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
22
import { render, screen } from '@testing-library/react';
33
import { MemoryRouter } from 'react-router-dom';
44
import { Layout } from './Layout';
5+
import { AuthProvider } from '../context/AuthContext';
6+
import { createFetchMock } from '../test/mocks';
7+
8+
beforeEach(() => {
9+
// Mock auth config endpoint to return noop provider
10+
global.fetch = createFetchMock({
11+
'/api/auth/config': { body: { provider: 'noop', registration_enabled: false, multi_tenant: false } },
12+
'/api/auth/me': { body: { user: { id: '1', name: 'Test', email: 'test@test.com', is_owner: true }, organization: { id: '1', name: 'Default', slug: 'default' } } },
13+
});
14+
vi.restoreAllMocks();
15+
});
516

617
function renderLayout(initialPath: string) {
718
return render(
819
<MemoryRouter initialEntries={[initialPath]}>
9-
<Layout />
20+
<AuthProvider>
21+
<Layout />
22+
</AuthProvider>
1023
</MemoryRouter>,
1124
);
1225
}

src/components/Layout.tsx

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState } from 'react';
22
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
3+
import { useAuth } from '../context/AuthContext';
34

45
const navItems = [
56
{
@@ -45,7 +46,7 @@ const navItems = [
4546
{
4647
to: '/settings',
4748
label: 'Variables',
48-
end: false,
49+
end: true,
4950
icon: (
5051
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
5152
<path strokeLinecap="round" strokeLinejoin="round" d="M4.745 3A23.933 23.933 0 003 12c0 3.183.62 6.22 1.745 9M19.255 3c.967 2.78 1.5 5.817 1.5 9s-.533 6.22-1.5 9M8.25 8.885l1.444-.89a.75.75 0 011.105.402l2.402 7.206a.75.75 0 001.105.401l1.444-.889" />
@@ -54,25 +55,53 @@ const navItems = [
5455
},
5556
];
5657

58+
const settingsNavItems = [
59+
{
60+
to: '/settings/profile',
61+
label: 'Profile',
62+
icon: (
63+
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
64+
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
65+
</svg>
66+
),
67+
},
68+
{
69+
to: '/settings/organization',
70+
label: 'Organization',
71+
icon: (
72+
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
73+
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21" />
74+
</svg>
75+
),
76+
},
77+
];
78+
5779
export function Layout() {
5880
const [mobileOpen, setMobileOpen] = useState(false);
5981
const location = useLocation();
6082
const navigate = useNavigate();
83+
const { user, organization, authConfig, logout } = useAuth();
6184

6285
const showNewTeamButton = location.pathname !== '/' && location.pathname !== '/teams/new';
86+
const showAuthNav = authConfig?.provider !== 'noop';
6387

6488
const sidebarContent = (
65-
<>
89+
<div className="flex h-full flex-col">
6690
{/* Brand */}
6791
<div className="flex h-16 items-center gap-2 border-b border-slate-800 px-5">
6892
<svg className="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
6993
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
7094
</svg>
71-
<span className="text-lg font-bold text-white">AgentCrew</span>
95+
<div className="flex flex-col">
96+
<span className="text-lg font-bold text-white">AgentCrew</span>
97+
{showAuthNav && organization && (
98+
<span className="text-xs text-slate-400">{organization.name}</span>
99+
)}
100+
</div>
72101
</div>
73102

74103
{/* Navigation */}
75-
<nav className="flex flex-col gap-1 p-3">
104+
<nav className="flex flex-1 flex-col gap-1 p-3">
76105
{showNewTeamButton && (
77106
<button
78107
onClick={() => {
@@ -105,8 +134,60 @@ export function Layout() {
105134
{item.label}
106135
</NavLink>
107136
))}
137+
138+
{/* Settings group (only when auth is active) */}
139+
{showAuthNav && (
140+
<>
141+
<div className="my-2 border-t border-slate-800" />
142+
<span className="mb-1 px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
143+
Settings
144+
</span>
145+
{settingsNavItems.map((item) => (
146+
<NavLink
147+
key={item.to}
148+
to={item.to}
149+
onClick={() => setMobileOpen(false)}
150+
className={({ isActive }) =>
151+
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
152+
isActive
153+
? 'bg-slate-800 text-white'
154+
: 'text-slate-400 hover:bg-slate-800/50 hover:text-slate-200'
155+
}`
156+
}
157+
>
158+
{item.icon}
159+
{item.label}
160+
</NavLink>
161+
))}
162+
</>
163+
)}
108164
</nav>
109-
</>
165+
166+
{/* User footer (only when auth is active) */}
167+
{showAuthNav && user && (
168+
<div className="border-t border-slate-800 p-3">
169+
<div className="flex items-center justify-between">
170+
<div className="min-w-0 flex-1">
171+
<p className="truncate text-sm font-medium text-white">{user.name}</p>
172+
<p className="truncate text-xs text-slate-400">{user.email}</p>
173+
</div>
174+
<button
175+
onClick={() => {
176+
logout();
177+
navigate('/login');
178+
}}
179+
className="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-white"
180+
aria-label="Sign out"
181+
title="Sign out"
182+
>
183+
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
184+
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
185+
</svg>
186+
</button>
187+
</div>
188+
</div>
189+
)}
190+
</div>
110191
);
111192

112193
return (

src/components/ProtectedRoute.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Navigate } from 'react-router-dom';
2+
import { useAuth } from '../context/AuthContext';
3+
4+
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
5+
const { isAuthenticated, isLoading, authConfig } = useAuth();
6+
7+
if (isLoading) {
8+
return (
9+
<div className="flex min-h-screen items-center justify-center bg-slate-950">
10+
<div className="flex flex-col items-center gap-3">
11+
<div className="h-8 w-8 animate-spin rounded-full border-2 border-slate-600 border-t-blue-500" />
12+
<p className="text-sm text-slate-400">Loading...</p>
13+
</div>
14+
</div>
15+
);
16+
}
17+
18+
// Noop mode: always authenticated
19+
if (authConfig?.provider === 'noop') {
20+
return <>{children}</>;
21+
}
22+
23+
if (!isAuthenticated) {
24+
return <Navigate to="/login" replace />;
25+
}
26+
27+
return <>{children}</>;
28+
}

0 commit comments

Comments
 (0)