Enhance authentication flow with username support and social login placeholders

- Updated sign-in and sign-up pages to allow users to log in with either email or username.
- Added social login options for Google and Apple, currently implemented as placeholders.
- Improved error handling with localized messages for authentication failures.
- Refactored input fields and validation logic to enhance user experience and accessibility.
This commit is contained in:
Matthias
2026-04-02 23:10:40 +02:00
parent 9fa0b8452e
commit f5f9753288
5 changed files with 379 additions and 25 deletions

View File

@@ -5,28 +5,80 @@ import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import Link from "next/link";
const socialProviders = [
{
id: "google",
name: "Google",
subtitle: "Platzhalter",
icon: "G",
},
{
id: "apple",
name: "Apple",
subtitle: "Platzhalter",
icon: "",
},
];
export default function SignInPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [magicLinkMessage, setMagicLinkMessage] = useState("");
const [socialMessage, setSocialMessage] = useState("");
const [loading, setLoading] = useState(false);
const [magicLinkLoading, setMagicLinkLoading] = useState(false);
const toGermanAuthError = (message?: string) => {
if (!message) {
return "Anmeldung fehlgeschlagen. Bitte versuche es erneut.";
}
const normalized = message.toLowerCase();
if (normalized.includes("invalid") || normalized.includes("credentials")) {
return "E-Mail/Username oder Passwort ist nicht korrekt.";
}
if (normalized.includes("verify") || normalized.includes("verification")) {
return "Bitte bestätige zuerst deine E-Mail-Adresse.";
}
if (normalized.includes("username")) {
return "Username oder Passwort ist nicht korrekt.";
}
return "Anmeldung fehlgeschlagen. Bitte prüfe deine Eingaben.";
};
const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setMagicLinkMessage("");
setLoading(true);
try {
const result = await authClient.signIn.email({
email,
password,
});
const trimmedIdentifier = identifier.trim();
const isEmailInput = trimmedIdentifier.includes("@");
if (!trimmedIdentifier) {
setError("Bitte gib deine E-Mail-Adresse oder deinen Username ein.");
return;
}
const result = isEmailInput
? await authClient.signIn.email({
email: trimmedIdentifier,
password,
})
: await authClient.signIn.username({
username: trimmedIdentifier,
password,
});
if (result.error) {
setError(result.error.message ?? "Anmeldung fehlgeschlagen");
setError(toGermanAuthError(result.error.message));
} else {
router.push("/dashboard");
}
@@ -41,21 +93,28 @@ export default function SignInPage() {
setError("");
setMagicLinkMessage("");
if (!email) {
setError("Bitte gib deine E-Mail-Adresse ein");
const trimmedIdentifier = identifier.trim();
if (!trimmedIdentifier) {
setError("Bitte gib zuerst deine E-Mail-Adresse ein.");
return;
}
if (!trimmedIdentifier.includes("@")) {
setError("Magic Link funktioniert nur mit einer E-Mail-Adresse.");
return;
}
setMagicLinkLoading(true);
try {
const result = await authClient.signIn.magicLink({
email,
email: trimmedIdentifier,
callbackURL: "/dashboard",
errorCallbackURL: "/auth/sign-in",
});
if (result.error) {
setError(result.error.message ?? "Magic Link konnte nicht gesendet werden");
setError(toGermanAuthError(result.error.message));
} else {
setMagicLinkMessage("Magic Link gesendet. Prüfe dein Postfach.");
}
@@ -66,6 +125,12 @@ export default function SignInPage() {
}
};
const handleSocialPlaceholder = (provider: string) => {
setError("");
setMagicLinkMessage("");
setSocialMessage(`${provider}-Login ist aktuell als Platzhalter eingebunden.`);
};
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="w-full max-w-sm space-y-6 rounded-xl border bg-card p-8 shadow-sm">
@@ -78,17 +143,19 @@ export default function SignInPage() {
<form onSubmit={handleSignIn} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1.5">
E-Mail
<label htmlFor="identifier" className="block text-sm font-medium mb-1.5">
E-Mail oder Username
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
id="identifier"
type="text"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
className="w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
placeholder="name@beispiel.de"
placeholder="name@beispiel.de oder dein Username"
autoCapitalize="none"
autoCorrect="off"
/>
</div>
@@ -111,6 +178,25 @@ export default function SignInPage() {
<p className="text-sm text-red-500">{error}</p>
)}
<div className="space-y-2">
<p className="text-xs text-center text-muted-foreground">Oder mit externen Anbietern</p>
{socialProviders.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => handleSocialPlaceholder(provider.name)}
className="w-full rounded-lg border bg-background px-4 py-2.5 text-sm font-medium hover:bg-muted transition-colors"
>
<span aria-hidden className="inline-block w-6 text-left">{provider.icon}</span>
{provider.name} {provider.subtitle}
</button>
))}
</div>
{socialMessage && (
<p className="text-sm text-amber-600">{socialMessage}</p>
)}
<button
type="submit"
disabled={loading}

