diff --git a/Internal/adapters/repository/postgres.go b/Internal/adapters/repository/postgres.go index cbf2b20..a6a2f28 100644 --- a/Internal/adapters/repository/postgres.go +++ b/Internal/adapters/repository/postgres.go @@ -42,6 +42,7 @@ func (db *DBContainer) Migrate() error { &domain.User{}, &domain.Invitation{}, &domain.Project{}, + &domain.ProjectUser{}, ) if err != nil { log.Fatalf("Failed to migrate users: %v", err) diff --git a/Internal/adapters/repository/projectRepo.go b/Internal/adapters/repository/projectRepo.go index 8ede3b1..15d8a51 100644 --- a/Internal/adapters/repository/projectRepo.go +++ b/Internal/adapters/repository/projectRepo.go @@ -28,8 +28,31 @@ func (pr *ProjectRepo) FetchProjects(projects []domain.Project) ([]domain.Projec func (pr *ProjectRepo) ProjectById(id string) (*domain.Project, error) { var project domain.Project - if err := pr.DB.Gorm.First(&project, id).Error; err != nil { + if err := pr.DB.Gorm.Preload("ProjectUsers.User").First(&project, id).Error; err != nil { return nil, err } return &project, nil } + +func (pr *ProjectRepo) InviteProject(projectuser *domain.ProjectUser) (*domain.ProjectUser, error) { + if err := pr.DB.Gorm.Create(projectuser).Error; err != nil { + return nil, err + } + return projectuser, nil +} + +func (pr *ProjectRepo) ProjectUserCheck(userID uint) (*domain.ProjectUser, error) { + var projectuser domain.ProjectUser + if err := pr.DB.Gorm.First(&projectuser, userID).Error; err != nil { + return nil, err + } + return &projectuser, nil +} + +func (pr *ProjectRepo) RemoveProjectUser(id uint) error { + var projectuser domain.ProjectUser + if err := pr.DB.Gorm.Delete(&projectuser, id).Error; err != nil { + return err + } + return nil +} diff --git a/Internal/adapters/repository/userRepo.go b/Internal/adapters/repository/userRepo.go index e965fbd..0015945 100644 --- a/Internal/adapters/repository/userRepo.go +++ b/Internal/adapters/repository/userRepo.go @@ -17,12 +17,21 @@ func NewUserRepository(db *DBContainer) *UserRepository { // Query func (ur *UserRepository) save(user *domain.User) error { + return ur.db.Gorm.Create(user).Error } func (ur *UserRepository) FindById(id uint) (*domain.User, error) { var user domain.User - err := ur.db.Gorm.First(&user, id).Error + if err := ur.db.Gorm.Preload("Projects.Project").First(&user, id).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (ur *UserRepository) FindByEmail(email string) (*domain.User, error) { + var user domain.User + err := ur.db.Gorm.Where("email = ?", email).First(&user).Error if err != nil { return nil, err } diff --git a/Internal/api/Routes/routev1.go b/Internal/api/Routes/routev1.go index c908a3e..03336d5 100644 --- a/Internal/api/Routes/routev1.go +++ b/Internal/api/Routes/routev1.go @@ -52,6 +52,12 @@ func SetupRouterV1(r *gin.Engine, deps Deps) { { project.GET("/projects", deps.Project.GetAllProjectHandler) project.GET("/:projectid", deps.Project.GetProjectHandler) + + manage := project.Group("/manage") + { + manage.POST("/invite/:projectid", deps.Project.InviteProjectHandler) + manage.DELETE("/remove/:projuserid", deps.Project.RemoveProjectUserHandler) + } } } } diff --git a/Internal/api/handlers/projectHandler.go b/Internal/api/handlers/projectHandler.go index 043041d..e302d32 100644 --- a/Internal/api/handlers/projectHandler.go +++ b/Internal/api/handlers/projectHandler.go @@ -1,6 +1,8 @@ package handlers import ( + "strconv" + "github.com/Arjuna-Ragil/Localbase/Internal/core/services" "github.com/gin-gonic/gin" ) @@ -77,3 +79,62 @@ func (ph *ProjectHandler) GetProjectHandler(c *gin.Context) { "data": project, }) } + +func (ph *ProjectHandler) InviteProjectHandler(c *gin.Context) { + projectIDStr := c.Param("projectid") + projectID, err := strconv.Atoi(projectIDStr) + if err != nil { + c.JSON(400, gin.H{ + "message": "Invalid Project ID", + "error": err.Error(), + }) + return + } + userRole := c.GetString("userRole") + userID := c.GetUint("userId") + var input services.InviteProjectInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(400, gin.H{ + "message": "Invalid input", + "data": err.Error(), + }) + return + } + invite, err := ph.ProjectService.InviteProject(&input, userRole, userID, uint(projectID)) + if err != nil { + c.JSON(500, gin.H{ + "message": "failed to invite project", + "data": err.Error(), + }) + return + } + c.JSON(200, gin.H{ + "message": "successfully invited project", + "data": invite, + }) +} + +func (ph *ProjectHandler) RemoveProjectUserHandler(c *gin.Context) { + projectUserSTR := c.Param("projuserid") + projectUserID, err := strconv.Atoi(projectUserSTR) + if err != nil { + c.JSON(400, gin.H{ + "message": "Invalid Project ID", + "error": err.Error(), + }) + return + } + userRole := c.GetString("userRole") + userID := c.GetUint("userId") + if err := ph.ProjectService.DeleteProjectUser(uint(projectUserID), userRole, userID); err != nil { + c.JSON(500, gin.H{ + "message": "failed to remove project user", + "data": err.Error(), + }) + return + } + c.JSON(200, gin.H{ + "message": "successfully removed project user", + "data": nil, + }) +} diff --git a/Internal/api/middleware/auth.go b/Internal/api/middleware/auth.go index 8c6ce6b..e77d2e4 100644 --- a/Internal/api/middleware/auth.go +++ b/Internal/api/middleware/auth.go @@ -71,7 +71,7 @@ func AuthMiddleware(userRepo *repository.UserRepository, cfg *config.Config) gin return } - c.Set("userID", user.Id) + c.Set("userID", user.ID) c.Set("userRole", user.Role) c.Next() diff --git a/Internal/core/domain/projectdb.go b/Internal/core/domain/projectdb.go index 031512a..cd6be9e 100644 --- a/Internal/core/domain/projectdb.go +++ b/Internal/core/domain/projectdb.go @@ -3,9 +3,22 @@ package domain import "time" type Project struct { - ID uint `gorm:"primary_key;auto_increment" json:"id"` - Name string `gorm:"size:255;not null" json:"name"` - Desc string `gorm:"size:255;" json:"desc"` - AdminID uint `json:"admin_id"` + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"size:255;not null" json:"name"` + Desc string `gorm:"size:255;" json:"desc"` + AdminID uint `json:"admin_id"` + ProjectUsers []ProjectUser `json:"project_users,omitempty"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` +} + +type ProjectUser struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + ProjectID uint `json:"project_id"` + UserID uint `json:"user_id"` + Project *Project `json:"project,omitempty"` + User *User `json:"user,omitempty"` + Role string `gorm:"size:255;not null" json:"role"` CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` } + +//Changes last savepoint diff --git a/Internal/core/domain/userdb.go b/Internal/core/domain/userdb.go index 110598d..e117373 100644 --- a/Internal/core/domain/userdb.go +++ b/Internal/core/domain/userdb.go @@ -8,15 +8,18 @@ import ( ) type User struct { - Id uint `gorm:"primary_key;AUTO_INCREMENT" json:"id"` - Email string `gorm:"size:255;unique;not null" json:"email"` - Password string `json:"-"` - Username string `gorm:"size:255;unique;not null" json:"username"` - Role string `gorm:"size:255;not null" json:"role"` - CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` - UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Email string `gorm:"size:255;unique;not null" json:"email"` + Password string `json:"-"` + Username string `gorm:"size:255;unique;not null" json:"username"` + Role string `gorm:"size:255;not null" json:"role"` + Projects []ProjectUser `json:"projects"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` } +//last chnages savepoint + func (u *User) BeforeCreate(tx *gorm.DB) (err error) { if len(u.Password) < 50 { hashed, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) diff --git a/Internal/core/services/authService.go b/Internal/core/services/authService.go index 8c7145f..17506d2 100644 --- a/Internal/core/services/authService.go +++ b/Internal/core/services/authService.go @@ -95,7 +95,7 @@ func (as *AuthService) LoginService(input *LoginInput) (string, error) { return "", err } token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "sub": user.Id, + "sub": user.ID, "exp": time.Now().Add(time.Hour * 48).Unix(), }) tokenString, err := token.SignedString([]byte(cfg.SecretKey)) diff --git a/Internal/core/services/projectService.go b/Internal/core/services/projectService.go index 8d36158..aec2a14 100644 --- a/Internal/core/services/projectService.go +++ b/Internal/core/services/projectService.go @@ -9,10 +9,11 @@ import ( type ProjectService struct { ProjectRepo *repository.ProjectRepo + UserRepo *repository.UserRepository } -func NewProjectService(projectRepo *repository.ProjectRepo) *ProjectService { - return &ProjectService{ProjectRepo: projectRepo} +func NewProjectService(project *repository.ProjectRepo, user *repository.UserRepository) *ProjectService { + return &ProjectService{ProjectRepo: project, UserRepo: user} } type CreateInput struct { @@ -21,6 +22,11 @@ type CreateInput struct { AdminID uint `json:"admin_id"` } +type InviteProjectInput struct { + Email string `json:"email"` + Role string `json:"role"` +} + func (ps *ProjectService) CreateProject(input *CreateInput) (*domain.Project, error) { projectInfo := domain.Project{ Name: input.Name, @@ -53,3 +59,45 @@ func (ps *ProjectService) GetProjectById(id string) (*domain.Project, error) { } return project, nil } + +func (ps *ProjectService) InviteProject(input *InviteProjectInput, Role string, userID uint, projectID uint) (*domain.ProjectUser, error) { + if Role != "admin" { + prorole, err := ps.ProjectRepo.ProjectUserCheck(userID) + if err != nil { + return nil, errors.New("user is not part of project") + } + if prorole.Role != "lead" { + return nil, errors.New("permission denied") + } + } + user, err := ps.UserRepo.FindByEmail(input.Email) + if err != nil { + return nil, err + } + var inviteInfo = domain.ProjectUser{ + ProjectID: projectID, + UserID: user.ID, + Role: input.Role, + } + project, err := ps.ProjectRepo.InviteProject(&inviteInfo) + if err != nil { + return nil, err + } + return project, nil +} + +func (ps *ProjectService) DeleteProjectUser(id uint, Role string, userID uint) error { + if Role != "admin" { + prorole, err := ps.ProjectRepo.ProjectUserCheck(userID) + if err != nil { + return errors.New("user is not part of project") + } + if prorole.Role != "lead" { + return errors.New("permission denied") + } + } + if err := ps.ProjectRepo.RemoveProjectUser(id); err != nil { + return err + } + return nil +} diff --git a/Lb-web/src/features/Login/context/AuthContext.tsx b/Lb-web/src/features/Login/context/AuthContext.tsx index cc84aca..e42c51d 100644 --- a/Lb-web/src/features/Login/context/AuthContext.tsx +++ b/Lb-web/src/features/Login/context/AuthContext.tsx @@ -6,6 +6,17 @@ interface User { username: string; email: string; role: string; + projects?: { + id: number; + project_id: number; + role: string; + project?: { + id: number; + name: string; + desc: string; + created_at: string; + }; + }[]; } interface AuthContextType { diff --git a/Lb-web/src/features/project/components/ProjectSidebar.tsx b/Lb-web/src/features/project/components/ProjectSidebar.tsx new file mode 100644 index 0000000..45916ea --- /dev/null +++ b/Lb-web/src/features/project/components/ProjectSidebar.tsx @@ -0,0 +1,69 @@ +import { Link, useLocation } from "react-router"; +import { Button } from "@/components/ui/button"; +import { LayoutDashboard, Settings, ArrowLeft, Database } from "lucide-react"; + +interface ProjectSidebarProps { + project: { + id: number; + name: string; + }; +} + +export function ProjectSidebar({ project }: ProjectSidebarProps) { + const location = useLocation(); + + const isActive = (path: string) => { + return location.pathname === path || location.pathname.startsWith(`${path}/`); + }; + + return ( + + ); +} diff --git a/Lb-web/src/features/project/pages/ProjectDashboard.tsx b/Lb-web/src/features/project/pages/ProjectDashboard.tsx index e61e152..19456e2 100644 --- a/Lb-web/src/features/project/pages/ProjectDashboard.tsx +++ b/Lb-web/src/features/project/pages/ProjectDashboard.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from "react"; import { useParams, Link } from "react-router"; import { projectService } from "../services/projectService"; +import { ProjectSidebar } from "../components/ProjectSidebar"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, LayoutDashboard, Settings } from "lucide-react"; +import { ArrowLeft } from "lucide-react"; export default function ProjectDashboard() { @@ -52,58 +53,44 @@ export default function ProjectDashboard() { } return ( -
+
{/* Subtle Background */} -
+
- {/* Sidebar (Mockup for now, or just a simple header) */} - {/* Let's stick to a clean layout similar to Home but focused on the project */} + -
-
- - - -
-

{project.name}

-

Project Dashboard

+
+ {/* Top Bar / Header (Optional, maybe just for user profile or breadcrumbs) */} +
+ {/* Can add User Profile dropdown here if needed, keeping it simple for now */} +
+ Dashboard
-
-
- - -
-
+ -
-
-
-

Welcome to {project.name}

-

- {project.desc || "No description provided for this project."} -

-
- Created on {new Date(project.created_at).toLocaleDateString()} - {/* Potential place for more stats like '3 Members', '5 Tasks', etc. */} +
+
+
+

Welcome to {project.name}

+

+ {project.desc || "No description provided for this project."} +

+
+ Created on {new Date(project.created_at).toLocaleDateString()} +
-
- {/* Placeholder for future dashboard widgets */} -
-
- Widget Placeholder -
-
- Widget Placeholder -
-
- Widget Placeholder + {/* Placeholder for future dashboard widgets */} +
+
+ Widget Placeholder +
+
+ Widget Placeholder +
+
+ Widget Placeholder +
diff --git a/Lb-web/src/features/project/pages/ProjectUserManagement.tsx b/Lb-web/src/features/project/pages/ProjectUserManagement.tsx new file mode 100644 index 0000000..fb2e94e --- /dev/null +++ b/Lb-web/src/features/project/pages/ProjectUserManagement.tsx @@ -0,0 +1,321 @@ +import { Button } from "@/components/ui/button"; +import { Plus, Loader2, Trash2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { useState, useEffect } from "react"; +import { useParams } from "react-router"; +import { projectService } from "../services/projectService"; +import { Label } from "@/components/ui/label"; +import { ProjectSidebar } from "../components/ProjectSidebar"; + + + +export default function ProjectUserManagement() { + const { id } = useParams<{ id: string }>(); + const projectId = id ? parseInt(id) : 0; + + // We need to fetch basic project info for the sidebar + // Ideally this should be context or passed down, but for now fetching again or assuming cached + const [project, setProject] = useState<{ + id: number; + name: string; + project_users?: { + id: number; + user_id: number; + role: string; + created_at: string; + user?: { + id: number; + username: string; + email: string; + role: string; + } + }[] + } | null>(null); + + const [email, setEmail] = useState(""); + const [role, setRole] = useState("staff"); + const [open, setOpen] = useState(false); + + // Invite loading state + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // User list state + const [fetchingUsers, setFetchingUsers] = useState(true); + + // Delete Modal State + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [userToDelete, setUserToDelete] = useState<{ id: number; username: string; project_user_id: number } | null>(null); + const [deleteLoading, setDeleteLoading] = useState(false); + + // Derived users list + const users = project?.project_users?.map(pu => ({ + id: pu.user?.id || pu.user_id, + username: pu.user?.username || "Unknown", + email: pu.user?.email || "Unknown", + role: pu.role, + joined_at: pu.created_at, + project_user_id: pu.id + })) || []; + + const handleDeleteClick = (user: { id: number; username: string; project_user_id: number }) => { + setUserToDelete(user); + setDeleteDialogOpen(true); + }; + + const confirmDelete = async () => { + if (!userToDelete) return; + setDeleteLoading(true); + try { + await projectService.removeProjectUser(userToDelete.project_user_id); + setDeleteDialogOpen(false); + setUserToDelete(null); + fetchProjectInfo(); + } catch (error) { + console.error("Failed to remove user:", error); + } finally { + setDeleteLoading(false); + } + }; + + useEffect(() => { + if (projectId) { + fetchProjectInfo(); + } + }, [projectId]); + + const fetchProjectInfo = async () => { + setFetchingUsers(true); + try { + if (!id) return; + const data = await projectService.getProjectById(id); + setProject(data); + } catch (e) { + console.error(e); + } finally { + setFetchingUsers(false); + } + } + + + + const handleInvite = async () => { + if (!email || !projectId) return; + + setLoading(true); + setError(null); + try { + await projectService.inviteUser(projectId, email, role); + + setOpen(false); + resetForm(); + fetchProjectInfo(); // Refresh to show new member + // Show success toast? + } catch (err) { + console.error("Failed to invite user:", err); + setError("Failed to invite user. Please try again."); + } finally { + setLoading(false); + } + }; + + const resetForm = () => { + setEmail(""); + setRole("user"); + setError(null); + setLoading(false); + }; + + if (!project) { + return
; + } + + return ( +
+ {/* Reuse Sidebar Layout */} + + +
+
+
+
+
+

Project Members

+

Manage users who have access to this project.

+
+ + { + setOpen(val); + if (!val) resetForm(); + }}> + + + + + + Invite to Project + + Send an invitation to add a user to this project. + + + +
+
+ + setEmail(e.target.value)} + /> +
+
+ + +
+ {error &&

{error}

} +
+ + + + +
+
+ + + + + Remove User + + Are you sure you want to remove {userToDelete?.username} from this project? This action cannot be undone. + + + + + + + + +
+ + {fetchingUsers ? ( +
+ +
+ ) : users.length === 0 ? ( +
+
+ +
+

No members yet

+

+ Start by inviting users to collaborate on this project. +

+
+ ) : ( +
+
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
UsernameEmailRoleJoinedActions
{user.username}{user.email} + + {user.role} + + + {new Date(user.joined_at).toLocaleDateString()} + + +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/Lb-web/src/features/project/services/projectService.ts b/Lb-web/src/features/project/services/projectService.ts index e0cba4b..338e54f 100644 --- a/Lb-web/src/features/project/services/projectService.ts +++ b/Lb-web/src/features/project/services/projectService.ts @@ -6,11 +6,26 @@ interface CreateProjectInput { admin_id: number; } +interface ProjectUser { + id: number; + project_id: number; + user_id: number; + role: string; + created_at: string; + user?: { + id: number; + username: string; + email: string; + role: string; + }; +} + interface Project { id: number; name: string; desc: string; admin_id: number; + project_users?: ProjectUser[]; created_at: string; updated_at: string; } @@ -27,5 +42,13 @@ export const projectService = { getProjectById: async (id: string): Promise => { const response = await api.get(`/protected/project/${id}`); return response.data.data; + }, + inviteUser: async (projectId: number, email: string, role: string) => { + const response = await api.post(`/protected/project/manage/invite/${projectId}`, { email, role }); + return response.data; + }, + removeProjectUser: async (projectUserId: number) => { + const response = await api.delete(`/protected/project/manage/remove/${projectUserId}`); + return response.data; } }; diff --git a/Lb-web/src/main.tsx b/Lb-web/src/main.tsx index 424efa0..b901da2 100644 --- a/Lb-web/src/main.tsx +++ b/Lb-web/src/main.tsx @@ -12,6 +12,7 @@ import SettingsLayout from './features/admin-setting/layout/SettingsLayout.tsx' import UserManagement from './features/admin-setting/pages/UserManagement.tsx' import Appearance from './features/admin-setting/pages/Appearance.tsx' import ProjectDashboard from './features/project/pages/ProjectDashboard.tsx' +import ProjectUserManagement from './features/project/pages/ProjectUserManagement.tsx' const router = createBrowserRouter([ { @@ -64,7 +65,16 @@ const router = createBrowserRouter([ }, { path: 'project/:id', - element: + children: [ + { + index: true, + element: + }, + { + path: 'settings/members', + element: + } + ] } ] } diff --git a/Lb-web/src/pages/Home.tsx b/Lb-web/src/pages/Home.tsx index 242ac9f..5c8c18b 100644 --- a/Lb-web/src/pages/Home.tsx +++ b/Lb-web/src/pages/Home.tsx @@ -7,7 +7,7 @@ import { CreateProjectModal } from "@/features/project/components/CreateProjectM import { projectService } from "@/features/project/services/projectService"; export default function Home() { - const { logout, role } = useAuth(); + const { logout, role, user } = useAuth(); const [isProfileOpen, setIsProfileOpen] = useState(false); const [isCreateProjectOpen, setIsCreateProjectOpen] = useState(false); @@ -31,10 +31,32 @@ export default function Home() { }, [isAdmin]); useEffect(() => { + console.log("EFFECT RUNNING. Admin:", isAdmin, "User:", user); if (isAdmin) { fetchProjects(); + } else if (user?.projects) { + console.log("USER PROJECTS FOUND:", user.projects); + // Map user's projects (from Preload) to the dashboard format + const userProjects = user.projects + .map(p => { + if (!p.project) return null; + return { + id: p.project.id, + name: p.project.name, + desc: p.project.desc, + // If created_at is missing in the nested project, fallback or use current date? + // Backend likely sends it. + created_at: p.project.created_at || new Date().toISOString() + }; + }) + .filter((p): p is { id: number; name: string; desc: string; created_at: string } => p !== null); + + console.log("PROJECTS SET:", userProjects); + setProjects(userProjects); + } else { + console.log("NO PROJECTS IN USER OBJECT"); } - }, [isAdmin, fetchProjects]); + }, [isAdmin, fetchProjects, user]); const handleLogout = async () => { await logout(); @@ -107,17 +129,19 @@ export default function Home() {
- ) : isAdmin && projects.length > 0 ? ( + ) : projects.length > 0 ? (

Your Projects

- + {isAdmin && ( + + )}
{projects.map((project: { id: number; name: string; desc: string; created_at: string }) => ( diff --git a/cmd/server/main.go b/cmd/server/main.go index c8ad29d..3ceb2f3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -61,7 +61,7 @@ func SetupApp(db *repository.DBContainer, cfg *config.Config) Routes.Deps { authHandler := handlers.NewAuthHandler(authService) projectRepo := repository.NewProjectRepo(db) - projectService := services.NewProjectService(projectRepo) + projectService := services.NewProjectService(projectRepo, userRepo) projectHandler := handlers.NewProjectHandler(projectService) return Routes.Deps{