feat: Implement project ownership and team member management with access control

- Added owner and team members relationships to the Project entity.
- Updated ProjectRepository to find projects based on user ownership or team membership.
- Enhanced ProjectVoter to manage view, edit, and delete permissions based on ownership and team membership.
- Created ProjectAccessExtension to filter projects based on user access.
- Updated ProjectManagement.vue to include owner and team member selection in the UI.
- Implemented API endpoints for managing Git repositories with proper access control.
- Added migration to update the database schema for project ownership and team members.
This commit is contained in:
olli 2025-11-14 10:49:54 +01:00
parent 8715dac059
commit 1e02439e8a
21 changed files with 962 additions and 23 deletions

View File

@ -278,6 +278,58 @@
</div>
</div>
<!-- Team & Access Management -->
<div class="font-semibold text-lg mt-4">Team & Zugriff</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label for="owner">Eigentümer *</label>
<Select
id="owner"
v-model="editingProject.owner"
:options="users"
option-label="email"
placeholder="Eigentümer auswählen"
filter
:disabled="saving"
:class="{ 'p-invalid': submitted && !editingProject.owner }"
>
<template #option="slotProps">
<div class="flex flex-col">
<span class="font-medium">{{ slotProps.option.email }}</span>
<span v-if="slotProps.option.firstName || slotProps.option.lastName" class="text-sm text-500">
{{ slotProps.option.firstName }} {{ slotProps.option.lastName }}
</span>
</div>
</template>
</Select>
<small v-if="submitted && !editingProject.owner" class="p-error">Eigentümer ist erforderlich</small>
</div>
<div class="flex flex-col gap-2">
<label for="teamMembers">Team-Mitglieder</label>
<Select
id="teamMembers"
v-model="editingProject.teamMembers"
:options="users"
option-label="email"
placeholder="Team-Mitglieder auswählen"
filter
multiple
:disabled="saving"
>
<template #option="slotProps">
<div class="flex flex-col">
<span class="font-medium">{{ slotProps.option.email }}</span>
<span v-if="slotProps.option.firstName || slotProps.option.lastName" class="text-sm text-500">
{{ slotProps.option.firstName }} {{ slotProps.option.lastName }}
</span>
</div>
</template>
</Select>
<small class="text-500">Team-Mitglieder können das Projekt ansehen und bearbeiten</small>
</div>
</div>
<!-- Documents Section (only for existing projects) -->
<div v-if="editingProject.id" class="mt-4">
<div class="font-semibold text-lg mb-3">Dokumente</div>
@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

148
bruno/README.md Normal file
View File

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

9
bruno/bruno.json Normal file
View File

@ -0,0 +1,9 @@
{
"version": "1",
"name": "myCRM API",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

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

View File

@ -0,0 +1,4 @@
vars {
baseUrl: https://mycrm.test
PHPSESSID: f5kmgf6khpmo106vkikupmpuc5
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Add owner and team members to projects
*/
final class Version20251114102103 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add owner (ManyToOne) and team_members (ManyToMany) relationships to projects table';
}
public function up(Schema $schema): void
{
// Check if owner_id already exists
$this->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');
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Doctrine\Extension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Project;
use App\Entity\User;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
final class ProjectAccessExtension implements QueryCollectionExtensionInterface
{
public function __construct(
private Security $security
) {
}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = []
): void {
if (Project::class !== $resourceClass) {
return;
}
$user = $this->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);
}
}

View File

@ -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<int, User>
*/
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.

View File

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

View File

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

View File

@ -0,0 +1,73 @@
<?php
namespace App\Security\Voter;
use App\Entity\Project;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ProjectVoter extends Voter
{
public const VIEW = 'VIEW';
public const EDIT = 'EDIT';
public const DELETE = 'DELETE';
public const CREATE = 'CREATE';
protected function supports(string $attribute, mixed $subject): bool
{
// Support CREATE on class name string
if ($attribute === self::CREATE && $subject === 'projects') {
return true;
}
// Support VIEW/EDIT/DELETE on Project instances
if (!in_array($attribute, [self::VIEW, self::EDIT, self::DELETE])) {
return false;
}
return $subject instanceof Project;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->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;
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\ProjectRepository;
use Symfony\Bundle\SecurityBundle\Security;
class ProjectCollectionProvider implements ProviderInterface
{
public function __construct(
private ProjectRepository $projectRepository,
private Security $security
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return [];
}
// Get all projects where user is owner or team member
return $this->projectRepository->findUserProjects($user);
}
}