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`
|
When making changes to the types, you can regenerate them using `npm run generate-types`
|
||||||
Do not manually edit shared/types.ts
|
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.
|
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::{
|
use axum::{
|
||||||
async_trait,
|
async_trait,
|
||||||
|
body::Body,
|
||||||
extract::FromRequestParts,
|
extract::FromRequestParts,
|
||||||
http::{request::Parts, StatusCode},
|
http::{request::Parts, StatusCode, Request},
|
||||||
|
middleware::Next,
|
||||||
|
response::Response,
|
||||||
};
|
};
|
||||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -15,6 +19,7 @@ pub struct Claims {
|
|||||||
pub exp: usize,
|
pub exp: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct AuthUser {
|
pub struct AuthUser {
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
@@ -29,32 +34,12 @@ where
|
|||||||
type Rejection = StatusCode;
|
type Rejection = StatusCode;
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||||
let headers = &parts.headers;
|
// Get user from request extensions (set by auth middleware)
|
||||||
|
parts
|
||||||
let auth_header = headers
|
.extensions
|
||||||
.get("authorization")
|
.get::<AuthUser>()
|
||||||
.and_then(|value| value.to_str().ok())
|
.cloned()
|
||||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
.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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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> {
|
pub fn verify_password(password: &str, hash: &str) -> Result<bool, bcrypt::BcryptError> {
|
||||||
bcrypt::verify(password, hash)
|
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::{
|
use axum::{
|
||||||
extract::Extension,
|
extract::Extension,
|
||||||
|
middleware,
|
||||||
response::Json as ResponseJson,
|
response::Json as ResponseJson,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
@@ -12,7 +13,7 @@ mod auth;
|
|||||||
mod models;
|
mod models;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
use auth::hash_password;
|
use auth::{auth_middleware, hash_password};
|
||||||
use models::ApiResponse;
|
use models::ApiResponse;
|
||||||
use routes::{health, projects, tasks, users};
|
use routes::{health, projects, tasks, users};
|
||||||
|
|
||||||
@@ -51,13 +52,24 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::warn!("Failed to create admin account: {}", e);
|
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("/", get(|| async { "Bloop API" }))
|
||||||
.route("/health", get(health::health_check))
|
.route("/health", get(health::health_check))
|
||||||
.route("/echo", post(echo_handler))
|
.route("/echo", post(echo_handler))
|
||||||
|
.merge(users::public_users_router());
|
||||||
|
|
||||||
|
// Protected routes (auth required)
|
||||||
|
let protected_routes = Router::new()
|
||||||
.merge(projects::projects_router())
|
.merge(projects::projects_router())
|
||||||
.merge(tasks::tasks_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(Extension(pool))
|
||||||
.layer(CorsLayer::permissive());
|
.layer(CorsLayer::permissive());
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ use chrono::Utc;
|
|||||||
use crate::models::{ApiResponse, project::{Project, CreateProject, UpdateProject}};
|
use crate::models::{ApiResponse, project::{Project, CreateProject, UpdateProject}};
|
||||||
use crate::auth::AuthUser;
|
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!(
|
match sqlx::query_as!(
|
||||||
Project,
|
Project,
|
||||||
"SELECT id, name, owner_id, created_at, updated_at FROM projects ORDER BY created_at DESC"
|
"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(
|
pub async fn get_project(
|
||||||
|
auth: AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Extension(pool): Extension<PgPool>
|
Extension(pool): Extension<PgPool>
|
||||||
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use crate::models::{ApiResponse, task::{Task, CreateTask, UpdateTask, TaskStatus
|
|||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
|
|
||||||
pub async fn get_project_tasks(
|
pub async fn get_project_tasks(
|
||||||
|
auth: AuthUser,
|
||||||
Path(project_id): Path<Uuid>,
|
Path(project_id): Path<Uuid>,
|
||||||
Extension(pool): Extension<PgPool>
|
Extension(pool): Extension<PgPool>
|
||||||
) -> Result<ResponseJson<ApiResponse<Vec<Task>>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<Vec<Task>>>, StatusCode> {
|
||||||
@@ -41,6 +42,7 @@ pub async fn get_project_tasks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_task(
|
pub async fn get_task(
|
||||||
|
auth: AuthUser,
|
||||||
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
||||||
Extension(pool): Extension<PgPool>
|
Extension(pool): Extension<PgPool>
|
||||||
) -> Result<ResponseJson<ApiResponse<Task>>, StatusCode> {
|
) -> 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()
|
Router::new()
|
||||||
.route("/auth/login", post(login))
|
.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("/auth/me", get(get_current_user))
|
||||||
.route("/users", get(get_users).post(create_user))
|
.route("/users", get(get_users).post(create_user))
|
||||||
.route("/users/:id", get(get_user).put(update_user).delete(delete_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 { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'
|
||||||
import { LoginForm } from '@/components/auth/login-form'
|
import { LoginForm } from '@/components/auth/login-form'
|
||||||
import { Navbar } from '@/components/layout/navbar'
|
import { Navbar } from '@/components/layout/navbar'
|
||||||
@@ -6,32 +5,37 @@ import { HomePage } from '@/pages/home'
|
|||||||
import { Projects } from '@/pages/projects'
|
import { Projects } from '@/pages/projects'
|
||||||
import { ProjectTasks } from '@/pages/project-tasks'
|
import { ProjectTasks } from '@/pages/project-tasks'
|
||||||
import { Users } from '@/pages/users'
|
import { Users } from '@/pages/users'
|
||||||
import { isAuthenticated } from '@/lib/auth'
|
import { AuthProvider, useAuth } from '@/contexts/auth-context'
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [authenticated, setAuthenticated] = useState(false)
|
const { isAuthenticated, isLoading, logout } = useAuth()
|
||||||
const showNavbar = location.pathname !== '/' || authenticated
|
const showNavbar = location.pathname !== '/' || isAuthenticated
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setAuthenticated(isAuthenticated())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleLogin = () => {
|
const handleLogin = () => {
|
||||||
setAuthenticated(true)
|
// The actual login logic is handled by the LoginForm component
|
||||||
|
// which will call the login method from useAuth()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
// Show loading while checking auth status
|
||||||
setAuthenticated(false)
|
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 <LoginForm onSuccess={handleLogin} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<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" : ""}>
|
<div className={showNavbar && location.pathname !== '/' ? "max-w-7xl mx-auto p-6 sm:p-8" : ""}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
@@ -48,7 +52,9 @@ function AppContent() {
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AppContent />
|
<AuthProvider>
|
||||||
|
<AppContent />
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { LoginRequest, LoginResponse, ApiResponse } from 'shared/types'
|
import { LoginRequest, LoginResponse, ApiResponse } from '@/types'
|
||||||
import { authStorage } from '@/lib/auth'
|
import { useAuth } from '@/contexts/auth-context'
|
||||||
import { LogIn, AlertCircle } from 'lucide-react'
|
import { LogIn, AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
onSuccess: () => void
|
onSuccess?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginForm({ onSuccess }: LoginFormProps) {
|
export function LoginForm({ onSuccess }: LoginFormProps) {
|
||||||
|
const { login } = useAuth()
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -41,9 +42,8 @@ export function LoginForm({ onSuccess }: LoginFormProps) {
|
|||||||
const data: ApiResponse<LoginResponse> = await response.json()
|
const data: ApiResponse<LoginResponse> = await response.json()
|
||||||
|
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
authStorage.setToken(data.data.token)
|
login(data.data.user, data.data.token)
|
||||||
authStorage.setUser(data.data.user)
|
onSuccess?.()
|
||||||
onSuccess()
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Login failed')
|
throw new Error('Login failed')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import { Button } from '@/components/ui/button'
|
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'
|
import { ArrowLeft, FolderOpen, Users, LogOut } from 'lucide-react'
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
@@ -13,7 +13,6 @@ export function Navbar({ onLogout }: NavbarProps) {
|
|||||||
const isHome = location.pathname === '/'
|
const isHome = location.pathname === '/'
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
|
||||||
onLogout()
|
onLogout()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Project, ApiResponse } from 'shared/types'
|
import { Project, ApiResponse } from '@/types'
|
||||||
import { ProjectForm } from './project-form'
|
import { ProjectForm } from './project-form'
|
||||||
|
import { makeAuthenticatedRequest } from '@/lib/auth'
|
||||||
import { ArrowLeft, Edit, Trash2, Calendar, Clock, User, AlertCircle, Loader2, CheckSquare } from 'lucide-react'
|
import { ArrowLeft, Edit, Trash2, Calendar, Clock, User, AlertCircle, Loader2, CheckSquare } from 'lucide-react'
|
||||||
|
|
||||||
interface ProjectDetailProps {
|
interface ProjectDetailProps {
|
||||||
@@ -24,7 +25,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/projects/${projectId}`)
|
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}`)
|
||||||
const data: ApiResponse<Project> = await response.json()
|
const data: ApiResponse<Project> = await response.json()
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
setProject(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
|
if (!confirm(`Are you sure you want to delete "${project.name}"? This action cannot be undone.`)) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/projects/${projectId}`, {
|
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
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 { AlertCircle } from 'lucide-react'
|
||||||
import { makeAuthenticatedRequest } from '@/lib/auth'
|
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Project, ApiResponse } from 'shared/types'
|
import { Project, ApiResponse } from '@/types'
|
||||||
import { ProjectForm } from './project-form'
|
import { ProjectForm } from './project-form'
|
||||||
|
import { makeAuthenticatedRequest } from '@/lib/auth'
|
||||||
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, CheckSquare } from 'lucide-react'
|
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, CheckSquare } from 'lucide-react'
|
||||||
|
|
||||||
export function ProjectList() {
|
export function ProjectList() {
|
||||||
@@ -20,7 +21,7 @@ export function ProjectList() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/projects')
|
const response = await makeAuthenticatedRequest('/api/projects')
|
||||||
const data: ApiResponse<Project[]> = await response.json()
|
const data: ApiResponse<Project[]> = await response.json()
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
setProjects(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
|
if (!confirm(`Are you sure you want to delete "${name}"? This action cannot be undone.`)) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/projects/${id}`, {
|
const response = await makeAuthenticatedRequest(`/api/projects/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
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 { makeAuthenticatedRequest, authStorage } from '@/lib/auth'
|
||||||
import { AlertCircle } from 'lucide-react'
|
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { User, ApiResponse } from 'shared/types'
|
import { User, ApiResponse } from '@/types'
|
||||||
import { UserForm } from './user-form'
|
import { UserForm } from './user-form'
|
||||||
import { makeAuthenticatedRequest, authStorage } from '@/lib/auth'
|
import { makeAuthenticatedRequest, authStorage } from '@/lib/auth'
|
||||||
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, Shield, User as UserIcon } from 'lucide-react'
|
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, Shield, User as UserIcon } from 'lucide-react'
|
||||||
@@ -142,7 +142,7 @@ export function UserList() {
|
|||||||
</div>
|
</div>
|
||||||
<CardDescription className="flex items-center">
|
<CardDescription className="flex items-center">
|
||||||
<Calendar className="mr-1 h-3 w-3" />
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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 TOKEN_KEY = 'auth_token'
|
||||||
const USER_KEY = 'auth_user'
|
const USER_KEY = 'auth_user'
|
||||||
@@ -56,8 +56,3 @@ export const makeAuthenticatedRequest = async (url: string, options: RequestInit
|
|||||||
export const isAuthenticated = (): boolean => {
|
export const isAuthenticated = (): boolean => {
|
||||||
return !!authStorage.getToken()
|
return !!authStorage.getToken()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const logout = (): void => {
|
|
||||||
authStorage.clear()
|
|
||||||
window.location.href = '/'
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { ApiResponse } from "@/types";
|
||||||
import { ApiResponse } from "shared/types";
|
|
||||||
import { authStorage, makeAuthenticatedRequest } from "@/lib/auth";
|
import { authStorage, makeAuthenticatedRequest } from "@/lib/auth";
|
||||||
import {
|
import {
|
||||||
Heart,
|
Heart,
|
||||||
@@ -22,7 +21,6 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Zap,
|
Zap,
|
||||||
Shield,
|
Shield,
|
||||||
Code,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
SelectValue
|
SelectValue
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { ArrowLeft, Plus, MoreHorizontal, Trash2, Edit } from 'lucide-react'
|
import { ArrowLeft, Plus, MoreHorizontal, Trash2, Edit } from 'lucide-react'
|
||||||
import { getAuthHeaders } from '@/lib/auth'
|
import { makeAuthenticatedRequest } from '@/lib/auth'
|
||||||
import {
|
import {
|
||||||
KanbanProvider,
|
KanbanProvider,
|
||||||
KanbanBoard,
|
KanbanBoard,
|
||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
KanbanCard,
|
KanbanCard,
|
||||||
type DragEndEvent
|
type DragEndEvent
|
||||||
} from '@/components/ui/shadcn-io/kanban'
|
} from '@/components/ui/shadcn-io/kanban'
|
||||||
import type { TaskStatus } from '../../../shared/types'
|
import type { TaskStatus } from '@/types'
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
id: string
|
id: string
|
||||||
@@ -108,9 +108,7 @@ export function ProjectTasks() {
|
|||||||
|
|
||||||
const fetchProject = async () => {
|
const fetchProject = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/projects/${projectId}`, {
|
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}`)
|
||||||
headers: getAuthHeaders()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result: ApiResponse<Project> = await response.json()
|
const result: ApiResponse<Project> = await response.json()
|
||||||
@@ -129,9 +127,7 @@ export function ProjectTasks() {
|
|||||||
const fetchTasks = async () => {
|
const fetchTasks = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const response = await fetch(`/api/projects/${projectId}/tasks`, {
|
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks`)
|
||||||
headers: getAuthHeaders()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result: ApiResponse<Task[]> = await response.json()
|
const result: ApiResponse<Task[]> = await response.json()
|
||||||
@@ -152,12 +148,8 @@ export function ProjectTasks() {
|
|||||||
if (!newTaskTitle.trim()) return
|
if (!newTaskTitle.trim()) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/projects/${projectId}/tasks`, {
|
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
...getAuthHeaders(),
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
title: newTaskTitle,
|
title: newTaskTitle,
|
||||||
@@ -182,12 +174,8 @@ export function ProjectTasks() {
|
|||||||
if (!editingTask || !editTaskTitle.trim()) return
|
if (!editingTask || !editTaskTitle.trim()) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/projects/${projectId}/tasks/${editingTask.id}`, {
|
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${editingTask.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
|
||||||
...getAuthHeaders(),
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: editTaskTitle,
|
title: editTaskTitle,
|
||||||
description: editTaskDescription || null,
|
description: editTaskDescription || null,
|
||||||
@@ -211,9 +199,8 @@ export function ProjectTasks() {
|
|||||||
if (!confirm('Are you sure you want to delete this task?')) return
|
if (!confirm('Are you sure you want to delete this task?')) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/projects/${projectId}/tasks/${taskId}`, {
|
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${taskId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: getAuthHeaders()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -252,12 +239,8 @@ export function ProjectTasks() {
|
|||||||
))
|
))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/projects/${projectId}/tasks/${taskId}`, {
|
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${taskId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
|
||||||
...getAuthHeaders(),
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: task.title,
|
title: task.title,
|
||||||
description: task.description,
|
description: task.description,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
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 { UserForm } from '@/components/users/user-form'
|
||||||
import { makeAuthenticatedRequest, authStorage } from '@/lib/auth'
|
import { makeAuthenticatedRequest, authStorage } from '@/lib/auth'
|
||||||
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, Shield, User as UserIcon } from 'lucide-react'
|
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, Shield, User as UserIcon } from 'lucide-react'
|
||||||
@@ -142,7 +142,7 @@ export function Users() {
|
|||||||
</div>
|
</div>
|
||||||
<CardDescription className="flex items-center">
|
<CardDescription className="flex items-center">
|
||||||
<Calendar className="mr-1 h-3 w-3" />
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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