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>
|
||||||
</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) -->
|
<!-- Documents Section (only for existing projects) -->
|
||||||
<div v-if="editingProject.id" class="mt-4">
|
<div v-if="editingProject.id" class="mt-4">
|
||||||
<div class="font-semibold text-lg mb-3">Dokumente</div>
|
<div class="font-semibold text-lg mb-3">Dokumente</div>
|
||||||
@ -796,6 +848,8 @@ const deleting = ref(false)
|
|||||||
const typeFilter = ref('all')
|
const typeFilter = ref('all')
|
||||||
const customers = ref([])
|
const customers = ref([])
|
||||||
const statuses = ref([])
|
const statuses = ref([])
|
||||||
|
const users = ref([])
|
||||||
|
const currentUser = ref(null)
|
||||||
const gitRepositories = ref([])
|
const gitRepositories = ref([])
|
||||||
const selectedRepository = ref(null)
|
const selectedRepository = ref(null)
|
||||||
const selectedBranch = ref('main')
|
const selectedBranch = ref('main')
|
||||||
@ -844,7 +898,7 @@ const projectColumns = ref([
|
|||||||
])
|
])
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadCustomers(), loadStatuses()])
|
await Promise.all([loadCustomers(), loadStatuses(), loadUsers(), loadCurrentUser()])
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadCustomers() {
|
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) {
|
function filterByType(type, loadData) {
|
||||||
typeFilter.value = type
|
typeFilter.value = type
|
||||||
|
|
||||||
@ -960,7 +1047,9 @@ function openNewProjectDialog() {
|
|||||||
endDate: null,
|
endDate: null,
|
||||||
budget: null,
|
budget: null,
|
||||||
hourContingent: null,
|
hourContingent: null,
|
||||||
isPrivate: false
|
isPrivate: false,
|
||||||
|
owner: currentUser.value,
|
||||||
|
teamMembers: []
|
||||||
}
|
}
|
||||||
submitted.value = false
|
submitted.value = false
|
||||||
projectDialog.value = true
|
projectDialog.value = true
|
||||||
@ -974,7 +1063,8 @@ function editProject(project) {
|
|||||||
startDate: project.startDate ? new Date(project.startDate) : null,
|
startDate: project.startDate ? new Date(project.startDate) : null,
|
||||||
endDate: project.endDate ? new Date(project.endDate) : null,
|
endDate: project.endDate ? new Date(project.endDate) : null,
|
||||||
budget: project.budget ? parseFloat(project.budget) : 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
|
submitted.value = false
|
||||||
projectDialog.value = true
|
projectDialog.value = true
|
||||||
@ -1218,7 +1308,7 @@ async function deleteGitRepo() {
|
|||||||
async function saveProject() {
|
async function saveProject() {
|
||||||
submitted.value = true
|
submitted.value = true
|
||||||
|
|
||||||
if (!editingProject.value.name) {
|
if (!editingProject.value.name || !editingProject.value.owner) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1238,7 +1328,9 @@ async function saveProject() {
|
|||||||
endDate: editingProject.value.endDate ? formatDateForAPI(editingProject.value.endDate) : null,
|
endDate: editingProject.value.endDate ? formatDateForAPI(editingProject.value.endDate) : null,
|
||||||
budget: editingProject.value.budget ? editingProject.value.budget.toString() : null,
|
budget: editingProject.value.budget ? editingProject.value.budget.toString() : null,
|
||||||
hourContingent: editingProject.value.hourContingent ? editingProject.value.hourContingent.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
|
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'])]
|
#[Groups(['project:read'])]
|
||||||
private Collection $gitRepositories;
|
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()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->createdAt = new \DateTimeImmutable();
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
$this->gitRepositories = new ArrayCollection();
|
$this->gitRepositories = new ArrayCollection();
|
||||||
|
$this->teamMembers = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
@ -344,6 +356,52 @@ class Project implements ModuleAwareInterface
|
|||||||
return $this;
|
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.
|
* Returns the module code this entity belongs to.
|
||||||
* Required by ModuleVoter for permission checks.
|
* Required by ModuleVoter for permission checks.
|
||||||
|
|||||||
@ -54,4 +54,19 @@ class ProjectRepository extends ServiceEntityRepository
|
|||||||
->getQuery()
|
->getQuery()
|
||||||
->getResult();
|
->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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement proper project-level permissions when Project has user relationships
|
// Allow access if user is owner or team member
|
||||||
// For now, allow all authenticated users to view repositories in existing projects
|
return $project->hasAccess($user);
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function canEdit(GitRepository $gitRepository, User $user): bool
|
private function canEdit(GitRepository $gitRepository, User $user): bool
|
||||||
@ -76,11 +70,8 @@ class GitRepositoryVoter extends Voter
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement role-based permissions when Project entity has user relationships
|
// Allow edit if user is owner or team member
|
||||||
// For now, allow all authenticated users to edit repositories
|
return $project->hasAccess($user);
|
||||||
// This maintains current behavior while adding structure for future restrictions
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function canDelete(GitRepository $gitRepository, User $user): bool
|
private function canDelete(GitRepository $gitRepository, User $user): bool
|
||||||
@ -92,10 +83,7 @@ class GitRepositoryVoter extends Voter
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Restrict to project owners when Project entity has owner relationship
|
// Only project owner can delete repositories
|
||||||
// For now, allow all authenticated users to delete repositories
|
return $project->getOwner() === $user;
|
||||||
// This maintains current behavior while adding structure for future restrictions
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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