diff --git a/app/auth/sign-in/page.tsx b/app/auth/sign-in/page.tsx index af732f7..b7c0682 100644 --- a/app/auth/sign-in/page.tsx +++ b/app/auth/sign-in/page.tsx @@ -10,7 +10,9 @@ export default function SignInPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); + const [magicLinkMessage, setMagicLinkMessage] = useState(""); const [loading, setLoading] = useState(false); + const [magicLinkLoading, setMagicLinkLoading] = useState(false); const handleSignIn = async (e: React.FormEvent) => { e.preventDefault(); @@ -35,6 +37,35 @@ export default function SignInPage() { } }; + const handleMagicLink = async () => { + setError(""); + setMagicLinkMessage(""); + + if (!email) { + setError("Bitte gib deine E-Mail-Adresse ein"); + return; + } + + setMagicLinkLoading(true); + try { + const result = await authClient.signIn.magicLink({ + email, + callbackURL: "/dashboard", + errorCallbackURL: "/auth/sign-in", + }); + + if (result.error) { + setError(result.error.message ?? "Magic Link konnte nicht gesendet werden"); + } else { + setMagicLinkMessage("Magic Link gesendet. Prüfe dein Postfach."); + } + } catch { + setError("Ein unerwarteter Fehler ist aufgetreten"); + } finally { + setMagicLinkLoading(false); + } + }; + return (
@@ -87,6 +118,18 @@ export default function SignInPage() { > {loading ? "Wird angemeldet…" : "Anmelden"} + + + {magicLinkMessage && ( +

{magicLinkMessage}

+ )}

diff --git a/convex/CLAUDE.md b/convex/CLAUDE.md index f83b394..585bfcc 100644 --- a/convex/CLAUDE.md +++ b/convex/CLAUDE.md @@ -119,7 +119,11 @@ Wirft bei unauthentifiziertem Zugriff. Wird von allen Queries und Mutations genu ### Auth-Race-Härtung - `canvases.get` nutzt optionalen Auth-Check und gibt bei fehlender Session `null` zurück (statt Throw), damit SSR/Client-Hydration bei kurzem Token-Race nicht in `404` kippt. +- `canvases.list` gibt bei fehlender Session eine leere Liste zurück (statt Throw), damit Dashboard-Subscriptions beim Logout keinen Error-Spam erzeugen. - `credits.getBalance` gibt bei fehlender Session einen Default-Stand (`0`-Werte) zurück (statt Throw), damit UI-Widgets nicht mit `Unauthenticated` fehlschlagen. +- `credits.getSubscription` fällt bei fehlender Session auf Free/Active zurück (statt Throw), damit Tier-UI stabil bleibt. +- `credits.getRecentTransactions` gibt bei fehlender Session `[]` zurück (statt Throw), damit Aktivitätslisten beim Logout sauber leeren. +- `credits.getUsageStats` gibt bei fehlender Session `0`-Statistiken zurück (statt Throw), damit Verbrauchsanzeigen ohne Fehler ausrendern. ### Idempotente Canvas-Mutations diff --git a/convex/auth.ts b/convex/auth.ts index 97f9365..42bc6d2 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -8,6 +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 { Resend } from "resend"; import authConfig from "./auth.config"; @@ -26,6 +27,9 @@ export const authComponent = createClient(components.betterAuth); // Auth Factory — wird pro Request aufgerufen (Convex ist request-scoped) export const createAuth = (ctx: GenericCtx) => { + const authAppUrl = appUrl ?? siteUrl; + const signInRedirectUrl = `${authAppUrl}/dashboard`; + return betterAuth({ baseURL: siteUrl, trustedOrigins: [siteUrl, lemonspaceAppOrigin, "http://localhost:3000"], @@ -78,6 +82,46 @@ export const createAuth = (ctx: GenericCtx) => { }, }, plugins: [ + magicLink({ + disableSignUp: true, + expiresIn: 60 * 10, // 10 Minuten + sendMagicLink: async ({ email, url }) => { + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) { + console.error("RESEND_API_KEY is not set — skipping magic link email"); + return; + } + + const magicLinkUrl = new URL(url); + magicLinkUrl.searchParams.set("callbackURL", signInRedirectUrl); + magicLinkUrl.searchParams.set("errorCallbackURL", `${authAppUrl}/auth/sign-in`); + + const resend = new Resend(apiKey); + const { error } = await resend.emails.send({ + from: "LemonSpace ", + to: email, + subject: "Dein LemonSpace Magic Link", + html: ` +

+

Dein Login-Link für LemonSpace 🍋

+

Klicke auf den Button, um dich anzumelden:

+ + Jetzt anmelden + +

+ Der Link ist 10 Minuten gültig. Falls der Button nicht funktioniert, kopiere diesen Link:
+ ${magicLinkUrl.toString()} +

+
+ `, + }); + + if (error) { + console.error("Failed to send magic link email:", error); + } + }, + }), convex({ authConfig }), polar({ client: polarClient, diff --git a/convex/canvases.ts b/convex/canvases.ts index 760366a..aa6b217 100644 --- a/convex/canvases.ts +++ b/convex/canvases.ts @@ -12,7 +12,10 @@ import { optionalAuth, requireAuth } from "./helpers"; export const list = query({ args: {}, handler: async (ctx) => { - const user = await requireAuth(ctx); + const user = await optionalAuth(ctx); + if (!user) { + return []; + } return await ctx.db .query("canvases") .withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId)) diff --git a/convex/credits.ts b/convex/credits.ts index 3bb5e8b..ebc7d71 100644 --- a/convex/credits.ts +++ b/convex/credits.ts @@ -101,7 +101,13 @@ export const listTransactions = query({ export const getSubscription = query({ args: {}, handler: async (ctx) => { - const user = await requireAuth(ctx); + const user = await optionalAuth(ctx); + if (!user) { + return { + tier: "free" as const, + status: "active" as const, + }; + } const row = await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", user.userId)) @@ -152,7 +158,10 @@ export const getRecentTransactions = query({ limit: v.optional(v.number()), }, handler: async (ctx, args) => { - const user = await requireAuth(ctx); + const user = await optionalAuth(ctx); + if (!user) { + return []; + } const limit = args.limit ?? 10; return await ctx.db @@ -170,7 +179,13 @@ export const getRecentTransactions = query({ export const getUsageStats = query({ args: {}, handler: async (ctx) => { - const user = await requireAuth(ctx); + const user = await optionalAuth(ctx); + if (!user) { + return { + monthlyUsage: 0, + totalGenerations: 0, + }; + } const now = new Date(); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime(); diff --git a/lib/auth-client.ts b/lib/auth-client.ts index 95396c7..0747a13 100644 --- a/lib/auth-client.ts +++ b/lib/auth-client.ts @@ -1,8 +1,9 @@ import { createAuthClient } from "better-auth/react"; +import { magicLinkClient } 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: [convexClient(), polarClient()], + plugins: [magicLinkClient(), convexClient(), polarClient()], });