fix(auth): the two links sent by email now go somewhere

An invite has always emailed `/invite?token=…` and "Glemt adgangskode?"
has always emailed `/reset?token=…`. Neither address existed. The Go
binary serves the app shell for anything it does not recognise, the shell
found no session, and both links opened the plain sign-in form — asking an
invited person to log into an account that did not exist yet, and asking
somebody who had forgotten their password for their password.

Both APIs were finished. `POST /auth/signup` takes the invite token, reads
the email address off the invite rather than the form — so a link cannot
be redirected to somebody else — creates the account, accepts the invite
and starts the session, all in one transaction. `POST
/auth/password/reset` takes the reset token and ends every session the
account had. Nothing was missing but two pages.

They are branches of SignIn rather than screens of their own, which is
what that component already says it is for: one decision tree, so a person
is never redirected mid-authentication. The routes exist because a link
has to be an address, and each renders the same component; it reads the
token out of the URL itself.

The reset does not sign you in afterwards. The whole point of ending every
session is that somebody may have been in the account, and quietly opening
a new one here would undo the half of that which matters.

Two states that are easy to leave out, and both are reachable in one click
from an old email: a link with no token says so instead of showing a form
that cannot succeed, and a link opened while already signed in as somebody
else offers to log out rather than creating a second account for a person
who is right there in the first one.

The smoke test walks the whole invite: share a project, read the link out
of the panel that shows it when there is no mail server, open it in a
second browser context, create the account, and find the shared project in
the sidebar. A second context and not a second page — inheriting the
session would test the one case that never happens, which is what it did
on the first run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 19:30:21 +02:00
co-authored by Claude Opus 5
parent c14559ed0c
commit 34b718529c
5 changed files with 237 additions and 2 deletions
+12
View File
@@ -13,6 +13,16 @@
let paletteOpen = $state(false);
let sidebarOpen = $state(false);
/**
* The two pages that arrive by email, and are for somebody who is not signed in.
*
* They render on their own rather than inside the shell or behind the sign-in
* screen. Without this the invite link showed a login form to a person who had
* no account yet, and the reset link showed one to a person who had forgotten
* the password it was asking for.
*/
let standalone = $derived(['/invite', '/reset'].includes($page.url.pathname));
$effect(() => {
app.load();
});
@@ -63,6 +73,8 @@
<!-- Deliberately blank. A spinner for a request that resolves in 30ms is a
flash of anxiety, not feedback. -->
<div class="booting"></div>
{:else if standalone}
{@render children()}
{:else if !app.user}
<SignIn />
{:else}
+19
View File
@@ -0,0 +1,19 @@
<script>
/**
* The address an invite link points at.
*
* It exists as a route because the server has been emailing this URL since
* invites were built, and until now nothing answered for it: the SPA fallback
* served the app shell, the shell found no session, and the recipient was shown
* a plain sign-in form for an account they did not have yet. A 200 the whole
* way, which is exactly how that class of bug hides here.
*
* The form is SignIn's: signing up from an invite is one branch of signing in,
* and it reads the token out of the URL itself.
*/
import SignIn from '$lib/components/SignIn.svelte';
</script>
<svelte:head><title>Invitation · verdande</title></svelte:head>
<SignIn />
+15
View File
@@ -0,0 +1,15 @@
<script>
/**
* The address a password-reset link points at.
*
* "Glemt adgangskode?" has always sent one and the API has always accepted the
* token — but nothing rendered here, so the link opened the sign-in screen and
* a person who had forgotten their password was shown the field they could not
* fill in. Same shape as the invite link, and found the same way.
*/
import SignIn from '$lib/components/SignIn.svelte';
</script>
<svelte:head><title>Ny adgangskode · verdande</title></svelte:head>
<SignIn />