diff --git a/PLAN_NEXT.md b/PLAN_NEXT.md
index f162c60..7261b30 100644
--- a/PLAN_NEXT.md
+++ b/PLAN_NEXT.md
@@ -127,6 +127,14 @@ workflows shot auto-selects `intake_form` to show the generated RJSF form.
Vite dev `server.watch` polling enabled (Windows bind-mount events often
never reach the Linux watcher → stale modules otherwise).
+## 0.5 Email-first login (`POST /api/auth/magic/request`)
+
+`/login` without `?token=` shows an email form (+ "Hier anfragen" link).
+Allowed = approved user row or `ADMIN_EMAILS` (admin auto-created on first
+request); anything else → 403 and the frontend forwards to
+`/access?email=…` (prefilled). Rate-limited (5/hour), token by mail only,
+delivery failures never 500 (see §0.4 hardening).
+
## 1. Locked decisions (do not re-ask)
* Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`,
diff --git a/backend/app/auth/api.py b/backend/app/auth/api.py
index 153668b..b1f6c81 100644
--- a/backend/app/auth/api.py
+++ b/backend/app/auth/api.py
@@ -21,6 +21,8 @@ from .schemas import (
AccessRequestRead,
AdminBootstrapRequest,
MagicConsumeRequest,
+ MagicRequestRead,
+ MagicRequestRequest,
MeRead,
MessageRead,
)
@@ -120,6 +122,26 @@ def reject_request(
return _read_request(request)
+@auth_router.post("/magic/request", response_model=MagicRequestRead)
+def request_magic(
+ data: MagicRequestRequest,
+ session: Session = Depends(get_db),
+):
+ """Email-first login: allowed emails get a magic link mailed.
+
+ 403 means "not allowed" — the frontend forwards those to /access.
+ The token travels by mail only, never in the response.
+ """
+ try:
+ user, token = service.request_magic_link(session, data.email)
+ except service.ForbiddenError as exc:
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
+ except service.RateLimitedError as exc:
+ raise HTTPException(status_code=429, detail=str(exc)) from exc
+ email_sent = mail.send_magic_link(user.email, mail.magic_link(token))
+ return MagicRequestRead(message="login link sent", email_sent=email_sent)
+
+
@auth_router.post("/magic/consume", response_model=MeRead)
def consume_magic(
data: MagicConsumeRequest,
diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py
index cee9e35..e7be2d1 100644
--- a/backend/app/auth/schemas.py
+++ b/backend/app/auth/schemas.py
@@ -33,6 +33,15 @@ class MagicConsumeRequest(BaseModel):
token: str = Field(min_length=1, max_length=200)
+class MagicRequestRequest(BaseModel):
+ email: str = Field(min_length=3, max_length=320)
+
+
+class MagicRequestRead(BaseModel):
+ message: str
+ email_sent: bool
+
+
class MeRead(BaseModel):
id: UUID
email: str
diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py
index bf13261..ac78524 100644
--- a/backend/app/auth/service.py
+++ b/backend/app/auth/service.py
@@ -24,6 +24,8 @@ SESSION_TTL = timedelta(days=30)
# Public request endpoint: at most this many requests per email per hour.
REQUESTS_PER_EMAIL_PER_HOUR = 5
+# Same for magic-link requests (email bombing / token spam protection).
+MAGIC_LINKS_PER_EMAIL_PER_HOUR = 5
class AuthError(Exception):
@@ -164,6 +166,39 @@ def issue_magic_token(session: Session, user: User) -> str:
return token
+def recent_token_count(session: Session, user: User, hours: int = 1) -> int:
+ since = utcnow() - timedelta(hours=hours)
+ return session.exec(
+ select(func.count())
+ .select_from(MagicLinkToken)
+ .where(
+ MagicLinkToken.user_id == user.id,
+ MagicLinkToken.created_at >= since,
+ )
+ ).one()
+
+
+def request_magic_link(session: Session, email: str) -> tuple[User, str]:
+ """Self-service login link for an allowed email.
+
+ Allowed = approved before (has a User row) or listed in ADMIN_EMAILS
+ (admin user is created on first request). Anything else raises
+ ForbiddenError — the frontend forwards those to the access form.
+ Note: 403-vs-200 distinguishes registered emails by design here.
+ """
+ email = normalize_email(email)
+ user = get_user_by_email(session, email)
+ if user is None:
+ if not is_admin_email(email):
+ raise ForbiddenError("email is not allowed to log in")
+ user = _get_or_create_user(session, "", email)
+ session.commit()
+ session.refresh(user)
+ if recent_token_count(session, user) >= MAGIC_LINKS_PER_EMAIL_PER_HOUR:
+ raise RateLimitedError("too many login links, try again later")
+ return user, issue_magic_token(session, user)
+
+
def approve_access_request(
session: Session, request_id: UUID, decided_by: str
) -> tuple[AccessRequest, User, str]:
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
index d29be8c..02bde95 100644
--- a/backend/tests/test_auth.py
+++ b/backend/tests/test_auth.py
@@ -268,3 +268,51 @@ async def test_approve_survives_delivery_failure(client, session, monkeypatch):
body = approved.json()
assert body["request"]["status"] == "approved"
assert body["email_sent"] is False
+
+
+async def test_magic_request_for_known_user(client, session):
+ _get_or_make_user(session, MEMBER)
+ response = await client.post(
+ "/api/auth/magic/request", json={"email": MEMBER}
+ )
+ assert response.status_code == 200, response.text
+ body = response.json()
+ assert body["message"] == "login link sent"
+ assert body["email_sent"] is False # no SMTP in tests
+ assert "token" not in response.text
+ tokens = session.exec(
+ select(MagicLinkToken).where(
+ MagicLinkToken.user_id
+ == service.get_user_by_email(session, MEMBER).id
+ )
+ ).all()
+ assert len(tokens) == 1
+
+
+async def test_magic_request_unknown_email_403(client):
+ response = await client.post(
+ "/api/auth/magic/request", json={"email": "stranger@example.com"}
+ )
+ assert response.status_code == 403
+
+
+async def test_magic_request_admin_email_creates_admin(client, session):
+ response = await client.post(
+ "/api/auth/magic/request", json={"email": ADMIN}
+ )
+ assert response.status_code == 200, response.text
+ user = service.get_user_by_email(session, ADMIN)
+ assert user is not None and user.role == "admin"
+
+
+async def test_magic_request_rate_limit(client, session):
+ _get_or_make_user(session, MEMBER)
+ for _ in range(service.MAGIC_LINKS_PER_EMAIL_PER_HOUR):
+ response = await client.post(
+ "/api/auth/magic/request", json={"email": MEMBER}
+ )
+ assert response.status_code == 200, response.text
+ limited = await client.post(
+ "/api/auth/magic/request", json={"email": MEMBER}
+ )
+ assert limited.status_code == 429
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index d913761..3746cd6 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -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) =>
diff --git a/frontend/src/pages/AccessPage.tsx b/frontend/src/pages/AccessPage.tsx
index 8248d6f..23fbaf9 100644
--- a/frontend/src/pages/AccessPage.tsx
+++ b/frontend/src/pages/AccessPage.tsx
@@ -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("");
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
index 5113ac9..b7557e0 100644
--- a/frontend/src/pages/LoginPage.tsx
+++ b/frontend/src/pages/LoginPage.tsx
@@ -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