Improve auth

This commit is contained in:
Louis Knight-Webb
2025-06-15 14:16:13 -04:00
parent 752c76fa9d
commit 458cff1651
20 changed files with 362 additions and 101 deletions

View File

@@ -1,4 +1,3 @@
import { useState, useEffect } from 'react'
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'
import { LoginForm } from '@/components/auth/login-form'
import { Navbar } from '@/components/layout/navbar'
@@ -6,32 +5,37 @@ import { HomePage } from '@/pages/home'
import { Projects } from '@/pages/projects'
import { ProjectTasks } from '@/pages/project-tasks'
import { Users } from '@/pages/users'
import { isAuthenticated } from '@/lib/auth'
import { AuthProvider, useAuth } from '@/contexts/auth-context'
function AppContent() {
const location = useLocation()
const [authenticated, setAuthenticated] = useState(false)
const showNavbar = location.pathname !== '/' || authenticated
useEffect(() => {
setAuthenticated(isAuthenticated())
}, [])
const { isAuthenticated, isLoading, logout } = useAuth()
const showNavbar = location.pathname !== '/' || isAuthenticated
const handleLogin = () => {
setAuthenticated(true)
// The actual login logic is handled by the LoginForm component
// which will call the login method from useAuth()
}
const handleLogout = () => {
setAuthenticated(false)
// Show loading while checking auth status
if (isLoading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-4"></div>
<p className="text-muted-foreground">Checking authentication...</p>
</div>
</div>
)
}
if (!authenticated) {
if (!isAuthenticated) {
return <LoginForm onSuccess={handleLogin} />
}
return (
<div className="min-h-screen bg-background">
{showNavbar && <Navbar onLogout={handleLogout} />}
{showNavbar && <Navbar onLogout={logout} />}
<div className={showNavbar && location.pathname !== '/' ? "max-w-7xl mx-auto p-6 sm:p-8" : ""}>
<Routes>
<Route path="/" element={<HomePage />} />
@@ -48,7 +52,9 @@ function AppContent() {
function App() {
return (
<BrowserRouter>
<AppContent />
<AuthProvider>
<AppContent />
</AuthProvider>
</BrowserRouter>
)
}

View File

@@ -4,15 +4,16 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { LoginRequest, LoginResponse, ApiResponse } from 'shared/types'
import { authStorage } from '@/lib/auth'
import { LoginRequest, LoginResponse, ApiResponse } from '@/types'
import { useAuth } from '@/contexts/auth-context'
import { LogIn, AlertCircle } from 'lucide-react'
interface LoginFormProps {
onSuccess: () => void
onSuccess?: () => void
}
export function LoginForm({ onSuccess }: LoginFormProps) {
const { login } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
@@ -41,9 +42,8 @@ export function LoginForm({ onSuccess }: LoginFormProps) {
const data: ApiResponse<LoginResponse> = await response.json()
if (data.success && data.data) {
authStorage.setToken(data.data.token)
authStorage.setUser(data.data.user)
onSuccess()
login(data.data.user, data.data.token)
onSuccess?.()
} else {
throw new Error('Login failed')
}

View File

@@ -1,6 +1,6 @@
import { Link, useLocation } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { authStorage, logout } from '@/lib/auth'
import { authStorage } from '@/lib/auth'
import { ArrowLeft, FolderOpen, Users, LogOut } from 'lucide-react'
interface NavbarProps {
@@ -13,7 +13,6 @@ export function Navbar({ onLogout }: NavbarProps) {
const isHome = location.pathname === '/'
const handleLogout = () => {
logout()
onLogout()
}

View File

@@ -4,8 +4,9 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Project, ApiResponse } from 'shared/types'
import { Project, ApiResponse } from '@/types'
import { ProjectForm } from './project-form'
import { makeAuthenticatedRequest } from '@/lib/auth'
import { ArrowLeft, Edit, Trash2, Calendar, Clock, User, AlertCircle, Loader2, CheckSquare } from 'lucide-react'
interface ProjectDetailProps {
@@ -24,7 +25,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
setLoading(true)
setError('')
try {
const response = await fetch(`/api/projects/${projectId}`)
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}`)
const data: ApiResponse<Project> = await response.json()
if (data.success && data.data) {
setProject(data.data)
@@ -44,7 +45,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
if (!confirm(`Are you sure you want to delete "${project.name}"? This action cannot be undone.`)) return
try {
const response = await fetch(`/api/projects/${projectId}`, {
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}`, {
method: 'DELETE',
})
if (response.ok) {

View File

@@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Project, CreateProject, UpdateProject } from 'shared/types'
import { Project, CreateProject, UpdateProject } from '@/types'
import { AlertCircle } from 'lucide-react'
import { makeAuthenticatedRequest } from '@/lib/auth'

View File

@@ -4,8 +4,9 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Project, ApiResponse } from 'shared/types'
import { Project, ApiResponse } from '@/types'
import { ProjectForm } from './project-form'
import { makeAuthenticatedRequest } from '@/lib/auth'
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, CheckSquare } from 'lucide-react'
export function ProjectList() {
@@ -20,7 +21,7 @@ export function ProjectList() {
setLoading(true)
setError('')
try {
const response = await fetch('/api/projects')
const response = await makeAuthenticatedRequest('/api/projects')
const data: ApiResponse<Project[]> = await response.json()
if (data.success && data.data) {
setProjects(data.data)
@@ -39,7 +40,7 @@ export function ProjectList() {
if (!confirm(`Are you sure you want to delete "${name}"? This action cannot be undone.`)) return
try {
const response = await fetch(`/api/projects/${id}`, {
const response = await makeAuthenticatedRequest(`/api/projects/${id}`, {
method: 'DELETE',
})
if (response.ok) {

View File

@@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { User, CreateUser, UpdateUser } from 'shared/types'
import { User, CreateUser, UpdateUser } from '@/types'
import { makeAuthenticatedRequest, authStorage } from '@/lib/auth'
import { AlertCircle } from 'lucide-react'

View File

@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { User, ApiResponse } from 'shared/types'
import { User, ApiResponse } from '@/types'
import { UserForm } from './user-form'
import { makeAuthenticatedRequest, authStorage } from '@/lib/auth'
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, Shield, User as UserIcon } from 'lucide-react'
@@ -142,7 +142,7 @@ export function UserList() {
</div>
<CardDescription className="flex items-center">
<Calendar className="mr-1 h-3 w-3" />
Joined {new Date(user.created_at).toLocaleDateString()}
Joined {user.created_at ? new Date(user.created_at).toLocaleDateString() : 'Unknown'}
</CardDescription>
</CardHeader>
<CardContent>

View File

@@ -0,0 +1,120 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { isAuthenticated, authStorage, makeAuthenticatedRequest } from '@/lib/auth'
import { User } from '@/types'
interface AuthContextType {
user: User | null
isAuthenticated: boolean
isLoading: boolean
login: (user: User, token: string) => void
logout: () => void
refreshAuthStatus: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
interface AuthProviderProps {
children: ReactNode
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null)
const [isAuthenticatedState, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const checkAuthStatus = async (): Promise<boolean> => {
const token = authStorage.getToken()
if (!token) {
return false
}
try {
const response = await makeAuthenticatedRequest('/api/auth/status')
if (response.ok) {
const data = await response.json()
if (data.success && data.data?.authenticated) {
// Update user data from server response
if (data.data.user_id && data.data.email) {
const userData: User = {
id: data.data.user_id,
email: data.data.email,
is_admin: data.data.is_admin || false
}
authStorage.setUser(userData)
setUser(userData)
}
return true
}
}
// If we get here, the token is invalid
return false
} catch (error) {
console.error('Auth status check failed:', error)
return false
}
}
const refreshAuthStatus = async () => {
setIsLoading(true)
if (isAuthenticated()) {
const isValid = await checkAuthStatus()
if (isValid) {
setIsAuthenticated(true)
} else {
// Clear invalid auth state
authStorage.clear()
setUser(null)
setIsAuthenticated(false)
}
} else {
setUser(null)
setIsAuthenticated(false)
}
setIsLoading(false)
}
useEffect(() => {
refreshAuthStatus()
}, [])
const login = (userData: User, token: string) => {
authStorage.setToken(token)
authStorage.setUser(userData)
setUser(userData)
setIsAuthenticated(true)
}
const logout = () => {
authStorage.clear()
setUser(null)
setIsAuthenticated(false)
window.location.href = '/'
}
const value: AuthContextType = {
user,
isAuthenticated: isAuthenticatedState,
isLoading,
login,
logout,
refreshAuthStatus
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

View File

@@ -1,4 +1,4 @@
import { User } from 'shared/types'
import { User } from '@/types'
const TOKEN_KEY = 'auth_token'
const USER_KEY = 'auth_user'
@@ -56,8 +56,3 @@ export const makeAuthenticatedRequest = async (url: string, options: RequestInit
export const isAuthenticated = (): boolean => {
return !!authStorage.getToken()
}
export const logout = (): void => {
authStorage.clear()
window.location.href = '/'
}

View File

@@ -10,8 +10,7 @@ import {
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { ApiResponse } from "shared/types";
import { ApiResponse } from "@/types";
import { authStorage, makeAuthenticatedRequest } from "@/lib/auth";
import {
Heart,
@@ -22,7 +21,6 @@ import {
AlertCircle,
Zap,
Shield,
Code,
} from "lucide-react";
export function HomePage() {

View File

@@ -25,7 +25,7 @@ import {
SelectValue
} from '@/components/ui/select'
import { ArrowLeft, Plus, MoreHorizontal, Trash2, Edit } from 'lucide-react'
import { getAuthHeaders } from '@/lib/auth'
import { makeAuthenticatedRequest } from '@/lib/auth'
import {
KanbanProvider,
KanbanBoard,
@@ -34,7 +34,7 @@ import {
KanbanCard,
type DragEndEvent
} from '@/components/ui/shadcn-io/kanban'
import type { TaskStatus } from '../../../shared/types'
import type { TaskStatus } from '@/types'
interface Task {
id: string
@@ -108,9 +108,7 @@ export function ProjectTasks() {
const fetchProject = async () => {
try {
const response = await fetch(`/api/projects/${projectId}`, {
headers: getAuthHeaders()
})
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}`)
if (response.ok) {
const result: ApiResponse<Project> = await response.json()
@@ -129,9 +127,7 @@ export function ProjectTasks() {
const fetchTasks = async () => {
try {
setLoading(true)
const response = await fetch(`/api/projects/${projectId}/tasks`, {
headers: getAuthHeaders()
})
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks`)
if (response.ok) {
const result: ApiResponse<Task[]> = await response.json()
@@ -152,12 +148,8 @@ export function ProjectTasks() {
if (!newTaskTitle.trim()) return
try {
const response = await fetch(`/api/projects/${projectId}/tasks`, {
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks`, {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
project_id: projectId,
title: newTaskTitle,
@@ -182,12 +174,8 @@ export function ProjectTasks() {
if (!editingTask || !editTaskTitle.trim()) return
try {
const response = await fetch(`/api/projects/${projectId}/tasks/${editingTask.id}`, {
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${editingTask.id}`, {
method: 'PUT',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: editTaskTitle,
description: editTaskDescription || null,
@@ -211,9 +199,8 @@ export function ProjectTasks() {
if (!confirm('Are you sure you want to delete this task?')) return
try {
const response = await fetch(`/api/projects/${projectId}/tasks/${taskId}`, {
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${taskId}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
if (response.ok) {
@@ -252,12 +239,8 @@ export function ProjectTasks() {
))
try {
const response = await fetch(`/api/projects/${projectId}/tasks/${taskId}`, {
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${taskId}`, {
method: 'PUT',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: task.title,
description: task.description,

View File

@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { User, ApiResponse } from 'shared/types'
import { User, ApiResponse } from '@/types'
import { UserForm } from '@/components/users/user-form'
import { makeAuthenticatedRequest, authStorage } from '@/lib/auth'
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, Shield, User as UserIcon } from 'lucide-react'
@@ -142,7 +142,7 @@ export function Users() {
</div>
<CardDescription className="flex items-center">
<Calendar className="mr-1 h-3 w-3" />
Joined {new Date(user.created_at).toLocaleDateString()}
Joined {user.created_at ? new Date(user.created_at).toLocaleDateString() : 'Unknown'}
</CardDescription>
</CardHeader>
<CardContent>

View File

@@ -0,0 +1,76 @@
// Shared types for the frontend application
export interface User {
id: string
email: string
is_admin: boolean
created_at?: Date
updated_at?: Date
}
export interface ApiResponse<T> {
success: boolean
data: T | null
message: string | null
}
export interface LoginRequest {
email: string
password: string
}
export interface LoginResponse {
user: User
token: string
}
export interface Project {
id: string
name: string
owner_id: string
created_at: Date
updated_at: Date
}
export interface CreateProject {
name: string
}
export interface UpdateProject {
name: string | null
}
export interface Task {
id: string
project_id: string
title: string
description: string | null
status: TaskStatus
created_at: string
updated_at: string
}
export type TaskStatus = "todo" | "inprogress" | "inreview" | "done" | "cancelled"
export interface CreateTask {
project_id: string
title: string
description: string | null
}
export interface UpdateTask {
title: string | null
description: string | null
status: TaskStatus | null
}
export interface CreateUser {
email: string
password: string
is_admin: boolean | null
}
export interface UpdateUser {
email: string | null
password: string | null
is_admin: boolean | null
}