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
+107 -2
View File
@@ -1,17 +1,26 @@
<script>
/**
* Sign-in, first-run setup and the second factor, in one component.
* Sign-in, first-run setup, the second factor, the invite that creates an
* account, and choosing a new password — in one component.
*
* They are one screen because they are one decision tree: an instance with no
* accounts asks you to create one, an instance with accounts asks you to log
* in, and a login with 2FA asks for a code. Splitting them across routes would
* mean redirecting somebody mid-authentication, which is where sessions get
* lost.
*
* The two that arrive by email — /invite and /reset — do have routes, because a
* link has to be an address. Those routes render this component and it reads
* the token out of the URL, so the form is the same form and there is one place
* where signing in is written down.
*/
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
let mode = $state('loading'); // loading | login | setup | totp | forgot | sent
// loading | login | setup | totp | forgot | sent | invite | reset | done
let mode = $state('loading');
let email = $state('');
let password = $state('');
let name = $state('');
@@ -20,7 +29,23 @@
let fields = $state({});
let busy = $state(false);
/**
* The token from an emailed link, if this is one.
*
* Read from the URL rather than passed in as a prop: the layout renders this
* component in several places and none of them should have to know about
* tokens.
*/
let token = $derived($page.url.searchParams.get('token') ?? '');
let linkMode = $derived(
$page.url.pathname === '/invite' ? 'invite' : $page.url.pathname === '/reset' ? 'reset' : ''
);
$effect(() => {
if (linkMode) {
mode = linkMode;
return;
}
api
.setupState()
.then((state) => (mode = state.needs_setup ? 'setup' : 'login'))
@@ -78,6 +103,31 @@
await api.forgotPassword(email);
mode = 'sent';
break;
case 'invite':
// The email address is not asked for and not sent: it comes off the
// invite on the server, which is what stops a link being redirected
// to somebody the inviter did not invite.
await api.signup({
token,
name,
password,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
locale: navigator.language?.startsWith('en') ? 'en' : 'da'
});
// Signup starts the session itself, so there is nothing to log into.
await app.load();
goto('/');
break;
case 'reset':
await api.resetPassword(token, password);
// Not signed in afterwards, on purpose: the reset ends every session
// the account had, and quietly starting a new one here would undo
// the half of that which matters.
password = '';
mode = 'done';
break;
}
});
};
@@ -142,6 +192,60 @@
<button type="submit" disabled={busy}>Send link</button>
<button type="button" class="link" onclick={() => (mode = 'login')}>Tilbage</button>
{:else if mode === 'invite' || mode === 'reset'}
{#if !token}
<!-- A link that lost its token on the way. Saying so beats a form that
cannot succeed and a message that arrives only after it is filled in. -->
<p class="lede">
Linket er ikke komplet. Åbn det fra e-mailen igen, eller bed om et nyt.
</p>
<a class="link" href="/">Til log ind</a>
{:else if app.user}
<!-- Already somebody else. The invite is tied to an address, so using it
from this session would create a second account for a person who is
right here in the first one. -->
<p class="lede">
Du er logget ind som {app.user.name}. Log ud først, og åbn så linket igen.
</p>
<button
type="button"
class="link"
onclick={async () => {
await api.logout();
location.reload();
}}>Log ud</button
>
{:else if mode === 'invite'}
<p class="lede">Du er inviteret. Vælg et navn og en adgangskode.</p>
<label>
Navn
<input bind:value={name} autocomplete="name" required />
{#if fields.name}<span class="field-error">Skal udfyldes</span>{/if}
</label>
<label>
Adgangskode
<input bind:value={password} type="password" autocomplete="new-password" required />
<span class="hint">Mindst 10 tegn. En sætning er både nemmere og stærkere.</span>
{#if fields.password}<span class="field-error">{fields.password}</span>{/if}
</label>
<button type="submit" disabled={busy}>Opret konto</button>
{:else}
<p class="lede">Vælg en ny adgangskode.</p>
<label>
Ny adgangskode
<input bind:value={password} type="password" autocomplete="new-password" required />
<span class="hint">Mindst 10 tegn. Alle andre enheder bliver logget ud.</span>
{#if fields.password}<span class="field-error">{fields.password}</span>{/if}
</label>
<button type="submit" disabled={busy}>Gem adgangskoden</button>
{/if}
{:else if mode === 'done'}
<p class="lede">Adgangskoden er skiftet. Log ind med den nye.</p>
<a class="link" href="/">Til log ind</a>
{:else if mode === 'sent'}
<!-- Deliberately not "we sent an email": that would confirm the address
has an account here to anybody who tried it. -->
@@ -280,6 +384,7 @@
color: var(--ink-faint);
font-size: var(--text-sm);
text-align: center;
text-decoration: none;
}
.link:hover {