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 @@
@@ -81,8 +171,8 @@ async function submit() {
-
Rejoindre le projet
-
Indique ton nom — tu seras ajouté en tant que contributeur.
+
Ajouter à l'équipe
+
Choisis un membre existant ou crées-en un nouveau.
-
+
L'ajout de membres est réservé à l'admin en V1.
Se connecter
- — la V2 ouvrira l'auto-rejoint via Nextcloud.
-
-
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']);