From 6e451e0795bad40aed469f8dacfa175c041b7daf Mon Sep 17 00:00:00 2001 From: Hammer Date: Wed, 28 Jan 2026 21:40:13 +0000 Subject: [PATCH] feat: admin panel and invite acceptance UI --- src/App.tsx | 4 + src/components/Layout.tsx | 7 +- src/lib/api.ts | 56 +++++- src/pages/AdminPage.tsx | 362 ++++++++++++++++++++++++++++++++++++++ src/pages/InvitePage.tsx | 180 +++++++++++++++++++ src/types/index.ts | 13 ++ 6 files changed, 619 insertions(+), 3 deletions(-) create mode 100644 src/pages/AdminPage.tsx create mode 100644 src/pages/InvitePage.tsx diff --git a/src/App.tsx b/src/App.tsx index 7aae5ab..123ff0b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,8 @@ import ClientDetailPage from '@/pages/ClientDetailPage'; import EventsPage from '@/pages/EventsPage'; import EmailsPage from '@/pages/EmailsPage'; import SettingsPage from '@/pages/SettingsPage'; +import AdminPage from '@/pages/AdminPage'; +import InvitePage from '@/pages/InvitePage'; import { PageLoader } from '@/components/LoadingSpinner'; function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -31,6 +33,7 @@ export default function App() { : } /> + } /> @@ -42,6 +45,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 87329e4..18b68bd 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -3,11 +3,11 @@ import { Link, useLocation, Outlet } from 'react-router-dom'; import { useAuthStore } from '@/stores/auth'; import { cn } from '@/lib/utils'; import { - LayoutDashboard, Users, Calendar, Mail, Settings, + LayoutDashboard, Users, Calendar, Mail, Settings, Shield, LogOut, Menu, X, ChevronLeft, Network, } from 'lucide-react'; -const navItems = [ +const baseNavItems = [ { path: '/', label: 'Dashboard', icon: LayoutDashboard }, { path: '/clients', label: 'Clients', icon: Users }, { path: '/events', label: 'Events', icon: Calendar }, @@ -15,11 +15,14 @@ const navItems = [ { path: '/settings', label: 'Settings', icon: Settings }, ]; +const adminNavItem = { path: '/admin', label: 'Admin', icon: Shield }; + export default function Layout() { const [collapsed, setCollapsed] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); const location = useLocation(); const { user, logout } = useAuthStore(); + const navItems = user?.role === 'admin' ? [...baseNavItems, adminNavItem] : baseNavItems; const handleLogout = async () => { await logout(); diff --git a/src/lib/api.ts b/src/lib/api.ts index 5731c78..b99964e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { Profile, Client, ClientCreate, Event, EventCreate, Email, EmailGenerate, User } from '@/types'; +import type { Profile, Client, ClientCreate, Event, EventCreate, Email, EmailGenerate, User, Invite } from '@/types'; const API_BASE = import.meta.env.PROD ? 'https://api.thenetwork.donovankelly.xyz/api' @@ -223,6 +223,60 @@ class ApiClient { async deleteEmail(id: string): Promise { await this.fetch(`/emails/${id}`, { method: 'DELETE' }); } + + // Admin + async getUsers(): Promise { + return this.fetch('/admin/users'); + } + + async updateUserRole(userId: string, role: string): Promise { + await this.fetch(`/admin/users/${userId}/role`, { + method: 'PUT', + body: JSON.stringify({ role }), + }); + } + + async deleteUser(userId: string): Promise { + await this.fetch(`/admin/users/${userId}`, { method: 'DELETE' }); + } + + async createInvite(data: { email: string; name: string; role?: string }): Promise { + return this.fetch('/admin/invites', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async getInvites(): Promise { + return this.fetch('/admin/invites'); + } + + async deleteInvite(id: string): Promise { + await this.fetch(`/admin/invites/${id}`, { method: 'DELETE' }); + } + + // Invite acceptance (public - no auth) + async validateInvite(token: string): Promise<{ id: string; email: string; name: string; role: string; expiresAt: string }> { + const response = await fetch(`${AUTH_BASE}/auth/invite/${token}`); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Invalid invite' })); + throw new Error(error.error || 'Invalid invite'); + } + return response.json(); + } + + async acceptInvite(token: string, password: string, name?: string): Promise<{ success: boolean; token?: string }> { + const response = await fetch(`${AUTH_BASE}/auth/invite/${token}/accept`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password, name }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Failed to accept invite' })); + throw new Error(error.error || 'Failed to accept invite'); + } + return response.json(); + } } export const api = new ApiClient(); diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx new file mode 100644 index 0000000..0b4a797 --- /dev/null +++ b/src/pages/AdminPage.tsx @@ -0,0 +1,362 @@ +import { useState, useEffect } from 'react'; +import { Users, Mail, Plus, Trash2, Copy, Check } from 'lucide-react'; +import { api } from '@/lib/api'; +import { useAuthStore } from '@/stores/auth'; +import type { User, Invite } from '@/types'; +import { cn } from '@/lib/utils'; + +export default function AdminPage() { + const { user: currentUser } = useAuthStore(); + const [users, setUsers] = useState([]); + const [invites, setInvites] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [activeTab, setActiveTab] = useState<'users' | 'invites'>('users'); + + // Invite form + const [showInviteForm, setShowInviteForm] = useState(false); + const [inviteEmail, setInviteEmail] = useState(''); + const [inviteName, setInviteName] = useState(''); + const [inviteRole, setInviteRole] = useState<'admin' | 'user'>('user'); + const [inviteError, setInviteError] = useState(''); + const [inviteUrl, setInviteUrl] = useState(''); + const [copied, setCopied] = useState(false); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + setIsLoading(true); + try { + const [usersData, invitesData] = await Promise.all([ + api.getUsers(), + api.getInvites(), + ]); + setUsers(usersData); + setInvites(invitesData); + } catch (error) { + console.error('Failed to load admin data:', error); + } finally { + setIsLoading(false); + } + }; + + const handleCreateInvite = async (e: React.FormEvent) => { + e.preventDefault(); + setInviteError(''); + + try { + const result = await api.createInvite({ email: inviteEmail, name: inviteName, role: inviteRole }); + setInviteUrl(result.setupUrl); + setInvites([result, ...invites]); + } catch (error) { + setInviteError(error instanceof Error ? error.message : 'Failed to create invite'); + } + }; + + const handleCopyUrl = () => { + navigator.clipboard.writeText(inviteUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleChangeRole = async (userId: string, role: 'admin' | 'user') => { + try { + await api.updateUserRole(userId, role); + setUsers(users.map(u => u.id === userId ? { ...u, role } : u)); + } catch (error) { + console.error('Failed to update role:', error); + } + }; + + const handleDeleteUser = async (userId: string) => { + if (!confirm('Are you sure you want to delete this user? All their data will be lost.')) return; + try { + await api.deleteUser(userId); + setUsers(users.filter(u => u.id !== userId)); + } catch (error) { + console.error('Failed to delete user:', error); + } + }; + + const handleDeleteInvite = async (inviteId: string) => { + try { + await api.deleteInvite(inviteId); + setInvites(invites.filter(i => i.id !== inviteId)); + } catch (error) { + console.error('Failed to delete invite:', error); + } + }; + + const resetInviteForm = () => { + setShowInviteForm(false); + setInviteEmail(''); + setInviteName(''); + setInviteRole('user'); + setInviteError(''); + setInviteUrl(''); + }; + + if (currentUser?.role !== 'admin') { + return ( +
+

Access denied. Admin only.

+
+ ); + } + + return ( +
+

Admin

+ + {/* Tabs */} +
+ + +
+ + {isLoading ? ( +
Loading...
+ ) : activeTab === 'users' ? ( +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
NameEmailRoleJoined
{user.name}{user.email} + {user.id === currentUser?.id ? ( + + {user.role} + + ) : ( + + )} + + {user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'} + + {user.id !== currentUser?.id && ( + + )} +
+
+ ) : ( +
+ {/* Create invite button/form */} + {!showInviteForm ? ( + + ) : ( +
+

Invite New User

+ + {inviteUrl ? ( +
+

✓ Invite created! Share this link:

+
+ + +
+ +
+ ) : ( +
+ {inviteError && ( +

{inviteError}

+ )} +
+
+ + setInviteName(e.target.value)} + required + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="John Doe" + /> +
+
+ + setInviteEmail(e.target.value)} + required + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="john@example.com" + /> +
+
+ + +
+
+
+ + +
+
+ )} +
+ )} + + {/* Invites list */} +
+ + + + + + + + + + + + + {invites.length === 0 ? ( + + + + ) : ( + invites.map((invite) => ( + + + + + + + + + )) + )} + +
NameEmailRoleStatusExpires
+ No invites yet +
{invite.name}{invite.email} + + {invite.role} + + + + {invite.status} + + + {new Date(invite.expiresAt).toLocaleDateString()} + + {invite.status === 'pending' && ( + + )} +
+
+
+ )} +
+ ); +} diff --git a/src/pages/InvitePage.tsx b/src/pages/InvitePage.tsx new file mode 100644 index 0000000..6f2299f --- /dev/null +++ b/src/pages/InvitePage.tsx @@ -0,0 +1,180 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { Network, Eye, EyeOff } from 'lucide-react'; +import { api } from '@/lib/api'; +import { useAuthStore } from '@/stores/auth'; +import LoadingSpinner from '@/components/LoadingSpinner'; + +export default function InvitePage() { + const { token } = useParams<{ token: string }>(); + const navigate = useNavigate(); + const { checkSession } = useAuthStore(); + + const [invite, setInvite] = useState<{ email: string; name: string; role: string } | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [name, setName] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(''); + + useEffect(() => { + if (!token) return; + (async () => { + try { + const data = await api.validateInvite(token); + setInvite(data); + setName(data.name); + } catch (err: any) { + setError(err.message || 'Invalid or expired invite'); + } finally { + setLoading(false); + } + })(); + }, [token]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitError(''); + + if (password.length < 8) { + setSubmitError('Password must be at least 8 characters'); + return; + } + if (password !== confirmPassword) { + setSubmitError('Passwords do not match'); + return; + } + + setSubmitting(true); + try { + const result = await api.acceptInvite(token!, password, name); + // If we got a token, store it + if (result.token) { + api.setToken(result.token); + } + // Now log in with the credentials + try { + await api.login(invite!.email, password); + } catch { + // Login might fail if token auth already worked + } + await checkSession(); + navigate('/'); + } catch (err: any) { + setSubmitError(err.message || 'Failed to create account'); + } finally { + setSubmitting(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+
+ +
+

Invalid Invite

+

{error}

+
+
+ ); + } + + return ( +
+
+ {/* Logo */} +
+
+ +
+

Welcome to NetworkCRM

+

You've been invited to join as {invite?.role}

+
+ + {/* Card */} +
+
+

+ Setting up account for {invite?.email} +

+
+ +
+ {submitError && ( +
+ {submitError} +
+ )} + +
+ + setName(e.target.value)} + required + className="w-full px-3.5 py-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow" + /> +
+ +
+ +
+ setPassword(e.target.value)} + required + minLength={8} + className="w-full px-3.5 py-2.5 pr-10 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow" + placeholder="Min 8 characters" + /> + +
+
+ +
+ + setConfirmPassword(e.target.value)} + required + className="w-full px-3.5 py-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow" + placeholder="Re-enter password" + /> +
+ + +
+
+
+
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 1c514f3..1c93740 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,6 +4,7 @@ export interface User { email: string; image?: string; role?: string; + createdAt?: string; } export interface Profile { @@ -110,3 +111,15 @@ export interface EmailGenerate { purpose: string; provider?: 'anthropic' | 'openai'; } + +export interface Invite { + id: string; + email: string; + name: string; + role: string; + token: string; + invitedBy?: string; + status: 'pending' | 'accepted' | 'expired'; + expiresAt: string; + createdAt: string; +}