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 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 22:14:33 +02:00
co-authored by Claude Opus 5
parent fc32fea607
commit b6ea87ac19
8 changed files with 321 additions and 1 deletions
+57
View File
@@ -705,6 +705,63 @@ test('den sidste administrator kan hverken fjernes eller slette sig selv', async
await expect(mine.getByRole('button')).toHaveCount(0);
});
/**
* A member's role can be corrected without removing them.
*
* This is what a viewer who cannot add tasks looks like from the other side. The
* rule itself is right — a viewer may not write — but until now the only way to
* fix an invite sent with the wrong role was to remove the person and invite them
* again, which unassigns every task they were responsible for on the way past.
*
* Driven end to end because the interesting part is that the *other* browser's
* permissions actually change: the server decides, and the page has to agree.
*/
test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Fælles');
await sidebar.getByLabel('Projektnavn').press('Enter');
await page.getByRole('button', { name: 'Del' }).click();
await page.getByLabel('Inviter via e-mail').fill('andreas@example.dk');
await page.getByLabel('Rolle').selectOption('viewer');
await page.getByRole('button', { name: 'Inviter' }).click();
const link = await page.locator('.link-out code').textContent();
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const andreas = await context.newPage();
await andreas.goto(link);
await andreas.getByLabel('Navn').fill('andreas');
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await andreas.getByRole('button', { name: 'Opret konto' }).click();
await expect(andreas.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await andreas
.getByRole('navigation', { name: 'Hovedmenu' })
.getByRole('link', { name: /Fælles/ })
.click();
// A viewer sees the project and is told, plainly, that it is read-only.
await expect(andreas.getByText('Du kan se dette projekt, men ikke ændre det')).toBeVisible();
await expect(andreas.getByLabel('Ny opgave')).toHaveCount(0);
// The owner promotes them from the member list. The owner's own row is text,
// not a dropdown: ownership is transferred, not granted.
await page.reload();
await page.getByRole('button', { name: /Delt/ }).click();
await expect(page.getByText('Ejer')).toBeVisible();
await page.getByLabel('Rolle for andreas').selectOption('editor');
await andreas.reload();
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
await expect(andreas.getByText('Du kan se dette projekt')).toHaveCount(0);
await context.close();
expect(trouble).toEqual([]);
});
/**
* The MCP connector address must not fall through to the app shell.
*
+2
View File
@@ -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`),
+61 -1
View File
@@ -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;