View File

@@ -5,29 +5,171 @@ import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import Link from "next/link";
const socialProviders = [
{
id: "google",
name: "Google",
subtitle: "Platzhalter",
icon: "G",
},
{
id: "apple",
name: "Apple",
subtitle: "Platzhalter",
icon: "",
},
];
const MIN_USERNAME_LENGTH = 3;
const MAX_USERNAME_LENGTH = 30;
const MAX_USERNAME_ATTEMPTS = 8;
function normalizeUsername(value: string) {
return value
.trim()
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9._]+/g, ".")
.replace(/\.{2,}/g, ".")
.replace(/^[_\.]+|[_\.]+$/g, "");
}
function truncateWithSuffix(base: string, suffix = "") {
const allowedBaseLength = Math.max(MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH - suffix.length);
const safeBase = base.slice(0, allowedBaseLength);
return `${safeBase}${suffix}`.slice(0, MAX_USERNAME_LENGTH);
}
function fallbackUsernameFromInput(name: string, email: string) {
const emailLocalPart = email.split("@")[0] ?? "";
const fromName = normalizeUsername(name);
const fromEmail = normalizeUsername(emailLocalPart);
const candidate = fromName || fromEmail || "user";
if (candidate.length >= MIN_USERNAME_LENGTH) {
return candidate.slice(0, MAX_USERNAME_LENGTH);
}
return truncateWithSuffix(`${candidate}user`);
}
async function isUsernameAvailable(username: string) {
try {
const result = await authClient.isUsernameAvailable({ username });
if (result.error) {
return null;
}
return result.data?.available === true;
} catch {
return null;
}
}
async function getAvailableUsername(base: string) {
const normalizedBase = normalizeUsername(base);
const seeded =
normalizedBase.length >= MIN_USERNAME_LENGTH
? normalizedBase
: truncateWithSuffix(`${normalizedBase || "user"}user`);
for (let attempt = 0; attempt < MAX_USERNAME_ATTEMPTS; attempt += 1) {
const suffix = attempt === 0 ? "" : `.${Math.floor(100 + Math.random() * 900)}`;
const candidate = truncateWithSuffix(seeded, suffix);
const available = await isUsernameAvailable(candidate);
if (available === true) {
return candidate;
}
if (available === null) {
return candidate;
}
}
return truncateWithSuffix(seeded, `.${Date.now().toString().slice(-4)}`);
}
function toGermanAuthError(message?: string) {
if (!message) {
return "Registrierung fehlgeschlagen. Bitte versuche es erneut.";
}
const normalized = message.toLowerCase();
if (normalized.includes("email") && normalized.includes("already")) {
return "Diese E-Mail-Adresse wird bereits verwendet.";
}
if (normalized.includes("username") && normalized.includes("already")) {
return "Dieser Username ist bereits vergeben.";
}
if (normalized.includes("password")) {
return "Das Passwort erfüllt die Anforderungen nicht.";
}
if (normalized.includes("invalid username")) {
return "Der Username enthält ungültige Zeichen.";
}
return "Registrierung fehlgeschlagen. Bitte prüfe deine Eingaben.";
}
export default function SignUpPage() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [generateUsername, setGenerateUsername] = useState(true);
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [socialMessage, setSocialMessage] = useState("");
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setSocialMessage("");
setLoading(true);
try {
const trimmedUsername = username.trim();
let finalUsername: string | undefined;
if (trimmedUsername) {
const normalizedInput = normalizeUsername(trimmedUsername);
if (normalizedInput.length < MIN_USERNAME_LENGTH) {
setError("Dein Username ist zu kurz (mindestens 3 Zeichen).");
return;
}
const availability = await isUsernameAvailable(normalizedInput);
if (availability === false) {
setError("Dieser Username ist bereits vergeben.");
return;
}
finalUsername = normalizedInput;
} else if (generateUsername) {
const generatedBase = fallbackUsernameFromInput(name, email);
finalUsername = await getAvailableUsername(generatedBase);
}
const result = await authClient.signUp.email({
email,
password,
name,
username: finalUsername,
});
if (result.error) {
setError(result.error.message ?? "Registrierung fehlgeschlagen");
setError(toGermanAuthError(result.error.message));
} else {
setSuccess(true);
}
@@ -38,6 +180,11 @@ export default function SignUpPage() {
}
};
const handleSocialPlaceholder = (provider: string) => {
setError("");
setSocialMessage(`${provider}-Signup ist aktuell als Platzhalter eingebunden.`);
};
if (success) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
@@ -102,6 +249,31 @@ export default function SignUpPage() {
/>
</div>
<div className="space-y-2">
<label htmlFor="username" className="block text-sm font-medium mb-1.5">
Username (optional)
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
placeholder="z. B. max.mustermann"
autoCapitalize="none"
autoCorrect="off"
/>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={generateUsername}
onChange={(e) => setGenerateUsername(e.target.checked)}
className="h-4 w-4 rounded border"
/>
Username automatisch aus Name oder E-Mail generieren, wenn das Feld leer ist.
</label>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1.5">
Passwort
@@ -122,6 +294,25 @@ export default function SignUpPage() {
<p className="text-sm text-red-500">{error}</p>
)}
<div className="space-y-2">
<p className="text-xs text-center text-muted-foreground">Oder mit externen Anbietern</p>
{socialProviders.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => handleSocialPlaceholder(provider.name)}
className="w-full rounded-lg border bg-background px-4 py-2.5 text-sm font-medium hover:bg-muted transition-colors"
>
<span aria-hidden className="inline-block w-6 text-left">{provider.icon}</span>
{provider.name} {provider.subtitle}
</button>
))}
</div>
{socialMessage && (
<p className="text-sm text-amber-600">{socialMessage}</p>
)}
<button
type="submit"
disabled={loading}

