diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js
index 5f5b322..c26a48d 100644
--- a/web/e2e/smoke.spec.js
+++ b/web/e2e/smoke.spec.js
@@ -361,6 +361,90 @@ test('en opgave kan trækkes til en anden dag og videre til et projekt', async (
expect(trouble).toEqual([]);
});
+/**
+ * An invite link creates the account it was sent to.
+ *
+ * The server has emailed this URL since invites were built and nothing rendered
+ * for it: the SPA fallback served the shell, the shell found no session, and the
+ * recipient got a plain sign-in form for an account that did not exist yet. A 200
+ * all the way down — the same shape as `/mcp` and `/.well-known/`, and invisible
+ * to every test that does not open the link.
+ *
+ * A second browser context, because the invitee is a different person: inheriting
+ * the signed-in session would test the one case that never happens.
+ */
+test('et invitationslink opretter kontoen og giver adgang til projektet', 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ælleshuset');
+ await sidebar.getByLabel('Projektnavn').press('Enter');
+ await expect(page.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'Del' }).click();
+ await page.getByLabel('Inviter via e-mail').fill('nabo@example.dk');
+ await page.getByRole('button', { name: 'Inviter' }).click();
+
+ // With no mail server the link is shown rather than swallowed, which is also
+ // the only way this test can get hold of it.
+ const link = await page.locator('.link-out code').textContent();
+ expect(link).toContain('/invite?token=');
+
+ // Explicitly empty, because a context made through the `browser` fixture picks
+ // up the project's storageState — and an invitee who is already signed in as
+ // the person who sent the invite is the one case that never happens.
+ const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
+ const invitee = await context.newPage();
+ const inviteeTrouble = watchForTrouble(invitee);
+ await invitee.goto(link);
+
+ await expect(invitee.getByText('Du er inviteret')).toBeVisible();
+ await invitee.getByLabel('Navn').fill('Nabo');
+ await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
+ await invitee.getByRole('button', { name: 'Opret konto' }).click();
+
+ // Signed in, with the project they were invited to.
+ await expect(invitee.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
+ await expect(
+ invitee.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: /Fælleshuset/ })
+ ).toBeVisible();
+
+ // One failure here is not trouble: the page loads before the account exists, so
+ // the shell's first `me()` is answered 401. That 401 is how the signed-out
+ // screen appears at all.
+ expect(inviteeTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
+ await context.close();
+ expect(trouble).toEqual([]);
+});
+
+/**
+ * The reset link renders its own form.
+ *
+ * The token is nonsense, so this stops at the form — which is the part that did
+ * not exist. "Glemt adgangskode?" has always sent a link to this address and the
+ * API has always accepted the token; what the link opened was the sign-in screen,
+ * asking for the password the person had just said they had forgotten.
+ */
+test('nulstillingslinket viser et felt til den nye adgangskode', async ({ browser }) => {
+ const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
+ const page = await context.newPage();
+
+ await page.goto('/reset?token=ugyldigt');
+ await expect(page.getByText('Vælg en ny adgangskode')).toBeVisible();
+
+ await page.getByLabel(/adgangskode/i).fill('et langt kodeord til test');
+ await page.getByRole('button', { name: 'Gem adgangskoden' }).click();
+
+ // And it says so rather than pretending: the token is not a real one.
+ await expect(page.getByRole('alert')).toBeVisible();
+ await context.close();
+});
+
/**
* The MCP connector address must not fall through to the app shell.
*
diff --git a/web/src/lib/components/SignIn.svelte b/web/src/lib/components/SignIn.svelte
index 9b8cba2..87c782e 100644
--- a/web/src/lib/components/SignIn.svelte
+++ b/web/src/lib/components/SignIn.svelte
@@ -1,17 +1,26 @@
+
+Invitation · verdande
+
+
diff --git a/web/src/routes/reset/+page.svelte b/web/src/routes/reset/+page.svelte
new file mode 100644
index 0000000..ebe9454
--- /dev/null
+++ b/web/src/routes/reset/+page.svelte
@@ -0,0 +1,15 @@
+
+
+Ny adgangskode · verdande
+
+