Improve auth
This commit is contained in:
6
AGENT.md
6
AGENT.md
@@ -61,6 +61,10 @@ ts-rs allows you to derive TypeScript types from Rust structs/enums. By annotati
|
||||
When making changes to the types, you can regenerate them using `npm run generate-types`
|
||||
Do not manually edit shared/types.ts
|
||||
|
||||
# Process
|
||||
# Working on the frontend AND the backend
|
||||
|
||||
When working on any task that involves changes to the backend and the frontend, start with the backend. If any shared types need to be regenerated, regenerate them before starting the frontend changes.
|
||||
|
||||
# Testing your work
|
||||
|
||||
Try to build the Typescript project after any frontend changes `npm run build`
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use axum::{
|
||||
async_trait,
|
||||
body::Body,
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, StatusCode},
|
||||
http::{request::Parts, StatusCode, Request},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -15,6 +19,7 @@ pub struct Claims {
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthUser {
|
||||
pub user_id: Uuid,
|
||||
pub email: String,
|
||||
@@ -29,32 +34,12 @@ where
|
||||
type Rejection = StatusCode;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let headers = &parts.headers;
|
||||
|
||||
let auth_header = headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let token = auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "your-secret-key".to_string());
|
||||
|
||||
let claims = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(jwt_secret.as_ref()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED)?
|
||||
.claims;
|
||||
|
||||
Ok(AuthUser {
|
||||
user_id: claims.user_id,
|
||||
email: claims.email,
|
||||
is_admin: claims.is_admin,
|
||||
})
|
||||
// Get user from request extensions (set by auth middleware)
|
||||
parts
|
||||
.extensions
|
||||
.get::<AuthUser>()
|
||||
.cloned()
|
||||
.ok_or(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,3 +72,58 @@ pub fn hash_password(password: &str) -> Result<String, bcrypt::BcryptError> {
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool, bcrypt::BcryptError> {
|
||||
bcrypt::verify(password, hash)
|
||||
}
|
||||
|
||||
// Auth middleware that requires authentication for all routes
|
||||
pub async fn auth_middleware(
|
||||
mut request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let headers = request.headers();
|
||||
|
||||
let auth_header = headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let token = auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "your-secret-key".to_string());
|
||||
|
||||
let claims = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(jwt_secret.as_ref()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED)?
|
||||
.claims;
|
||||
|
||||
// Get database pool from request extensions
|
||||
let pool = request
|
||||
.extensions()
|
||||
.get::<PgPool>()
|
||||
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Verify user exists in database
|
||||
let user_exists = sqlx::query!(
|
||||
"SELECT id FROM users WHERE id = $1",
|
||||
claims.user_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if user_exists.is_none() {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Add user info to request extensions for handlers to access
|
||||
request.extensions_mut().insert(AuthUser {
|
||||
user_id: claims.user_id,
|
||||
email: claims.email,
|
||||
is_admin: claims.is_admin,
|
||||
});
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use axum::{
|
||||
extract::Extension,
|
||||
middleware,
|
||||
response::Json as ResponseJson,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
@@ -12,7 +13,7 @@ mod auth;
|
||||
mod models;
|
||||
mod routes;
|
||||
|
||||
use auth::hash_password;
|
||||
use auth::{auth_middleware, hash_password};
|
||||
use models::ApiResponse;
|
||||
use routes::{health, projects, tasks, users};
|
||||
|
||||
@@ -51,13 +52,24 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::warn!("Failed to create admin account: {}", e);
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
// Public routes (no auth required)
|
||||
let public_routes = Router::new()
|
||||
.route("/", get(|| async { "Bloop API" }))
|
||||
.route("/health", get(health::health_check))
|
||||
.route("/echo", post(echo_handler))
|
||||
.merge(users::public_users_router());
|
||||
|
||||
// Protected routes (auth required)
|
||||
let protected_routes = Router::new()
|
||||
.merge(projects::projects_router())
|
||||
.merge(tasks::tasks_router())
|
||||
.merge(users::users_router())
|
||||
.merge(users::protected_users_router())
|
||||
.layer(Extension(pool.clone()))
|
||||
.layer(middleware::from_fn(auth_middleware));
|
||||
|
||||
let app = Router::new()
|
||||
.merge(public_routes)
|
||||
.merge(protected_routes)
|
||||
.layer(Extension(pool))
|
||||
.layer(CorsLayer::permissive());
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ use chrono::Utc;
|
||||
use crate::models::{ApiResponse, project::{Project, CreateProject, UpdateProject}};
|
||||
use crate::auth::AuthUser;
|
||||
|
||||
pub async fn get_projects(Extension(pool): Extension<PgPool>) -> Result<ResponseJson<ApiResponse<Vec<Project>>>, StatusCode> {
|
||||
pub async fn get_projects(
|
||||
auth: AuthUser,
|
||||
Extension(pool): Extension<PgPool>
|
||||
) -> Result<ResponseJson<ApiResponse<Vec<Project>>>, StatusCode> {
|
||||
match sqlx::query_as!(
|
||||
Project,
|
||||
"SELECT id, name, owner_id, created_at, updated_at FROM projects ORDER BY created_at DESC"
|
||||
@@ -34,6 +37,7 @@ pub async fn get_projects(Extension(pool): Extension<PgPool>) -> Result<Response
|
||||
}
|
||||
|
||||
pub async fn get_project(
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Extension(pool): Extension<PgPool>
|
||||
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::models::{ApiResponse, task::{Task, CreateTask, UpdateTask, TaskStatus
|
||||
use crate::auth::AuthUser;
|
||||
|
||||
pub async fn get_project_tasks(
|
||||
auth: AuthUser,
|
||||
Path(project_id): Path<Uuid>,
|
||||
Extension(pool): Extension<PgPool>
|
||||
) -> Result<ResponseJson<ApiResponse<Vec<Task>>>, StatusCode> {
|
||||
@@ -41,6 +42,7 @@ pub async fn get_project_tasks(
|
||||
}
|
||||
|
||||
pub async fn get_task(
|
||||
auth: AuthUser,
|
||||
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
||||
Extension(pool): Extension<PgPool>
|
||||
) -> Result<ResponseJson<ApiResponse<Task>>, StatusCode> {
|
||||
|
||||
@@ -292,9 +292,29 @@ pub async fn get_current_user(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn users_router() -> Router {
|
||||
pub async fn check_auth_status(
|
||||
auth: AuthUser,
|
||||
) -> ResponseJson<ApiResponse<serde_json::Value>> {
|
||||
ResponseJson(ApiResponse {
|
||||
success: true,
|
||||
data: Some(serde_json::json!({
|
||||
"authenticated": true,
|
||||
"user_id": auth.user_id,
|
||||
"email": auth.email,
|
||||
"is_admin": auth.is_admin
|
||||
})),
|
||||
message: Some("User is authenticated".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn public_users_router() -> Router {
|
||||
Router::new()
|
||||
.route("/auth/login", post(login))
|
||||
}
|
||||
|
||||
pub fn protected_users_router() -> Router {
|
||||
Router::new()
|
||||
.route("/auth/status", get(check_auth_status))
|
||||
.route("/auth/me", get(get_current_user))
|
||||
.route("/users", get(get_users).post(create_user))
|
||||
.route("/users/:id", get(get_user).put(update_user).delete(delete_user))
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
120
frontend/src/contexts/auth-context.tsx
Normal file
120
frontend/src/contexts/auth-context.tsx
Normal 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
|
||||
}
|
||||
@@ -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 = '/'
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
76
frontend/src/types/index.ts
Normal file
76
frontend/src/types/index.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user