feat: email-first login with allow-check and access forward

This commit is contained in:
Jonas Miederer 2026-09-12 11:20:55 +02:00
parent 82f7ff8b02
commit b533549e45
8 changed files with 224 additions and 21 deletions

View file

@ -193,6 +193,11 @@ export const api = {
method: "POST",
body: JSON.stringify({ token }),
}),
requestMagicLink: (email: string) =>
req<{ message: string; email_sent: boolean }>("/api/auth/magic/request", {
method: "POST",
body: JSON.stringify({ email }),
}),
logout: () =>
req<{ message: string }>("/api/auth/logout", { method: "POST" }),
requestAccess: (name: string, email: string, reason: string) =>

View file

@ -1,4 +1,5 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
Alert,
Button,
@ -13,8 +14,9 @@ import { IconAlertCircle, IconCheck } from "@tabler/icons-react";
import { ApiError, api } from "../api";
export default function AccessPage() {
const [params] = useSearchParams();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [email, setEmail] = useState(params.get("email") ?? "");
const [reason, setReason] = useState("");
const [done, setDone] = useState(false);
const [error, setError] = useState("");

View file

@ -1,19 +1,36 @@
import { useEffect, useState } from "react";
import { Link, Navigate, useSearchParams } from "react-router-dom";
import { Alert, Anchor, Card, Center, Loader, Title } from "@mantine/core";
import { IconAlertCircle } from "@tabler/icons-react";
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
import {
Alert,
Anchor,
Button,
Card,
Center,
Loader,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { IconAlertCircle, IconCheck, IconMail } from "@tabler/icons-react";
import { ApiError, api } from "../api";
import { useAuth } from "../auth";
export default function LoginPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const token = params.get("token") ?? "";
const { me, refresh } = useAuth();
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [error, setError] = useState("");
const [busy, setBusy] = useState(!!token);
useEffect(() => {
if (!token) return;
if (!token) {
setBusy(false);
return;
}
setBusy(true);
api
.consumeMagic(token)
@ -28,6 +45,31 @@ export default function LoginPage() {
.finally(() => setBusy(false));
}, [token, refresh]);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError("");
try {
await api.requestMagicLink(email.trim());
setSent(true);
} catch (err) {
if (err instanceof ApiError && err.status === 403) {
// Not allowed yet — forward to the access request form.
navigate(`/access?email=${encodeURIComponent(email.trim())}`);
return;
}
setError(
err instanceof ApiError
? err.status === 429
? "Zu viele Links angefordert — bitte später erneut versuchen."
: `${err.status}: ${err.message}`
: String(err),
);
} finally {
setBusy(false);
}
}
if (me) return <Navigate to="/app/workflows" replace />;
return (
@ -36,23 +78,55 @@ export default function LoginPage() {
<Title order={2} mb="md">
Login
</Title>
{busy && (
<Center>
<Loader />
</Center>
)}
{error && (
<Alert color="red" icon={<IconAlertCircle size={18} />}>
{error}
</Alert>
)}
{!token && !error && (
<Alert color="blue" title="Kein Link vorhanden">
<Anchor component={Link} to="/access">
Zugang anfragen
</Anchor>{" "}
— der Login-Link kommt per E-Mail.
{token ? (
<>
{busy && (
<Center>
<Loader />
</Center>
)}
{error && (
<Alert color="red" icon={<IconAlertCircle size={18} />}>
{error}
</Alert>
)}
</>
) : sent ? (
<Alert color="green" icon={<IconCheck size={18} />} title="Unterwegs">
Falls die Adresse freigegeben ist, kommt der Login-Link per
E-Mail. Bitte auch den Spam-Ordner prüfen.
</Alert>
) : (
<form onSubmit={(e) => void onSubmit(e)}>
<Stack>
{error && (
<Alert color="red" icon={<IconAlertCircle size={18} />}>
{error}
</Alert>
)}
<TextInput
label="E-Mail"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
maxLength={320}
/>
<Button
type="submit"
loading={busy}
leftSection={<IconMail size={16} />}
>
Login-Link anfordern
</Button>
<Text size="sm" c="dimmed">
Noch keinen Account?{" "}
<Anchor component={Link} to="/access">
Hier anfragen
</Anchor>
</Text>
</Stack>
</form>
)}
</Card>
</Center>