View File

@@ -333,6 +333,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const pendingMoveAfterCreateRef = useRef(
new Map<string, { positionX: number; positionY: number }>(),
);
const pendingResizeAfterCreateRef = useRef(
new Map<string, { width: number; height: number }>(),
);
const resolvedRealIdByClientRequestRef = useRef(new Map<string, Id<"nodes">>());
const pendingEdgeSplitByClientRequestRef = useRef(
new Map<string, PendingEdgeSplit>(),
@@ -759,6 +762,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}
pendingMoveAfterCreateRef.current.delete(args.clientRequestId);
pendingResizeAfterCreateRef.current.delete(args.clientRequestId);
pendingEdgeSplitByClientRequestRef.current.delete(args.clientRequestId);
pendingConnectionCreatesRef.current.delete(args.clientRequestId);
resolvedRealIdByClientRequestRef.current.delete(args.clientRequestId);
@@ -1092,11 +1096,21 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
});
}
if (op.type === "resizeNode" && process.env.NODE_ENV !== "production") {
const resizeNodeId =
typeof op.payload.nodeId === "string" ? op.payload.nodeId : null;
const resizeClientRequestId = resizeNodeId
? clientRequestIdFromOptimisticNodeId(resizeNodeId)
: null;
const resizeResolvedRealId = resizeClientRequestId
? resolvedRealIdByClientRequestRef.current.get(resizeClientRequestId)
: null;
console.warn("[Canvas sync debug] resizeNode flush failed", {
opId: op.id,
attemptCount: op.attemptCount,
transient,
error: getErrorMessage(error),
clientRequestId: resizeClientRequestId,
resolvedRealId: resizeResolvedRealId ?? null,
...summarizeResizePayload(op.payload),
});
}
@@ -1259,13 +1273,67 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
[enqueueSyncMutation],
);
const runResizeNodeMutation = useCallback(
async (args: { nodeId: Id<"nodes">; width: number; height: number }) => {
await enqueueSyncMutation("resizeNode", args);
const flushPendingResizeForClientRequest = useCallback(
async (clientRequestId: string, realId: Id<"nodes">): Promise<void> => {
const pendingResize = pendingResizeAfterCreateRef.current.get(clientRequestId);
if (!pendingResize) return;
pendingResizeAfterCreateRef.current.delete(clientRequestId);
await enqueueSyncMutation("resizeNode", {
nodeId: realId,
width: pendingResize.width,
height: pendingResize.height,
});
},
[enqueueSyncMutation],
);
const runResizeNodeMutation = useCallback(
async (args: { nodeId: Id<"nodes">; width: number; height: number }) => {
const rawNodeId = args.nodeId as string;
if (!isOptimisticNodeId(rawNodeId)) {
await enqueueSyncMutation("resizeNode", args);
return;
}
if (!isSyncOnline) {
await enqueueSyncMutation("resizeNode", args);
return;
}
const clientRequestId = clientRequestIdFromOptimisticNodeId(rawNodeId);
const resolvedRealId = clientRequestId
? resolvedRealIdByClientRequestRef.current.get(clientRequestId)
: undefined;
if (resolvedRealId) {
await enqueueSyncMutation("resizeNode", {
nodeId: resolvedRealId,
width: args.width,
height: args.height,
});
return;
}
if (clientRequestId) {
pendingResizeAfterCreateRef.current.set(clientRequestId, {
width: args.width,
height: args.height,
});
}
if (process.env.NODE_ENV !== "production") {
console.info("[Canvas sync debug] deferred resize for optimistic node", {
nodeId: rawNodeId,
clientRequestId,
resolvedRealId: resolvedRealId ?? null,
width: args.width,
height: args.height,
});
}
},
[enqueueSyncMutation, isSyncOnline],
);
const runUpdateNodeDataMutation = useCallback(
async (args: { nodeId: Id<"nodes">; data: unknown }) => {
await enqueueSyncMutation("updateData", args);
@@ -1521,6 +1589,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
clientRequestId,
);
pendingMoveAfterCreateRef.current.delete(clientRequestId);
pendingResizeAfterCreateRef.current.delete(clientRequestId);
pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId);
pendingConnectionCreatesRef.current.delete(clientRequestId);
resolvedRealIdByClientRequestRef.current.delete(clientRequestId);
@@ -1572,6 +1641,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
error: String(error),
});
}
await flushPendingResizeForClientRequest(clientRequestId, realId);
return;
}
@@ -1592,10 +1662,12 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
positionX: pendingMove.positionX,
positionY: pendingMove.positionY,
});
await flushPendingResizeForClientRequest(clientRequestId, realId);
return;
}
resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId);
await flushPendingResizeForClientRequest(clientRequestId, realId);
return;
}
@@ -1643,6 +1715,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
[
canvasId,
runBatchRemoveNodesMutation,
flushPendingResizeForClientRequest,
runMoveNodeMutation,
runSplitEdgeAtExistingNodeMutation,
],

