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

@@ -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`

View File

@@ -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)
}

View File

@@ -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());

View File

@@ -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> {

View File

@@ -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> {

View File

@@ -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))

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>
<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
}