feat(projects): change a member's role from the member list
An invite sent with the wrong role could not be corrected. The only way back was to remove the person and invite them again — and removing a member unassigns every task they were responsible for, so fixing a dropdown cost somebody their work. This is the other side of "an external user cannot create tasks": the rule was right, the role was wrong, and there was no way to say so. PATCH against a membership that already exists, not the upsert AddMember does. Somebody who is not a member is not silently made one: that is an invite, it belongs to the invite flow, and doing it here would let a typo in a user id add a stranger to a project without anybody being asked. The owner is refused, with the real reason rather than the true but useless one. Ownership lives on the project rather than in project_members, so the plain answer would have been "not a member" — which is correct and tells nobody anything. Ownership is transferred, not granted, and two owners is a state with no way to settle a disagreement about who may remove whom. In the list, the owner's standing is text and everybody else's is a dropdown. A disabled control would be a thing that exists to say no. And the roles are finally in Danish. The API's words are `owner`, `editor` and `viewer`, and they were being printed straight into a Danish page; the list now uses the same three phrases the invite form beside it already did. The smoke test drives both browsers, because the interesting part is that the other one's permissions really change: a viewer is told the project is read-only and has no quick-add box, the owner promotes them from the list, and after a reload the box is there and the notice is gone. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fc32fea607
commit
b6ea87ac19
@@ -205,6 +205,8 @@ export const api = {
|
||||
|
||||
listMembers: (projectId) => get(`/projects/${projectId}/members`),
|
||||
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
|
||||
setMemberRole: (projectId, userId, role) =>
|
||||
patch(`/projects/${projectId}/members/${userId}`, { role }),
|
||||
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
|
||||
activity: (projectId) => get(`/projects/${projectId}/activity`),
|
||||
|
||||
|
||||
@@ -245,6 +245,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What each standing is called in the interface.
|
||||
*
|
||||
* The API's words are `owner`, `editor` and `viewer`, and they were being
|
||||
* printed straight into a Danish page. These are the same three words the
|
||||
* invite form already uses, so the list and the form agree.
|
||||
*/
|
||||
const ROLES = { owner: 'Ejer', editor: 'Kan redigere', viewer: 'Kan kun se' };
|
||||
|
||||
/**
|
||||
* Changes what somebody may do, without removing them.
|
||||
*
|
||||
* Removing and re-inviting was the only way to correct a role, and it
|
||||
* unassigns every task they were responsible for on the way past — so fixing a
|
||||
* dropdown cost somebody their work.
|
||||
*/
|
||||
async function setMemberRole(member, role) {
|
||||
const previous = members;
|
||||
members = members.map((m) => (m.user_id === member.user_id ? { ...m, role } : m));
|
||||
try {
|
||||
await api.setMemberRole(project.id, member.user_id, role);
|
||||
} catch (e) {
|
||||
members = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function invite(event) {
|
||||
event.preventDefault();
|
||||
inviteError = '';
|
||||
@@ -360,7 +387,22 @@
|
||||
{member.name[0]?.toUpperCase()}
|
||||
</span>
|
||||
<span class="member-name">{member.name}</span>
|
||||
<span class="role">{member.role}</span>
|
||||
<!-- The owner's standing is not a choice: ownership is transferred,
|
||||
not granted, and the server refuses it. A disabled dropdown
|
||||
would be a control that exists to say no. -->
|
||||
{#if isOwner && member.role !== 'owner'}
|
||||
<select
|
||||
class="role-picker"
|
||||
value={member.role}
|
||||
aria-label="Rolle for {member.name}"
|
||||
onchange={(e) => setMemberRole(member, e.currentTarget.value)}
|
||||
>
|
||||
<option value="editor">{ROLES.editor}</option>
|
||||
<option value="viewer">{ROLES.viewer}</option>
|
||||
</select>
|
||||
{:else}
|
||||
<span class="role">{ROLES[member.role] ?? member.role}</span>
|
||||
{/if}
|
||||
{#if isOwner && member.role !== 'owner'}
|
||||
<button
|
||||
class="remove"
|
||||
@@ -764,6 +806,24 @@
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.role-picker {
|
||||
flex: none;
|
||||
padding: var(--s1) var(--s2);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-xs);
|
||||
outline: none;
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.role-picker:hover,
|
||||
.role-picker:focus {
|
||||
border-color: var(--accent);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.remove {
|
||||
color: var(--ink-faint);
|
||||
width: 20px;
|
||||
|
||||
Reference in New Issue
Block a user