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:
co-authored by
Claude Opus 5
parent
c14559ed0c
commit
34b718529c
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user