feat: email-first login with allow-check and access forward
This commit is contained in:
parent
82f7ff8b02
commit
b533549e45
8 changed files with 224 additions and 21 deletions
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue