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:
parent
8715dac059
commit
1e02439e8a
@ -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
|
||||
|
||||
65
bruno/Git Integration/Create Repository.bru
Normal file
65
bruno/Git Integration/Create Repository.bru
Normal 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
|
||||
}
|
||||
32
bruno/Git Integration/Delete Repository.bru
Normal file
32
bruno/Git Integration/Delete Repository.bru
Normal 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
|
||||
}
|
||||
52
bruno/Git Integration/Get Commits.bru
Normal file
52
bruno/Git Integration/Get Commits.bru
Normal 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
|
||||
}
|
||||
62
bruno/Git Integration/Get Contributions.bru
Normal file
62
bruno/Git Integration/Get Contributions.bru
Normal 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
|
||||
}
|
||||
34
bruno/Git Integration/Get Git Repositories.bru
Normal file
34
bruno/Git Integration/Get Git Repositories.bru
Normal 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
|
||||
}
|
||||
27
bruno/Git Integration/Get Single Repository.bru
Normal file
27
bruno/Git Integration/Get Single Repository.bru
Normal 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
|
||||
}
|
||||
36
bruno/Git Integration/Refresh Cache.bru
Normal file
36
bruno/Git Integration/Refresh Cache.bru
Normal 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
|
||||
}
|
||||
44
bruno/Git Integration/Test Repository Connection.bru
Normal file
44
bruno/Git Integration/Test Repository Connection.bru
Normal 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"
|
||||
}
|
||||
```
|
||||
}
|
||||
38
bruno/Git Integration/Update Repository.bru
Normal file
38
bruno/Git Integration/Update Repository.bru
Normal 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
148
bruno/README.md
Normal 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
9
bruno/bruno.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "myCRM API",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
6
bruno/environments/local.bru
Normal file
6
bruno/environments/local.bru
Normal 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
|
||||
}
|
||||
4
bruno/environments/myCRM.bru
Normal file
4
bruno/environments/myCRM.bru
Normal file
@ -0,0 +1,4 @@
|
||||
vars {
|
||||
baseUrl: https://mycrm.test
|
||||
PHPSESSID: f5kmgf6khpmo106vkikupmpuc5
|
||||
}
|
||||
81
migrations/Version20251114102103.php
Normal file
81
migrations/Version20251114102103.php
Normal 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');
|
||||
}
|
||||
}
|
||||
44
src/Doctrine/Extension/ProjectAccessExtension.php
Normal file
44
src/Doctrine/Extension/ProjectAccessExtension.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
73
src/Security/Voter/ProjectVoter.php
Normal file
73
src/Security/Voter/ProjectVoter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
31
src/State/ProjectCollectionProvider.php
Normal file
31
src/State/ProjectCollectionProvider.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user