diff --git a/assets/js/views/ProjectManagement.vue b/assets/js/views/ProjectManagement.vue index 743ed47..ccb748d 100644 --- a/assets/js/views/ProjectManagement.vue +++ b/assets/js/views/ProjectManagement.vue @@ -278,6 +278,58 @@ + +
Team & Zugriff
+
+
+ + + Eigentümer ist erforderlich +
+ +
+ + + Team-Mitglieder können das Projekt ansehen und bearbeiten +
+
+
Dokumente
@@ -796,6 +848,8 @@ const deleting = ref(false) const typeFilter = ref('all') const customers = ref([]) const statuses = ref([]) +const users = ref([]) +const currentUser = ref(null) const gitRepositories = ref([]) const selectedRepository = ref(null) const selectedBranch = ref('main') @@ -844,7 +898,7 @@ const projectColumns = ref([ ]) onMounted(async () => { - await Promise.all([loadCustomers(), loadStatuses()]) + await Promise.all([loadCustomers(), loadStatuses(), loadUsers(), loadCurrentUser()]) }) async function loadCustomers() { @@ -891,6 +945,39 @@ async function loadStatuses() { } } +async function loadUsers() { + try { + const response = await fetch('/api/users?pagination=false') + if (!response.ok) throw new Error('Fehler beim Laden der Benutzer') + + const data = await response.json() + const usersList = data['hydra:member'] || data.member || data + + users.value = Array.isArray(usersList) ? usersList : [] + } catch (error) { + console.error('Error loading users:', error) + users.value = [] + toast.add({ + severity: 'error', + summary: 'Fehler', + detail: 'Benutzer konnten nicht geladen werden', + life: 3000 + }) + } +} + +async function loadCurrentUser() { + try { + const response = await fetch('/api/me') + if (!response.ok) throw new Error('Fehler beim Laden des aktuellen Benutzers') + + currentUser.value = await response.json() + } catch (error) { + console.error('Error loading current user:', error) + currentUser.value = null + } +} + function filterByType(type, loadData) { typeFilter.value = type @@ -960,7 +1047,9 @@ function openNewProjectDialog() { endDate: null, budget: null, hourContingent: null, - isPrivate: false + isPrivate: false, + owner: currentUser.value, + teamMembers: [] } submitted.value = false projectDialog.value = true @@ -974,7 +1063,8 @@ function editProject(project) { startDate: project.startDate ? new Date(project.startDate) : null, endDate: project.endDate ? new Date(project.endDate) : null, budget: project.budget ? parseFloat(project.budget) : null, - hourContingent: project.hourContingent ? parseFloat(project.hourContingent) : null + hourContingent: project.hourContingent ? parseFloat(project.hourContingent) : null, + teamMembers: project.teamMembers || [] } submitted.value = false projectDialog.value = true @@ -1218,7 +1308,7 @@ async function deleteGitRepo() { async function saveProject() { submitted.value = true - if (!editingProject.value.name) { + if (!editingProject.value.name || !editingProject.value.owner) { return } @@ -1238,7 +1328,9 @@ async function saveProject() { endDate: editingProject.value.endDate ? formatDateForAPI(editingProject.value.endDate) : null, budget: editingProject.value.budget ? editingProject.value.budget.toString() : null, hourContingent: editingProject.value.hourContingent ? editingProject.value.hourContingent.toString() : null, - isPrivate: editingProject.value.isPrivate + isPrivate: editingProject.value.isPrivate, + owner: `/api/users/${editingProject.value.owner.id}`, + teamMembers: editingProject.value.teamMembers?.map(member => `/api/users/${member.id}`) || [] } const isNew = !editingProject.value.id diff --git a/bruno/Git Integration/Create Repository.bru b/bruno/Git Integration/Create Repository.bru new file mode 100644 index 0000000..55fe7a4 --- /dev/null +++ b/bruno/Git Integration/Create Repository.bru @@ -0,0 +1,65 @@ +meta { + name: Create Repository + type: http + seq: 7 +} + +post { + url: {{baseUrl}}/api/git_repositories + body: json + auth: none +} + +headers { + Accept: application/json + Content-Type: application/json + Cookie: {{sessionCookie}} +} + +body:json { + { + "name": "My Project Repository", + "provider": "github", + "owner": "username", + "repo": "repository-name", + "branch": "main", + "project": "/api/projects/1", + "personalAccessToken": "ghp_xxxxxxxxxxxx" + } +} + +docs { + # Create Repository + + Creates a new Git repository entry. + + ## Body Parameters + - `name`: Display name for the repository + - `provider`: One of 'github', 'gitea', 'local' + - `owner`: Repository owner/organization + - `repo`: Repository name + - `branch`: Default branch (usually 'main' or 'master') + - `project`: IRI reference to project (e.g., "/api/projects/1") + - `personalAccessToken`: Optional access token for private repos + + ## Provider-Specific Settings + + ### GitHub + - `owner`: GitHub username or organization + - `repo`: Repository name + - `personalAccessToken`: GitHub Personal Access Token (optional for public repos) + + ### Gitea + - `owner`: Gitea username or organization + - `repo`: Repository name + - `personalAccessToken`: Gitea API token + - Requires `giteaUrl` in environment + + ### Local + - `localPath`: Absolute path to local Git repository + - No owner/repo needed + + ## Security + - Requires authentication + - Token is stored encrypted in database +} diff --git a/bruno/Git Integration/Delete Repository.bru b/bruno/Git Integration/Delete Repository.bru new file mode 100644 index 0000000..3a7816d --- /dev/null +++ b/bruno/Git Integration/Delete Repository.bru @@ -0,0 +1,32 @@ +meta { + name: Delete Repository + type: http + seq: 9 +} + +delete { + url: {{baseUrl}}/api/git_repositories/1 + body: none + auth: none +} + +headers { + Accept: application/json + Cookie: {{sessionCookie}} +} + +docs { + # Delete Repository + + Deletes a Git repository entry. + + ## Security + - Requires authentication via session cookie + - Voter checks DELETE permission + - Only accessible if repository belongs to user's project + + ## Important + - This only deletes the database entry + - The actual Git repository remains untouched + - Cache for this repository is automatically cleared +} diff --git a/bruno/Git Integration/Get Commits.bru b/bruno/Git Integration/Get Commits.bru new file mode 100644 index 0000000..51b9773 --- /dev/null +++ b/bruno/Git Integration/Get Commits.bru @@ -0,0 +1,52 @@ +meta { + name: Get Commits + type: http + seq: 4 +} + +get { + url: {{baseUrl}}/api/git-repos/1/commits?branch=main&limit=50 + body: none + auth: none +} + +params:query { + branch: main + limit: 50 +} + +headers { + Accept: application/json + Cookie: {{sessionCookie}} +} + +docs { + # Get Commits + + Fetches commit history for a specific repository and branch. + + ## Query Parameters + - `branch`: Git branch name (default: 'main') + - `limit`: Number of commits to fetch (default: 100) + + ## Response + ```json + { + "commits": [ + { + "hash": "abc123def456...", + "shortHash": "abc123d", + "subject": "Add new feature", + "body": "Detailed commit message...", + "author": "John Doe", + "email": "john@example.com", + "date": "2025-11-14T10:30:00+00:00" + } + ] + } + ``` + + ## Caching + - Cached for 15 minutes (900 seconds) + - Cache key includes repository ID, branch, and limit +} diff --git a/bruno/Git Integration/Get Contributions.bru b/bruno/Git Integration/Get Contributions.bru new file mode 100644 index 0000000..c980ff2 --- /dev/null +++ b/bruno/Git Integration/Get Contributions.bru @@ -0,0 +1,62 @@ +meta { + name: Get Contributions + type: http + seq: 5 +} + +get { + url: {{baseUrl}}/api/git-repos/1/contributions?branch=main&year=2025 + body: none + auth: none +} + +params:query { + branch: main + year: 2025 + ~author: john@example.com +} + +headers { + Accept: application/json + Cookie: {{sessionCookie}} +} + +docs { + # Get Contributions + + Fetches daily contribution data for a specific repository, branch, and year. + Used for generating GitHub-style contribution heatmaps. + + ## Query Parameters + - `branch`: Git branch name (default: 'main') + - `year`: Year for contributions (default: current year) + - `author`: Optional email filter for specific author + + ## Response + ```json + { + "contributions": [ + { + "date": "2025-01-01", + "count": 3, + "weekday": 3 + }, + { + "date": "2025-01-02", + "count": 0, + "weekday": 4 + } + ] + } + ``` + + ## Weekday Format + - 0 = Sunday + - 1 = Monday + - ... + - 6 = Saturday + + ## Caching + - Cached for 1 hour (3600 seconds) + - Cache key includes repository ID, branch, author, and year +} diff --git a/bruno/Git Integration/Get Git Repositories.bru b/bruno/Git Integration/Get Git Repositories.bru new file mode 100644 index 0000000..e6fb94f --- /dev/null +++ b/bruno/Git Integration/Get Git Repositories.bru @@ -0,0 +1,34 @@ +meta { + name: Get Git Repositories + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/git_repositories?project=4 + body: none + auth: none +} + +params:query { + project: 4 +} + +headers { + Accept: application/json + Cookie: {{sessionCookie}} +} + +docs { + # Get Git Repositories + + Fetches all Git repositories associated with a specific project. + + ## Query Parameters + - `project`: Project ID (required for security filtering) + + ## Security + - Requires authentication via session cookie + - SearchFilter ensures only repositories for the specified project are returned + - Voter checks are applied on individual items +} diff --git a/bruno/Git Integration/Get Single Repository.bru b/bruno/Git Integration/Get Single Repository.bru new file mode 100644 index 0000000..3cbc531 --- /dev/null +++ b/bruno/Git Integration/Get Single Repository.bru @@ -0,0 +1,27 @@ +meta { + name: Get Single Repository + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/git_repositories/1 + body: none + auth: none +} + +headers { + Accept: application/json + Cookie: {{sessionCookie}} +} + +docs { + # Get Single Repository + + Fetches a specific Git repository by ID. + + ## Security + - Requires authentication via session cookie + - Voter checks VIEW permission + - Only accessible if repository belongs to user's project +} diff --git a/bruno/Git Integration/Refresh Cache.bru b/bruno/Git Integration/Refresh Cache.bru new file mode 100644 index 0000000..e792343 --- /dev/null +++ b/bruno/Git Integration/Refresh Cache.bru @@ -0,0 +1,36 @@ +meta { + name: Refresh Cache + type: http + seq: 6 +} + +post { + url: {{baseUrl}}/api/git-repos/1/refresh-cache + body: none + auth: none +} + +headers { + Accept: application/json + Cookie: {{sessionCookie}} +} + +docs { + # Refresh Cache + + Manually clears the cache for a specific repository. + Forces fresh data on next request for commits and contributions. + + ## Response + ```json + { + "success": true, + "message": "Cache cleared successfully" + } + ``` + + ## Use Cases + - After pushing new commits + - When data appears outdated + - Testing/development +} diff --git a/bruno/Git Integration/Test Repository Connection.bru b/bruno/Git Integration/Test Repository Connection.bru new file mode 100644 index 0000000..8f36567 --- /dev/null +++ b/bruno/Git Integration/Test Repository Connection.bru @@ -0,0 +1,44 @@ +meta { + name: Test Repository Connection + type: http + seq: 3 +} + +post { + url: {{baseUrl}}/api/git-repos/1/test + body: none + auth: none +} + +headers { + Accept: application/json + Cookie: {{sessionCookie}} +} + +docs { + # Test Repository Connection + + Tests if the repository connection is working correctly by fetching the first 10 commits. + + ## Response + ```json + { + "success": true, + "message": "Repository connection successful!", + "details": { + "commits": 10, + "firstCommit": "abc123d: Add new feature", + "provider": "github|gitea|local" + } + } + ``` + + ## Error Response + ```json + { + "success": false, + "error": "Connection failed: ...", + "hint": "Check owner/repo/branch settings" + } + ``` +} diff --git a/bruno/Git Integration/Update Repository.bru b/bruno/Git Integration/Update Repository.bru new file mode 100644 index 0000000..302d62d --- /dev/null +++ b/bruno/Git Integration/Update Repository.bru @@ -0,0 +1,38 @@ +meta { + name: Update Repository + type: http + seq: 8 +} + +put { + url: {{baseUrl}}/api/git_repositories/1 + body: json + auth: none +} + +headers { + Accept: application/json + Content-Type: application/json + Cookie: {{sessionCookie}} +} + +body:json { + { + "name": "Updated Repository Name", + "branch": "develop" + } +} + +docs { + # Update Repository + + Updates an existing Git repository entry. + + ## Security + - Requires authentication via session cookie + - Voter checks EDIT permission + - Only accessible if repository belongs to user's project + + ## Partial Updates + You can send only the fields you want to update. +} diff --git a/bruno/README.md b/bruno/README.md new file mode 100644 index 0000000..d534785 --- /dev/null +++ b/bruno/README.md @@ -0,0 +1,148 @@ +# myCRM Bruno API Collection + +Diese Bruno-Collection enthält alle API-Endpunkte für die myCRM-Anwendung. + +## Setup + +1. **Bruno installieren** + ```bash + # Via npm + npm install -g @usebruno/cli + + # Oder Desktop-App von https://www.usebruno.com/ herunterladen + ``` + +2. **Collection öffnen** + - Bruno Desktop-App öffnen + - "Open Collection" wählen + - Diesen `bruno/` Ordner auswählen + +3. **Environment konfigurieren** + - In Bruno: Environment "local" auswählen + - Session-Cookie holen: + ```bash + # Option 1: Aus Browser DevTools kopieren + # 1. In Browser bei localhost:8000 einloggen + # 2. DevTools → Application → Cookies + # 3. PHPSESSID kopieren + + # Option 2: Via Symfony CLI + symfony server:log + # Nach Login den Cookie aus den Logs kopieren + ``` + - In `environments/local.bru` den `sessionCookie` Wert eintragen + +## Verfügbare Sammlungen + +### Git Integration +Alle Endpunkte für die Git-Repository-Integration: + +- **Get Git Repositories** - Liste aller Repositories eines Projekts +- **Get Single Repository** - Details eines einzelnen Repositories +- **Test Repository Connection** - Verbindung testen (10 Commits) +- **Get Commits** - Commit-Historie abrufen +- **Get Contributions** - Tägliche Contributions für Heatmap +- **Refresh Cache** - Cache manuell leeren +- **Create Repository** - Neues Repository anlegen +- **Update Repository** - Repository aktualisieren +- **Delete Repository** - Repository löschen + +## Authentifizierung + +Die API verwendet Session-basierte Authentifizierung: + +``` +Cookie: PHPSESSID=your_session_id_here +``` + +Die Session wird automatisch von allen Requests verwendet, wenn sie im Environment konfiguriert ist. + +## Sicherheit + +Alle Endpunkte sind durch Symfony Security und Voter geschützt: + +- **Authentication**: Session-Cookie erforderlich +- **Authorization**: GitRepositoryVoter prüft VIEW/EDIT/DELETE Rechte +- **Project-Filtering**: Nur Repositories des eigenen Projekts sind sichtbar + +## Caching + +Die Git-Integration verwendet Symfony Cache: + +- **Commits**: 15 Minuten (900s) +- **Contributions**: 1 Stunde (3600s) +- **Manuelles Löschen**: POST `/api/git-repos/{id}/refresh-cache` + +## Provider-Typen + +### GitHub +```json +{ + "provider": "github", + "owner": "username", + "repo": "repository", + "personalAccessToken": "ghp_xxxxx" +} +``` + +### Gitea +```json +{ + "provider": "gitea", + "owner": "username", + "repo": "repository", + "personalAccessToken": "gitea_token", + "giteaUrl": "https://gitea.example.com" +} +``` + +### Local +```json +{ + "provider": "local", + "localPath": "/absolute/path/to/repo" +} +``` + +## Testing + +1. **Repository-Verbindung testen**: + ``` + POST /api/git-repos/{id}/test + ``` + +2. **Commits abrufen**: + ``` + GET /api/git-repos/{id}/commits?branch=main&limit=50 + ``` + +3. **Contributions für Heatmap**: + ``` + GET /api/git-repos/{id}/contributions?branch=main&year=2025 + ``` + +## Troubleshooting + +### 401 Unauthorized +- Session-Cookie abgelaufen → Neu einloggen und Cookie aktualisieren +- Cookie falsch formatiert → Format: `PHPSESSID=value` + +### 403 Forbidden +- Voter verweigert Zugriff → Repository gehört nicht zum eigenen Projekt +- Fehlende Berechtigungen → Projekt-Zugriff prüfen + +### 404 Not Found +- Repository existiert nicht +- Falsche ID verwendet +- Repository wurde gelöscht + +### 500 Internal Server Error +- Git-Fehler → Repository-Einstellungen prüfen (owner/repo/branch) +- Token ungültig → Personal Access Token erneuern +- Cache-Fehler → `var/cache/` Ordner prüfen + +## Weitere Informationen + +- [Symfony API Platform Docs](https://api-platform.com/) +- [Bruno Documentation](https://docs.usebruno.com/) +- [Git Integration Docs](../docs/GIT_INTEGRATION.md) diff --git a/bruno/bruno.json b/bruno/bruno.json new file mode 100644 index 0000000..d4692ae --- /dev/null +++ b/bruno/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "myCRM API", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} diff --git a/bruno/environments/local.bru b/bruno/environments/local.bru new file mode 100644 index 0000000..849ecd3 --- /dev/null +++ b/bruno/environments/local.bru @@ -0,0 +1,6 @@ +vars { + baseUrl: http://localhost:8000 + # Get this from your browser's developer tools after login + # Or use symfony CLI to get session cookie + sessionCookie: PHPSESSID=f5kmgf6khpmo106vkikupmpuc5 +} diff --git a/bruno/environments/myCRM.bru b/bruno/environments/myCRM.bru new file mode 100644 index 0000000..53adc74 --- /dev/null +++ b/bruno/environments/myCRM.bru @@ -0,0 +1,4 @@ +vars { + baseUrl: https://mycrm.test + PHPSESSID: f5kmgf6khpmo106vkikupmpuc5 +} diff --git a/migrations/Version20251114102103.php b/migrations/Version20251114102103.php new file mode 100644 index 0000000..f4e3e8d --- /dev/null +++ b/migrations/Version20251114102103.php @@ -0,0 +1,81 @@ +addSql('SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = "projects" AND COLUMN_NAME = "owner_id")'); + $this->addSql('SET @sql = IF(@col_exists = 0, "ALTER TABLE projects ADD owner_id INT DEFAULT NULL", "SELECT 1")'); + $this->addSql('PREPARE stmt FROM @sql'); + $this->addSql('EXECUTE stmt'); + $this->addSql('DEALLOCATE PREPARE stmt'); + + // Set first user as owner for existing projects + $this->addSql('UPDATE projects SET owner_id = (SELECT id FROM users ORDER BY id LIMIT 1) WHERE owner_id IS NULL'); + + // Make owner_id NOT NULL + $this->addSql('SET @sql = IF(@col_exists = 0, "ALTER TABLE projects MODIFY owner_id INT NOT NULL", "SELECT 1")'); + $this->addSql('PREPARE stmt FROM @sql'); + $this->addSql('EXECUTE stmt'); + $this->addSql('DEALLOCATE PREPARE stmt'); + + // Add foreign key if not exists + $this->addSql('SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = "projects" AND CONSTRAINT_NAME = "FK_5C93B3A47E3C61F9")'); + $this->addSql('SET @sql = IF(@fk_exists = 0, "ALTER TABLE projects ADD CONSTRAINT FK_5C93B3A47E3C61F9 FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE", "SELECT 1")'); + $this->addSql('PREPARE stmt FROM @sql'); + $this->addSql('EXECUTE stmt'); + $this->addSql('DEALLOCATE PREPARE stmt'); + + // Add index if not exists + $this->addSql('SET @idx_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = "projects" AND INDEX_NAME = "IDX_5C93B3A47E3C61F9")'); + $this->addSql('SET @sql = IF(@idx_exists = 0, "CREATE INDEX IDX_5C93B3A47E3C61F9 ON projects (owner_id)", "SELECT 1")'); + $this->addSql('PREPARE stmt FROM @sql'); + $this->addSql('EXECUTE stmt'); + $this->addSql('DEALLOCATE PREPARE stmt'); + + // Create junction table for team members if not exists + $this->addSql('CREATE TABLE IF NOT EXISTS project_team_members (project_id INT NOT NULL, user_id INT NOT NULL, INDEX IDX_8A3C5F7D166D1F9C (project_id), INDEX IDX_8A3C5F7DA76ED395 (user_id), PRIMARY KEY(project_id, user_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + + // Add foreign keys for junction table if not exists + $this->addSql('SET @fk1_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = "project_team_members" AND CONSTRAINT_NAME = "FK_8A3C5F7D166D1F9C")'); + $this->addSql('SET @sql = IF(@fk1_exists = 0, "ALTER TABLE project_team_members ADD CONSTRAINT FK_8A3C5F7D166D1F9C FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE", "SELECT 1")'); + $this->addSql('PREPARE stmt FROM @sql'); + $this->addSql('EXECUTE stmt'); + $this->addSql('DEALLOCATE PREPARE stmt'); + + $this->addSql('SET @fk2_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = "project_team_members" AND CONSTRAINT_NAME = "FK_8A3C5F7DA76ED395")'); + $this->addSql('SET @sql = IF(@fk2_exists = 0, "ALTER TABLE project_team_members ADD CONSTRAINT FK_8A3C5F7DA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE", "SELECT 1")'); + $this->addSql('PREPARE stmt FROM @sql'); + $this->addSql('EXECUTE stmt'); + $this->addSql('DEALLOCATE PREPARE stmt'); + } + + public function down(Schema $schema): void + { + // Drop junction table + $this->addSql('ALTER TABLE project_team_members DROP FOREIGN KEY FK_8A3C5F7D166D1F9C'); + $this->addSql('ALTER TABLE project_team_members DROP FOREIGN KEY FK_8A3C5F7DA76ED395'); + $this->addSql('DROP TABLE project_team_members'); + + // Remove owner_id from projects + $this->addSql('ALTER TABLE projects DROP FOREIGN KEY FK_5C93B3A47E3C61F9'); + $this->addSql('DROP INDEX IDX_5C93B3A47E3C61F9 ON projects'); + $this->addSql('ALTER TABLE projects DROP owner_id'); + } +} diff --git a/src/Doctrine/Extension/ProjectAccessExtension.php b/src/Doctrine/Extension/ProjectAccessExtension.php new file mode 100644 index 0000000..3e32936 --- /dev/null +++ b/src/Doctrine/Extension/ProjectAccessExtension.php @@ -0,0 +1,44 @@ +security->getUser(); + if (!$user instanceof User) { + return; + } + + $rootAlias = $queryBuilder->getRootAliases()[0]; + $teamMemberAlias = $queryNameGenerator->generateJoinAlias('teamMembers'); + + $queryBuilder + ->leftJoin(sprintf('%s.teamMembers', $rootAlias), $teamMemberAlias) + ->andWhere(sprintf('%s.owner = :current_user OR %s = :current_user', $rootAlias, $teamMemberAlias)) + ->setParameter('current_user', $user); + } +} diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 4980508..35cabe9 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -132,10 +132,22 @@ class Project implements ModuleAwareInterface #[Groups(['project:read'])] private Collection $gitRepositories; + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[Groups(['project:read', 'project:write'])] + #[Assert\NotNull(message: 'Ein Projekt muss einen Eigentümer haben')] + private ?User $owner = null; + + #[ORM\ManyToMany(targetEntity: User::class)] + #[ORM\JoinTable(name: 'project_team_members')] + #[Groups(['project:read', 'project:write'])] + private Collection $teamMembers; + public function __construct() { $this->createdAt = new \DateTimeImmutable(); $this->gitRepositories = new ArrayCollection(); + $this->teamMembers = new ArrayCollection(); } public function getId(): ?int @@ -344,6 +356,52 @@ class Project implements ModuleAwareInterface return $this; } + public function getOwner(): ?User + { + return $this->owner; + } + + public function setOwner(?User $owner): static + { + $this->owner = $owner; + $this->updatedAt = new \DateTimeImmutable(); + return $this; + } + + /** + * @return Collection + */ + public function getTeamMembers(): Collection + { + return $this->teamMembers; + } + + public function addTeamMember(User $teamMember): static + { + if (!$this->teamMembers->contains($teamMember)) { + $this->teamMembers->add($teamMember); + } + + return $this; + } + + public function removeTeamMember(User $teamMember): static + { + $this->teamMembers->removeElement($teamMember); + + return $this; + } + + public function isTeamMember(User $user): bool + { + return $this->teamMembers->contains($user); + } + + public function hasAccess(User $user): bool + { + return $this->owner === $user || $this->isTeamMember($user); + } + /** * Returns the module code this entity belongs to. * Required by ModuleVoter for permission checks. diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 2e1919e..fb4f47e 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -54,4 +54,19 @@ class ProjectRepository extends ServiceEntityRepository ->getQuery() ->getResult(); } + + /** + * Find all projects where user is owner or team member + */ + public function findUserProjects(\App\Entity\User $user): array + { + return $this->createQueryBuilder('p') + ->leftJoin('p.teamMembers', 'tm') + ->where('p.owner = :user') + ->orWhere('tm = :user') + ->setParameter('user', $user) + ->orderBy('p.startDate', 'DESC') + ->getQuery() + ->getResult(); + } } diff --git a/src/Security/Voter/GitRepositoryVoter.php b/src/Security/Voter/GitRepositoryVoter.php index 6e6cde7..e478038 100644 --- a/src/Security/Voter/GitRepositoryVoter.php +++ b/src/Security/Voter/GitRepositoryVoter.php @@ -57,14 +57,8 @@ class GitRepositoryVoter extends Voter return false; } - // TODO: Implement proper project-level permissions when Project has user relationships - // For now, allow all authenticated users to view repositories in existing projects - // This is safe because: - // 1. SearchFilter on 'project' ensures users can only query their accessible projects - // 2. Direct item access requires knowing the project exists - // 3. Future ProjectVoter will add fine-grained control - - return true; + // Allow access if user is owner or team member + return $project->hasAccess($user); } private function canEdit(GitRepository $gitRepository, User $user): bool @@ -76,11 +70,8 @@ class GitRepositoryVoter extends Voter return false; } - // TODO: Implement role-based permissions when Project entity has user relationships - // For now, allow all authenticated users to edit repositories - // This maintains current behavior while adding structure for future restrictions - - return true; + // Allow edit if user is owner or team member + return $project->hasAccess($user); } private function canDelete(GitRepository $gitRepository, User $user): bool @@ -92,10 +83,7 @@ class GitRepositoryVoter extends Voter return false; } - // TODO: Restrict to project owners when Project entity has owner relationship - // For now, allow all authenticated users to delete repositories - // This maintains current behavior while adding structure for future restrictions - - return true; + // Only project owner can delete repositories + return $project->getOwner() === $user; } } diff --git a/src/Security/Voter/ProjectVoter.php b/src/Security/Voter/ProjectVoter.php new file mode 100644 index 0000000..88613ee --- /dev/null +++ b/src/Security/Voter/ProjectVoter.php @@ -0,0 +1,73 @@ +getUser(); + + if (!$user instanceof User) { + return false; + } + + // CREATE permission - any authenticated user can create projects + if ($attribute === self::CREATE) { + return true; + } + + /** @var Project $project */ + $project = $subject; + + return match ($attribute) { + self::VIEW => $this->canView($project, $user), + self::EDIT => $this->canEdit($project, $user), + self::DELETE => $this->canDelete($project, $user), + default => false, + }; + } + + private function canView(Project $project, User $user): bool + { + // Owner and team members can view + return $project->hasAccess($user); + } + + private function canEdit(Project $project, User $user): bool + { + // Owner and team members can edit + return $project->hasAccess($user); + } + + private function canDelete(Project $project, User $user): bool + { + // Only owner can delete + return $project->getOwner() === $user; + } +} diff --git a/src/State/ProjectCollectionProvider.php b/src/State/ProjectCollectionProvider.php new file mode 100644 index 0000000..a1a2efc --- /dev/null +++ b/src/State/ProjectCollectionProvider.php @@ -0,0 +1,31 @@ +security->getUser(); + + if (!$user instanceof User) { + return []; + } + + // Get all projects where user is owner or team member + return $this->projectRepository->findUserProjects($user); + } +}