feat(admin): a page for who is on the instance, and how somebody else gets on it
Release / Build and publish image (push) Failing after 10m0s
Release / Build and publish image (push) Failing after 10m0s
The README has promised "internal users are created by an admin" since the beginning and there was no such thing. There are exactly two ways an account has ever come into existence: the first admin at setup, and somebody accepting an invite to a project. So the only way to add a person was to share a project with them first. The store has been ready the whole time. CreateInvite already documents that "projectID may be empty, which invites somebody to the instance rather than to a particular project", and AcceptInvite already has the branch that grants no membership when there is none. requireAdmin has existed in the middleware and was used nowhere. This wires those three together and puts a page on them. An invite rather than an account with a password the administrator picks. A password typed here would be known to two people before it was ever used, and would then have to travel to the second one by some channel nobody has thought about. No name is asked for either — the signup form asks the person, and a name an administrator guesses is a name somebody has to correct. Two refusals keep the instance administrable: the last administrator cannot be demoted or deleted, and nobody can delete the account they are signed in as. Both are enforced on the server; the interface simply does not draw the buttons on your own row, because a button whose only purpose is to be refused wastes a click to teach a rule. The last-admin refusal gets its own error code, because "that already exists" is not what happened and this is the one refusal a person needs explained. Deleting an account is the only hard delete in this database, and building the confirmation is what turned up how wide it goes. `projects.owner_id` cascades, which is expected. But `tasks.created_by` cascades too — so removing a colleague deletes every task they ever wrote, including in projects somebody else owns. The count in the list says so, and the confirmation says it in words. Changing that to ON DELETE SET NULL, so the work stays and loses its author, means rebuilding the tasks table and its FTS triggers: worth doing, not worth doing quietly while adding a screen. It is written down in the store instead. Pending invites are listed and can be withdrawn. Only the token's hash is stored, so a link sent to the wrong address cannot be looked up and resent — withdrawing it is the only way to stop it before it expires. Six API tests for the guards and the counts, and two smoke tests: the whole loop from invite to a second browser creating the account and appearing in the list as somebody who is not an administrator, and the absence of buttons on your own row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a8b453843d
commit
e8ebd0fa78
@@ -153,6 +153,9 @@ test('hver fane under indstillinger renderer', async ({ page }) => {
|
||||
'Integrationer',
|
||||
'AI',
|
||||
'API-tokens',
|
||||
// Only an administrator sees this one, and the account these tests run as
|
||||
// is the first account, which is one.
|
||||
'Brugere',
|
||||
'Data og skabeloner'
|
||||
]) {
|
||||
await tabs.getByRole('link', { name: label, exact: true }).click();
|
||||
@@ -533,6 +536,72 @@ test('enhedslisten viser denne enhed, og en anden kan logges ud', async ({ brows
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* An administrator can put somebody on the instance without sharing a project
|
||||
* with them first.
|
||||
*
|
||||
* This is the answer to "how do I create a user": there is no open registration
|
||||
* and no account with a password somebody else chose, so adding a person means
|
||||
* issuing an invite that carries no project. The whole loop is worth driving —
|
||||
* the link, a second browser, the signup, and the new account showing up in the
|
||||
* list as somebody who is *not* an administrator.
|
||||
*/
|
||||
test('en administrator kan invitere en bruger til instansen', async ({ browser, page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/indstillinger/brugere');
|
||||
|
||||
await page.getByLabel('E-mailadresse').fill('anders@example.dk');
|
||||
await page.getByRole('button', { name: 'Send invitation' }).click();
|
||||
|
||||
const link = await page.locator('.link-out').textContent();
|
||||
expect(link).toContain('/invite?token=');
|
||||
await expect(page.getByText('anders@example.dk')).toBeVisible();
|
||||
await expect(page.getByText('til instansen')).toBeVisible();
|
||||
|
||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const invitee = await context.newPage();
|
||||
await invitee.goto(link);
|
||||
await invitee.getByLabel('Navn').fill('Anders');
|
||||
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await invitee.getByRole('button', { name: 'Opret konto' }).click();
|
||||
|
||||
// They are in — with an Inbox of their own, and no project shared with them,
|
||||
// which is the difference between this and a project invite.
|
||||
await expect(invitee.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
await expect(invitee.getByRole('heading', { name: 'I dag' })).toBeVisible();
|
||||
await context.close();
|
||||
|
||||
await page.reload();
|
||||
const konti = page.locator('section.panel').filter({ hasText: 'Konti' });
|
||||
const row = konti.locator('li').filter({ hasText: 'Anders' });
|
||||
await expect(row).toBeVisible();
|
||||
// Invited, not promoted. Asserted through the button that offers to promote
|
||||
// them — which only reads this way for somebody who is not one. Matching the
|
||||
// word "administrator" in the row would match that very button.
|
||||
await expect(row.getByRole('button', { name: 'Gør til administrator' })).toBeVisible();
|
||||
// And the invite is spent rather than left live.
|
||||
await expect(page.getByText('Afventer svar')).toBeHidden();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The two refusals that keep an instance administrable. Driven here as well as in
|
||||
* the API tests because the interface has to *say* why, and a 409 nobody explains
|
||||
* is a button that appears broken.
|
||||
*/
|
||||
test('den sidste administrator kan hverken fjernes eller slette sig selv', async ({ page }) => {
|
||||
await page.goto('/indstillinger/brugere');
|
||||
|
||||
const konti = page.locator('section.panel').filter({ hasText: 'Konti' });
|
||||
const mine = konti.locator('li').filter({ hasText: 'dig' });
|
||||
await expect(mine).toBeVisible();
|
||||
|
||||
// No buttons on your own row: the server refuses both, and a button whose only
|
||||
// purpose is to be refused wastes a click to teach a rule.
|
||||
await expect(mine.getByRole('button')).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* The MCP connector address must not fall through to the app shell.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user