View File

@@ -8,7 +8,7 @@ import { internal } from "./_generated/api";
import { DataModel } from "./_generated/dataModel";
import { query } from "./_generated/server";
import { betterAuth } from "better-auth/minimal";
import { magicLink } from "better-auth/plugins";
import { magicLink, username } from "better-auth/plugins";
import { Resend } from "resend";
import authConfig from "./auth.config";
@@ -128,10 +128,14 @@ export const createAuth = (ctx: GenericCtx<DataModel>) => {
}
},
}),
username(),
convex({ authConfig }),
polar({
client: polarClient,
createCustomerOnSignUp: true,
// Keep signup resilient: Polar customer sync is best-effort and should
// never fail account creation. Checkout/portal flows can still create
// and use customers via externalCustomerId when needed.
createCustomerOnSignUp: false,
use: [
checkout({
successUrl: `${siteUrl}/dashboard?checkout=success`,

View File

@@ -1,9 +1,9 @@
import { createAuthClient } from "better-auth/react";
import { magicLinkClient } from "better-auth/client/plugins";
import { magicLinkClient, usernameClient } from "better-auth/client/plugins";
import { convexClient } from "@convex-dev/better-auth/client/plugins";
import { polarClient } from "@polar-sh/better-auth/client";
// Next.js: kein crossDomainClient nötig (same-origin via API Route Proxy)
export const authClient = createAuthClient({
plugins: [magicLinkClient(), convexClient(), polarClient()],
plugins: [magicLinkClient(), usernameClient(), convexClient(), polarClient()],
});