diff --git a/frontend/src/components/JoinProjectModal.vue b/frontend/src/components/JoinProjectModal.vue index 211ba3f..3b18599 100644 --- a/frontend/src/components/JoinProjectModal.vue +++ b/frontend/src/components/JoinProjectModal.vue @@ -1,76 +1,166 @@ diff --git a/frontend/src/pages/ProjectDetailPage.vue b/frontend/src/pages/ProjectDetailPage.vue index 8e4a35f..25cd879 100644 --- a/frontend/src/pages/ProjectDetailPage.vue +++ b/frontend/src/pages/ProjectDetailPage.vue @@ -3,7 +3,7 @@ import { ref, computed, onMounted, watch } from 'vue' import { RouterLink, useRouter } from 'vue-router' import { api, ApiError } from '@/api/client' import { useAuth } from '@/composables/useAuth' -import type { Discussion, ProjectDetail, Resource, RoadmapStep, Wrapped } from '@/types/api' +import type { Discussion, Member, MemberRole, ProjectDetail, ProjectMemberEntry, Resource, RoadmapStep, Wrapped } from '@/types/api' import StatusBadge from '@/components/StatusBadge.vue' import CategoryBadge from '@/components/CategoryBadge.vue' @@ -181,22 +181,53 @@ async function deleteProject() { } } -function onJoined(payload: { member: { id: number; slug: string; display_name: string }; role: string }) { - // Optimiste : push dans la liste des membres locale. +function onJoined(payload: { member: Member; role: MemberRole }) { + // Optimiste : push dans la liste des membres locale avec les vraies infos. if (!project.value) return project.value.members.push({ - id: payload.member.id, - slug: payload.member.slug, + id: payload.member.id, + slug: payload.member.slug, display_name: payload.member.display_name, - initials: null, - kind: 'person', - avatar_url: null, - role: payload.role as 'contributor', - joined_at: new Date().toISOString(), + initials: payload.member.initials, + kind: payload.member.kind, + avatar_url: payload.member.avatar_url, + role: payload.role, + joined_at: new Date().toISOString(), }) project.value.counts.members = project.value.members.length } +async function changeMemberRole(m: ProjectMemberEntry, newRole: MemberRole) { + if (!isAuthenticated.value || !project.value || newRole === m.role) return + const oldRole = m.role + m.role = newRole // optimiste + try { + await api.patch( + `/api/projects/${encodeURIComponent(props.slug)}/members/${encodeURIComponent(m.slug)}`, + { role: newRole }, + ) + } catch (e) { + m.role = oldRole + if (e instanceof ApiError) alert(`Erreur : ${e.payload.message ?? e.code}`) + else alert('Erreur réseau') + } +} + +async function removeMember(m: ProjectMemberEntry) { + if (!isAuthenticated.value || !project.value) return + if (!confirm(`Retirer « ${m.display_name} » de l'équipe ?\nLe membre reste dans l'annuaire.`)) return + try { + await api.delete( + `/api/projects/${encodeURIComponent(props.slug)}/members/${encodeURIComponent(m.slug)}`, + ) + project.value.members = project.value.members.filter(x => x.id !== m.id) + project.value.counts.members = project.value.members.length + } catch (e) { + if (e instanceof ApiError) alert(`Erreur : ${e.payload.message ?? e.code}`) + else alert('Erreur réseau') + } +} + // Présentations dérivées const categoryColor = computed(() => project.value?.category?.color_hex ?? '#111827') const externalDomain = computed(() => { @@ -603,13 +634,39 @@ const ROLE_LABEL = { admin: 'Admin', contributor: 'Contributor', observer: 'Obse
  • {{ m.display_name }}
    -
    {{ ROLE_LABEL[m.role] }}
    + +
    {{ ROLE_LABEL[m.role] }}
    +
    + +
  • @@ -620,6 +677,7 @@ const ROLE_LABEL = { admin: 'Admin', contributor: 'Contributor', observer: 'Obse diff --git a/public/index.php b/public/index.php index e44d8e3..1b6a913 100644 --- a/public/index.php +++ b/public/index.php @@ -74,6 +74,7 @@ $router->patch ('/api/projects/:slug', [ProjectsControlle $router->delete('/api/projects/:slug', [ProjectsController::class, 'destroy']); $router->post ('/api/projects/:slug/members', [ProjectMembersController::class, 'store']); +$router->patch ('/api/projects/:slug/members/:member_slug', [ProjectMembersController::class, 'update']); $router->delete('/api/projects/:slug/members/:member_slug', [ProjectMembersController::class, 'destroy']); $router->post ('/api/projects/:slug/roadmap', [RoadmapController::class, 'store']); diff --git a/src/Controllers/ProjectMembersController.php b/src/Controllers/ProjectMembersController.php index ca09f70..c661c6f 100644 --- a/src/Controllers/ProjectMembersController.php +++ b/src/Controllers/ProjectMembersController.php @@ -98,6 +98,51 @@ final class ProjectMembersController ], 201); } + public function update(Request $req, array $params): void + { + $p = ProjectsController::findOr404($params['slug']); + $member = Database::fetchOne( + 'SELECT id, slug, display_name FROM members WHERE slug = :s', + [':s' => $params['member_slug']], + ); + if ($member === null) { + throw new \ApiException(404, ['error' => 'not_found']); + } + + $body = $req->json(); + $v = new Validator($body); + $v->required('role')->in('role', ['admin', 'contributor', 'observer']); + $v->check(); + + $updated = Database::update( + 'project_members', + ['role' => (string) $body['role']], + 'project_id = :pid AND member_id = :mid', + [':pid' => (int) $p['id'], ':mid' => (int) $member['id']], + ); + if ($updated === 0) { + // Soit la ligne pivot n'existe pas, soit le rôle est déjà le même. + // Vérifie pour distinguer 404 vs no-op. + $exists = Database::fetchValue( + 'SELECT 1 FROM project_members WHERE project_id = :pid AND member_id = :mid', + [':pid' => (int) $p['id'], ':mid' => (int) $member['id']], + ); + if ($exists === null) { + throw new \ApiException(404, ['error' => 'not_found']); + } + } + + Response::json([ + 'project_id' => (int) $p['id'], + 'member' => [ + 'id' => (int) $member['id'], + 'slug' => $member['slug'], + 'display_name' => $member['display_name'], + ], + 'role' => (string) $body['role'], + ]); + } + public function destroy(Request $req, array $params): void { $p = ProjectsController::findOr404($params['slug']);