diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0fb13a2b..6e9d6a18 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -50,7 +50,11 @@ jobs: PRODUCTION_URL: ${{ secrets.PRODUCTION_URL }} OAUTH_GITHUB_CLIENT_ID: ${{ secrets.OAUTH_GITHUB_CLIENT_ID }} OAUTH_GITHUB_CLIENT_SECRET: ${{ secrets.OAUTH_GITHUB_CLIENT_SECRET }} + OAUTH_GOOGLE_CLIENT_ID: ${{ secrets.OAUTH_GOOGLE_CLIENT_ID }} + OAUTH_GOOGLE_CLIENT_SECRET: ${{ secrets.OAUTH_GOOGLE_CLIENT_SECRET }} OAUTH_PROXY_SECRET: ${{ secrets.OAUTH_PROXY_SECRET }} + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + RESEND_FROM_EMAIL: ${{ secrets.RESEND_FROM_EMAIL }} ALLOWED_ORIGINS: ${{ secrets.ALLOWED_ORIGINS }} WEB_APP_URL: ${{ secrets.WEB_APP_URL }} diff --git a/apps/server/.env.example b/apps/server/.env.example index 9ca7bd78..fd3167eb 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -12,10 +12,16 @@ NODE_ENV= PRODUCTION_URL= WEB_APP_URL= -# Oauth +# Oauth Settings OAUTH_GITHUB_CLIENT_ID= OAUTH_GITHUB_CLIENT_SECRET= +OAUTH_GOOGLE_CLIENT_ID= +OAUTH_GOOGLE_CLIENT_SECRET= OAUTH_PROXY_SECRET= +# Email Settings +RESEND_API_KEY= +RESEND_FROM_EMAIL= + # CORS & Trusted Origins (comma-separated) ALLOWED_ORIGINS= diff --git a/apps/server/package.json b/apps/server/package.json index 5cf5c9f0..828ff0a3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -13,12 +13,14 @@ "auth:generate": "dotenvx run -- bun x @better-auth/cli generate -y --config src/auth/cli.ts --output src/db/models/auth.ts" }, "dependencies": { + "@better-auth-ui/react": "catalog:auth", "@better-auth/drizzle-adapter": "1.6.21", "@better-auth/expo": "catalog:auth", "@cyrus/connections": "workspace:*", "@cyrus/schemas": "workspace:*", "@dotenvx/dotenvx": "catalog:env", "@orpc/server": "catalog:rpc", + "@react-email/render": "^2.1.0", "@soorya-u/better-auth-desktop": "catalog:auth", "@soorya-u/better-auth-ws-ticket": "catalog:auth", "@t3-oss/env-core": "catalog:env", @@ -29,11 +31,15 @@ "evlog": "catalog:observability", "hono": "^4.12.27", "partyserver": "^0.5.8", + "react": "catalog:react", + "react-email": "^6.9.1", + "resend": "^6.18.0", "zod": "catalog:rpc" }, "devDependencies": { "@cyrus/typescript": "workspace:*", "@types/bun": "catalog:core", + "@types/react": "catalog:react", "drizzle-kit": "catalog:database", "typescript": "catalog:core", "vitest": "catalog:testing" diff --git a/apps/server/src/auth/index.test.ts b/apps/server/src/auth/index.test.ts index 70dc45a5..c10b7075 100644 --- a/apps/server/src/auth/index.test.ts +++ b/apps/server/src/auth/index.test.ts @@ -1,5 +1,6 @@ import { exports } from "cloudflare:workers"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; +import { resend } from "../emails"; const worker = exports.default; const ORIGIN = "https://cyrus.soorya-u.dev"; @@ -151,4 +152,31 @@ describe("device authorization against D1", () => { }; expect(tokenBody.access_token).toBeTruthy(); }); + + test("accepts magic-link sign-in requests", async () => { + const email = `magic-link-${crypto.randomUUID()}@cyrus.test`; + const sendSpy = vi.spyOn(resend.emails, "send").mockResolvedValue({ + data: { id: "email_test" }, + error: null, + headers: null, + }); + + try { + const requestMagicLink = await worker.fetch( + "https://cyrus.soorya-u.dev/api/auth/sign-in/magic-link", + { + method: "POST", + headers: authHeaders({ "content-type": "application/json" }), + body: JSON.stringify({ + email, + callbackURL: "https://cyrus.soorya-u.dev/workers", + }), + } + ); + expect(requestMagicLink.ok).toBe(true); + expect(sendSpy).toHaveBeenCalled(); + } finally { + sendSpy.mockRestore(); + } + }); }); diff --git a/apps/server/src/auth/options.ts b/apps/server/src/auth/options.ts index 79cb9191..b76c0d54 100644 --- a/apps/server/src/auth/options.ts +++ b/apps/server/src/auth/options.ts @@ -1,20 +1,19 @@ import { expo } from "@better-auth/expo"; import { betterAuthDesktop } from "@soorya-u/better-auth-desktop/server"; -import { wsTicketPlugin } from "@soorya-u/better-auth-ws-ticket/server"; +import { wsTicketPlugin as wsTicket } from "@soorya-u/better-auth-ws-ticket/server"; import type { BetterAuthOptions } from "better-auth"; -import { bearer, deviceAuthorization, oAuthProxy } from "better-auth/plugins"; +import { + bearer, + deviceAuthorization, + magicLink, + oAuthProxy, +} from "better-auth/plugins"; import { log } from "evlog"; import { env } from "../config/env"; +import { sendMagicLinkEmail as sendMagicLink } from "../emails/magic-email"; const emailAndPassword = - env.NODE_ENV === "production" - ? {} - : { - emailAndPassword: { - enabled: true, - autoSignIn: true, - }, - }; + env.NODE_ENV === "production" ? {} : { emailAndPassword: { enabled: true } }; export const authOptions = { appName: "Cyrus", @@ -26,6 +25,10 @@ export const authOptions = { clientId: env.OAUTH_GITHUB_CLIENT_ID, clientSecret: env.OAUTH_GITHUB_CLIENT_SECRET, }, + google: { + clientId: env.OAUTH_GOOGLE_CLIENT_ID, + clientSecret: env.OAUTH_GOOGLE_CLIENT_SECRET, + }, }, secret: env.BETTER_AUTH_SECRET, baseURL: env.WEB_APP_URL, @@ -54,8 +57,12 @@ export const authOptions = { productionURL: env.PRODUCTION_URL, secret: env.OAUTH_PROXY_SECRET, }), + magicLink({ + disableSignUp: false, + sendMagicLink, + }), deviceAuthorization({ verificationUri: `${env.WEB_APP_URL}/auth/device` }), bearer(), - wsTicketPlugin(), + wsTicket(), ], } satisfies BetterAuthOptions; diff --git a/apps/server/src/config/env.ts b/apps/server/src/config/env.ts index 5e5fa54e..d027a639 100644 --- a/apps/server/src/config/env.ts +++ b/apps/server/src/config/env.ts @@ -7,7 +7,11 @@ export const env = createEnv({ BETTER_AUTH_SECRET: z.string().min(32), OAUTH_GITHUB_CLIENT_ID: z.string(), OAUTH_GITHUB_CLIENT_SECRET: z.string(), + OAUTH_GOOGLE_CLIENT_ID: z.string(), + OAUTH_GOOGLE_CLIENT_SECRET: z.string(), OAUTH_PROXY_SECRET: z.string(), + RESEND_API_KEY: z.string(), + RESEND_FROM_EMAIL: z.email(), NODE_ENV: z .enum(["development", "testing", "production"]) .default("development"), diff --git a/apps/server/src/emails/index.ts b/apps/server/src/emails/index.ts new file mode 100644 index 00000000..ee531b00 --- /dev/null +++ b/apps/server/src/emails/index.ts @@ -0,0 +1,4 @@ +import { Resend } from "resend"; +import { env } from "../config/env"; + +export const resend = new Resend(env.RESEND_API_KEY); diff --git a/apps/server/src/emails/magic-email.test.ts b/apps/server/src/emails/magic-email.test.ts new file mode 100644 index 00000000..3c3a9605 --- /dev/null +++ b/apps/server/src/emails/magic-email.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test, vi } from "vitest"; +import { env } from "../config/env"; +import { resend } from "./index"; + +describe("magic email", () => { + test("builds a magic-link template with the action URL", async () => { + const { buildMagicLinkEmail } = await import("./magic-email"); + const email = "person@cyrus.test"; + const url = "https://example.com/sign-in"; + const template = await buildMagicLinkEmail({ email, url }); + expect(template.subject).toBe("Sign in to Cyrus"); + expect(template.html).toContain(url); + expect(template.html).toContain(email); + expect(template.text).toContain(url); + }); + + test("sends Cyrus-branded payload through Resend", async () => { + const { sendMagicLinkEmail } = await import("./magic-email"); + const sendSpy = vi.spyOn(resend.emails, "send").mockResolvedValue({ + data: { id: "email_test" }, + error: null, + headers: null, + }); + const url = + "https://cyrus.soorya-u.dev/api/auth/magic-link/verify?token=123"; + + await sendMagicLinkEmail({ + email: "person@cyrus.test", + url, + }); + + try { + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + from: env.RESEND_FROM_EMAIL, + to: ["person@cyrus.test"], + subject: "Sign in to Cyrus", + html: expect.stringContaining(url), + text: expect.stringContaining(url), + }) + ); + expect(sendSpy.mock.calls[0]?.[0]?.html).toContain("person@cyrus.test"); + } finally { + sendSpy.mockRestore(); + } + }); + + test("throws when Resend returns an API error", async () => { + const { sendMagicLinkEmail } = await import("./magic-email"); + const apiError = { + name: "validation_error" as const, + message: "Invalid from address", + statusCode: 403 as const, + }; + const sendSpy = vi.spyOn(resend.emails, "send").mockResolvedValue({ + data: null, + error: apiError, + headers: null, + }); + + try { + await expect( + sendMagicLinkEmail({ + email: "person@cyrus.test", + url: "https://example.com/sign-in", + }) + ).rejects.toEqual(apiError); + } finally { + sendSpy.mockRestore(); + } + }); +}); diff --git a/apps/server/src/emails/magic-email.ts b/apps/server/src/emails/magic-email.ts new file mode 100644 index 00000000..6e9f31ce --- /dev/null +++ b/apps/server/src/emails/magic-email.ts @@ -0,0 +1,50 @@ +import { MagicLinkEmail } from "@better-auth-ui/react/email"; +import { render } from "@react-email/render"; +import { createElement } from "react"; +import { env } from "../config/env"; +import { resend } from "./index"; + +type EmailParams = { + email: string; + url: string; +}; + +function magicLinkElement(params: EmailParams) { + return createElement(MagicLinkEmail, { + appName: "Cyrus", + darkMode: true, + expirationMinutes: 5, + poweredBy: true, + ...params, + }); +} + +export async function buildMagicLinkEmail(params: EmailParams): Promise<{ + subject: string; + html: string; + text: string; +}> { + const element = magicLinkElement(params); + const [html, text] = await Promise.all([ + render(element), + render(element, { plainText: true }), + ]); + + return { + subject: "Sign in to Cyrus", + html, + text, + }; +} + +export async function sendMagicLinkEmail(params: EmailParams): Promise { + const template = await buildMagicLinkEmail(params); + const { error } = await resend.emails.send({ + from: env.RESEND_FROM_EMAIL, + to: [params.email], + ...template, + }); + if (error) { + throw error; + } +} diff --git a/apps/server/src/worker-configuration.d.ts b/apps/server/src/worker-configuration.d.ts index 2108dc83..2cb637f7 100644 --- a/apps/server/src/worker-configuration.d.ts +++ b/apps/server/src/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 9a12e9879675b42e7c1aa04a72e199f9) +// Generated by Wrangler by running `wrangler types` (hash: c04f1b3f0cb2898b03bf51bbcddb2b2e) // Runtime types generated with workerd@1.20260623.1 2025-06-01 nodejs_compat interface __BaseEnv_Env { DB: D1Database; @@ -11,6 +11,10 @@ interface __BaseEnv_Env { OAUTH_GITHUB_CLIENT_SECRET: string; OAUTH_PROXY_SECRET: string; ALLOWED_ORIGINS: string; + OAUTH_GOOGLE_CLIENT_ID: string; + OAUTH_GOOGLE_CLIENT_SECRET: string; + RESEND_API_KEY: string; + RESEND_FROM_EMAIL: string; HUB: DurableObjectNamespace; } declare namespace Cloudflare { @@ -25,7 +29,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/apps/web/package.json b/apps/web/package.json index 56661faa..10ef3bdd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,8 +11,8 @@ }, "dependencies": { "@base-ui/react": "^1.6.0", - "@better-auth-ui/core": "^1.6.39", - "@better-auth-ui/react": "^1.6.39", + "@better-auth-ui/core": "catalog:auth", + "@better-auth-ui/react": "catalog:auth", "@cyrus/connections": "workspace:*", "@cyrus/constants": "workspace:*", "@cyrus/errors": "workspace:*", diff --git a/apps/web/src/auth/two-factor-methods.ts b/apps/web/src/auth/two-factor-methods.ts deleted file mode 100644 index 01e5ee89..00000000 --- a/apps/web/src/auth/two-factor-methods.ts +++ /dev/null @@ -1,76 +0,0 @@ -export type TwoFactorMethod = "totp" | "otp"; - -const TWO_FACTOR_METHODS: TwoFactorMethod[] = ["totp", "otp"]; - -/** Auth plugin id used by Better Auth UI's two-factor integration. */ -export const TWO_FACTOR_PLUGIN_ID = "twoFactor"; - -/** - * `sessionStorage` key holding the methods reported by the sign-in response. - * - * Only the non-sensitive method names are stored, never a code, token, or the - * two-factor cookie, which stays HTTP-only. - */ -export const TWO_FACTOR_METHODS_STORAGE_KEY = - "better-auth-ui.two-factor-methods"; - -type TwoFactorRedirect = { - twoFactorRedirect: true; - twoFactorMethods?: unknown; -}; - -/** Detect the redirect payload Better Auth returns before a second factor. */ -export function isTwoFactorRedirect(data: unknown): data is TwoFactorRedirect { - return ( - typeof data === "object" && - data !== null && - (data as { twoFactorRedirect?: unknown }).twoFactorRedirect === true - ); -} - -/** Narrow arbitrary method names to the challenge views this UI supports. */ -export function parseTwoFactorMethods(methods?: unknown): TwoFactorMethod[] { - if (!Array.isArray(methods)) return []; - - return TWO_FACTOR_METHODS.filter((method) => methods.includes(method)); -} - -/** Persist the enabled method names without blocking sign-in on storage errors. */ -export function storeTwoFactorMethods(methods?: unknown) { - try { - if (typeof sessionStorage === "undefined") return; - - sessionStorage.setItem( - TWO_FACTOR_METHODS_STORAGE_KEY, - JSON.stringify(parseTwoFactorMethods(methods)) - ); - } catch { - // The challenge falls back to every method when storage is unavailable. - } -} - -/** Read the stored methods, falling back to every supported challenge. */ -export function readTwoFactorMethods(): TwoFactorMethod[] { - try { - if (typeof sessionStorage === "undefined") return TWO_FACTOR_METHODS; - - const stored = sessionStorage.getItem(TWO_FACTOR_METHODS_STORAGE_KEY); - if (!stored) return TWO_FACTOR_METHODS; - - const methods = parseTwoFactorMethods(JSON.parse(stored)); - return methods.length ? methods : TWO_FACTOR_METHODS; - } catch { - return TWO_FACTOR_METHODS; - } -} - -/** Clear stored method hints after the challenge finishes or is abandoned. */ -export function clearTwoFactorMethods() { - try { - if (typeof sessionStorage === "undefined") return; - - sessionStorage.removeItem(TWO_FACTOR_METHODS_STORAGE_KEY); - } catch { - // Stale method hints are harmless and must not block navigation. - } -} diff --git a/apps/web/src/auth/use-sign-in-continuation.ts b/apps/web/src/auth/use-sign-in-continuation.ts deleted file mode 100644 index 781e97a6..00000000 --- a/apps/web/src/auth/use-sign-in-continuation.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useAuth } from "@better-auth-ui/react"; -import { useCallback } from "react"; -import { - isTwoFactorRedirect, - storeTwoFactorMethods, - TWO_FACTOR_PLUGIN_ID, -} from "./two-factor-methods"; - -/** - * Resolve what happens after a sign-in request succeeds. - * - * Better Auth withholds the session when a second factor is required and - * answers with `{ twoFactorRedirect: true, twoFactorMethods }` instead, so no - * sign-in strategy may navigate to `redirectTo` unconditionally. This hook is - * the single place that decision lives — every password-based form calls it - * from `onSuccess`. - * - * The enabled methods are stashed in session storage (names only, never a - * code or token) and `redirectTo` rides along in the query string so the - * challenge view can finish the original navigation. - * - * The two-factor plugin is looked up by its stable id, so sign-in forms stay - * installable without either the two-factor components or a matching release - * of `@better-auth-ui/core`. - * - * @returns A callback taking the resolved data of a sign-in mutation. - */ -export function useSignInContinuation() { - const { basePaths, navigate, plugins, redirectTo } = useAuth(); - - const twoFactorPath = plugins.find( - (plugin) => plugin.id === TWO_FACTOR_PLUGIN_ID - )?.viewPaths?.auth?.twoFactor; - - return useCallback( - (data: unknown) => { - if (twoFactorPath && isTwoFactorRedirect(data)) { - storeTwoFactorMethods(data.twoFactorMethods); - - navigate({ - to: `${basePaths.auth}/${twoFactorPath}?redirectTo=${encodeURIComponent(redirectTo)}`, - }); - return; - } - - navigate({ to: redirectTo }); - }, - [basePaths.auth, navigate, redirectTo, twoFactorPath] - ); -} diff --git a/apps/web/src/components/auth/magic-link-button.tsx b/apps/web/src/components/auth/magic-link-button.tsx index d5c365a3..df65a8bb 100644 --- a/apps/web/src/components/auth/magic-link-button.tsx +++ b/apps/web/src/components/auth/magic-link-button.tsx @@ -5,8 +5,8 @@ import { useAuth, useAuthPlugin } from "@better-auth-ui/react"; import { useIsMutating } from "@tanstack/react-query"; import { cn } from "cnfast"; import { Lock, Mail } from "lucide-react"; -import { magicLinkPlugin } from "@/auth/magic-link-plugin"; import { buttonVariants } from "@/components/ui/button"; +import { magicLinkPlugin } from "@/lib/auth/plugins/magic-link-plugin"; export type MagicLinkButtonProps = { /** @remarks `AuthView` */ @@ -34,6 +34,7 @@ export function MagicLinkButton({ view }: MagicLinkButtonProps) { useAuthPlugin(magicLinkPlugin); const isMagicLinkView = view === "magicLink"; + const searchSuffix = window.location.search; // On the magic-link view this button switches back to password sign-in. // With password auth disabled there's nowhere to switch to, so hide it. @@ -46,10 +47,10 @@ export function MagicLinkButton({ view }: MagicLinkButtonProps) { aria-disabled={isPending || undefined} className={cn( buttonVariants({ variant: "outline" }), - "w-full", + "w-full bg-white/8 hover:border-primary hover:bg-white/12 dark:bg-white/8 dark:hover:bg-white/12", isPending && "pointer-events-none opacity-50" )} - href={`${basePaths.auth}/${isMagicLinkView ? viewPaths.auth.signIn : magicLinkViewPaths.auth.magicLink}`} + href={`${basePaths.auth}/${isMagicLinkView ? viewPaths.auth.signIn : magicLinkViewPaths.auth.magicLink}${searchSuffix}`} onClick={(event) => { if (isPending) event.preventDefault(); }} diff --git a/apps/web/src/components/auth/magic-link-sent.tsx b/apps/web/src/components/auth/magic-link-sent.tsx index 20594644..7c2b133a 100644 --- a/apps/web/src/components/auth/magic-link-sent.tsx +++ b/apps/web/src/components/auth/magic-link-sent.tsx @@ -1,39 +1,31 @@ import { useAuth, useAuthPlugin } from "@better-auth-ui/react"; import { cn } from "cnfast"; import { useState } from "react"; -import { magicLinkPlugin } from "@/auth/magic-link-plugin"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { FieldDescription } from "@/components/ui/field"; -import { MAGIC_LINK_SENT_STORAGE_KEY } from "@/constants/storage-keys"; +import { MAGIC_LINK_SENT } from "@/constants/storage-keys"; +import { magicLinkPlugin } from "@/lib/auth/plugins/magic-link-plugin"; import { OpenEmailButton } from "./open-email-button"; export type MagicLinkSentProps = { className?: string; }; -/** - * Render a card confirming that a magic-link email was sent, with a button - * to open the user's email provider. - * - * The target email is read from `sessionStorage` (set when the magic-link - * form redirects here); the OpenEmail button is only shown when an email is - * stored and resolves to a known provider. - * - * @param className - Additional CSS classes applied to the card - * @returns The magic-link-sent card React element - */ export function MagicLinkSent({ className }: MagicLinkSentProps) { const { basePaths, emailAndPassword, localization, viewPaths, Link } = useAuth(); const { localization: magicLinkLocalization } = useAuthPlugin(magicLinkPlugin); - const [email] = useState( - () => sessionStorage.getItem(MAGIC_LINK_SENT_STORAGE_KEY) ?? "" - ); + const [email] = useState(() => sessionStorage.getItem(MAGIC_LINK_SENT) ?? ""); return ( - + {localization.auth.checkYourEmailTitle} @@ -52,21 +44,20 @@ export function MagicLinkSent({ className }: MagicLinkSentProps) { {email && } - - {emailAndPassword?.enabled && (
- - {localization.auth.needToCreateAnAccount}{" "} - - {localization.auth.signUp} - - + {emailAndPassword?.enabled && ( +
+ + {localization.auth.signUp} + +
+ )}
- )} +
); diff --git a/apps/web/src/components/auth/magic-link.tsx b/apps/web/src/components/auth/magic-link.tsx index e3140c21..597aa7e7 100644 --- a/apps/web/src/components/auth/magic-link.tsx +++ b/apps/web/src/components/auth/magic-link.tsx @@ -7,13 +7,12 @@ import { } from "@better-auth-ui/react"; import { useIsMutating } from "@tanstack/react-query"; import { cn } from "cnfast"; +import { Mail } from "lucide-react"; import { type SyntheticEvent, useState } from "react"; -import { magicLinkPlugin } from "@/auth/magic-link-plugin"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Field, - FieldDescription, FieldError, FieldGroup, FieldLabel, @@ -21,13 +20,15 @@ import { } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; -import { MAGIC_LINK_SENT_STORAGE_KEY } from "@/constants/storage-keys"; +import { MAGIC_LINK_SENT } from "@/constants/storage-keys"; +import { magicLinkPlugin } from "@/lib/auth/plugins/magic-link-plugin"; import { ProviderButtons, type SocialLayout } from "./provider-buttons"; export type MagicLinkProps = { className?: string; socialLayout?: SocialLayout; socialPosition?: "top" | "bottom"; + callbackUrl?: string; }; /** @@ -42,19 +43,17 @@ export function MagicLink({ className, socialLayout, socialPosition = "bottom", + callbackUrl, }: MagicLinkProps) { const { authClient, basePaths, baseURL, - emailAndPassword, localization, navigate, plugins, redirectTo, socialProviders, - viewPaths, - Link, } = useAuth(); const { localization: magicLinkLocalization, viewPaths: magicLinkViewPaths } = useAuthPlugin(magicLinkPlugin); @@ -64,7 +63,7 @@ export function MagicLink({ const { mutate: signInMagicLink, isPending: signInMagicLinkPending } = useSignInMagicLink(authClient as MagicLinkAuthClient, { onSuccess: (_data, variables) => { - sessionStorage.setItem(MAGIC_LINK_SENT_STORAGE_KEY, variables.email); + sessionStorage.setItem(MAGIC_LINK_SENT, variables.email); navigate({ to: `${basePaths.auth}/${magicLinkViewPaths.auth.magicLinkSent}`, }); @@ -85,13 +84,21 @@ export function MagicLink({ const handleSubmit = (e: SyntheticEvent) => { e.preventDefault(); - signInMagicLink({ email, callbackURL: `${baseURL}${redirectTo}` }); + signInMagicLink({ + email, + callbackURL: callbackUrl ?? `${baseURL}${redirectTo}`, + }); }; const showSeparator = socialProviders && socialProviders.length > 0; return ( - + {localization.auth.signIn} @@ -101,11 +108,15 @@ export function MagicLink({ {socialPosition === "top" && ( <> {socialProviders && socialProviders.length > 0 && ( - + )} {showSeparator && ( - + {localization.auth.or} )} @@ -122,6 +133,7 @@ export function MagicLink({
- {plugins.flatMap((plugin) => @@ -172,31 +191,21 @@ export function MagicLink({ {socialPosition === "bottom" && ( <> {showSeparator && ( - + {localization.auth.or} )} {socialProviders && socialProviders.length > 0 && ( - + )} )}
- - {emailAndPassword?.enabled && ( -
- - {localization.auth.needToCreateAnAccount}{" "} - - {localization.auth.signUp} - - -
- )}
); diff --git a/apps/web/src/components/auth/provider-button.tsx b/apps/web/src/components/auth/provider-button.tsx index 8d742aca..ea624bc9 100644 --- a/apps/web/src/components/auth/provider-button.tsx +++ b/apps/web/src/components/auth/provider-button.tsx @@ -65,7 +65,10 @@ export function ProviderButton({ return ( - - {plugins.flatMap((plugin) => - (plugin.authButtons ?? []).map((AuthButton, index) => ( - - )) - )} - - - + + + { + setPassword(e.target.value); + + setFieldErrors((prev) => ({ + ...prev, + password: undefined, + })); + }} + onInvalid={(e) => { + e.preventDefault(); + const el = e.target as HTMLInputElement; + const min = emailAndPassword?.minPasswordLength; + const max = emailAndPassword?.maxPasswordLength; + + let msg = localization.auth.fieldRequired; + if (!el.validity.valueMissing) { + msg = el.validity.tooShort + ? localization.auth.tooShort.replace( + "{{min}}", + String(min) + ) + : localization.auth.tooLong.replace( + "{{max}}", + String(max) + ); + } + + setFieldErrors((prev) => ({ + ...prev, + password: msg, + })); + }} + placeholder={localization.auth.passwordPlaceholder} + required + type={isPasswordVisible ? "text" : "password"} + value={password} + /> + + + { + setIsPasswordVisible((visible) => !visible); + }} + size="icon-xs" + title={ + isPasswordVisible + ? localization.auth.hidePassword + : localization.auth.showPassword + } + > + {isPasswordVisible ? : } + + + + + {fieldErrors.password} + + + {emailAndPassword?.rememberMe && ( + +
+ + + + {localization.auth.rememberMe} + +
+
+ )} + + {Captcha && ( +
{Captcha}
+ )} + + + + + )} + + {authButtons} + )} {socialPosition === "bottom" && ( <> {showSeparator && ( - + {localization.auth.or} )} {socialProviders && socialProviders.length > 0 && ( - + )} )} - -
- {emailAndPassword?.enabled && emailAndPassword?.forgotPassword && ( - - {localization.auth.forgotPasswordLink} - - )} - - {emailAndPassword?.enabled && ( - - {localization.auth.needToCreateAnAccount}{" "} - - {localization.auth.signUp} - - - )} -
); diff --git a/apps/web/src/components/home/hero.tsx b/apps/web/src/components/home/hero.tsx index f7f975cf..5b3251d5 100644 --- a/apps/web/src/components/home/hero.tsx +++ b/apps/web/src/components/home/hero.tsx @@ -1,5 +1,5 @@ +import { Link } from "@tanstack/react-router"; import { AGENTS } from "@/constants/agents"; -import { ProviderButton } from "../auth/provider-button"; export function Hero() { return ( @@ -79,11 +79,13 @@ export function Hero() {

- + + Sign In +
diff --git a/apps/web/src/components/login-form.tsx b/apps/web/src/components/login-form.tsx new file mode 100644 index 00000000..0df2e200 --- /dev/null +++ b/apps/web/src/components/login-form.tsx @@ -0,0 +1,570 @@ +import { authMutationKeys } from "@better-auth-ui/core"; +import { + AuthPrompts, + type MagicLinkAuthClient, + useAuth, + useAuthPlugin, + useFetchOptions, + useSignInEmail, + useSignInMagicLink, +} from "@better-auth-ui/react"; +import { useIsMutating } from "@tanstack/react-query"; +import { cn } from "cnfast"; +import { Eye, EyeOff, Mail } from "lucide-react"; +import { + type Dispatch, + type ReactNode, + type SetStateAction, + type SyntheticEvent, + useState, +} from "react"; +import { LastUsedBadge } from "@/components/auth/last-login-method/last-used-badge"; +import { + ProviderButtons, + type SocialLayout, +} from "@/components/auth/provider-buttons"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Field, + FieldError, + FieldGroup, + FieldLabel, + FieldSeparator, +} from "@/components/ui/field"; +import { FlickeringGrid } from "@/components/ui/flickering-grid"; +import { Input } from "@/components/ui/input"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group"; +import { OrbitingCircles } from "@/components/ui/orbiting-circles"; +import { Spinner } from "@/components/ui/spinner"; +import { AGENTS } from "@/constants/agents"; +import { MAGIC_LINK_SENT } from "@/constants/storage-keys"; +import { useSignInContinuation } from "@/hooks/auth/use-sign-in-continuation"; +import { magicLinkPlugin } from "@/lib/auth/plugins/magic-link-plugin"; + +export type LoginFormProps = { + className?: string; + socialLayout?: SocialLayout; + socialPosition?: "top" | "bottom"; + callbackUrl?: string; +}; + +function LoginAgentsPanel() { + return ( +
+ +
+ + {AGENTS.slice() + .reverse() + .map((agent) => ( + + ))} + +
+
+ ); +} + +type PasswordFieldsProps = { + authButtons: ReactNode; + captcha: ReactNode; + emailError?: string; + isPending: boolean; + isPasswordVisible: boolean; + maxPasswordLength?: number; + minPasswordLength?: number; + onSubmit: (e: SyntheticEvent) => void; + onTogglePassword: () => void; + password: string; + passwordError?: string; + passwordPlaceholder: string; + rememberMe?: boolean; + rememberMeLabel: string; + setFieldErrors: Dispatch< + SetStateAction<{ email?: string; password?: string }> + >; + setPassword: (value: string) => void; + signInEmailPending: boolean; + labels: { + email: string; + emailPlaceholder: string; + fieldRequired: string; + hidePassword: string; + invalidEmail: string; + password: string; + showPassword: string; + signIn: string; + tooLong: string; + tooShort: string; + }; +}; + +function PasswordAuthFields({ + authButtons, + captcha, + emailError, + isPending, + isPasswordVisible, + labels, + maxPasswordLength, + minPasswordLength, + onSubmit, + onTogglePassword, + password, + passwordError, + passwordPlaceholder, + rememberMe, + rememberMeLabel, + setFieldErrors, + setPassword, + signInEmailPending, +}: PasswordFieldsProps) { + return ( +
+
+ + + {labels.email} + { + setFieldErrors((prev) => ({ ...prev, email: undefined })); + }} + onInvalid={(e) => { + e.preventDefault(); + const el = e.target as HTMLInputElement; + const msg = el.validity.valueMissing + ? labels.fieldRequired + : labels.invalidEmail; + setFieldErrors((prev) => ({ ...prev, email: msg })); + }} + placeholder={labels.emailPlaceholder} + required + type="email" + /> + {emailError} + + + + {labels.password} + + { + setPassword(e.target.value); + setFieldErrors((prev) => ({ + ...prev, + password: undefined, + })); + }} + onInvalid={(e) => { + e.preventDefault(); + const el = e.target as HTMLInputElement; + let msg = labels.fieldRequired; + if (!el.validity.valueMissing) { + msg = el.validity.tooShort + ? labels.tooShort.replace( + "{{min}}", + String(minPasswordLength) + ) + : labels.tooLong.replace( + "{{max}}", + String(maxPasswordLength) + ); + } + setFieldErrors((prev) => ({ ...prev, password: msg })); + }} + placeholder={passwordPlaceholder} + required + type={isPasswordVisible ? "text" : "password"} + value={password} + /> + + + {isPasswordVisible ? : } + + + + {passwordError} + + + {rememberMe && ( + +
+ + + {rememberMeLabel} + +
+
+ )} + + {captcha} + + +
+
+ {authButtons} +
+ ); +} + +type MagicLinkFieldsProps = { + authButtons: ReactNode; + email: string; + emailError?: string; + emailLabel: string; + emailPlaceholder: string; + isPending: boolean; + onSubmit: (e: SyntheticEvent) => void; + setEmail: (value: string) => void; + setFieldErrors: Dispatch< + SetStateAction<{ email?: string; password?: string }> + >; + signInMagicLinkPending: boolean; + submitLabel: string; +}; + +function MagicLinkAuthFields({ + authButtons, + email, + emailError, + emailLabel, + emailPlaceholder, + isPending, + onSubmit, + setEmail, + setFieldErrors, + signInMagicLinkPending, + submitLabel, +}: MagicLinkFieldsProps) { + return ( +
+ + + {emailLabel} + { + setEmail(e.target.value); + setFieldErrors((prev) => ({ ...prev, email: undefined })); + }} + onInvalid={(e) => { + e.preventDefault(); + setFieldErrors((prev) => ({ + ...prev, + email: (e.target as HTMLInputElement).validationMessage, + })); + }} + placeholder={emailPlaceholder} + required + type="email" + value={email} + /> + {emailError} + + +
+ + {authButtons} +
+
+
+ ); +} + +/** + * Two-column auth form combining email/password (when enabled) and magic-link + * flows with social providers, plus an orbiting-agents visual panel. + */ +export function LoginForm({ + className, + socialLayout, + socialPosition = "bottom", + callbackUrl, +}: LoginFormProps) { + const { + authClient, + basePaths, + baseURL, + emailAndPassword, + localization, + navigate, + plugins, + redirectTo, + socialProviders, + viewPaths, + } = useAuth(); + const { localization: magicLinkLocalization, viewPaths: magicLinkViewPaths } = + useAuthPlugin(magicLinkPlugin); + + const { fetchOptions, resetFetchOptions } = useFetchOptions(); + const continueSignIn = useSignInContinuation(); + + const canUseEmailAndPassword = Boolean(emailAndPassword?.enabled); + const view = canUseEmailAndPassword ? "signIn" : "magicLink"; + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const [fieldErrors, setFieldErrors] = useState<{ + email?: string; + password?: string; + }>({}); + + const { mutate: signInEmail, isPending: signInEmailPending } = useSignInEmail( + authClient, + { + onError: (error, { email: failedEmail }) => { + setPassword(""); + + if (error.error?.code === "EMAIL_NOT_VERIFIED") { + sessionStorage.setItem("better-auth-ui.verify-email", failedEmail); + navigate({ + to: `${basePaths.auth}/${viewPaths.auth.verifyEmail}`, + }); + } + + resetFetchOptions(); + }, + onSuccess: (data) => continueSignIn(data), + } + ); + + const { mutate: signInMagicLink, isPending: signInMagicLinkPending } = + useSignInMagicLink(authClient as MagicLinkAuthClient, { + onSuccess: (_data, variables) => { + sessionStorage.setItem(MAGIC_LINK_SENT, variables.email); + navigate({ + to: `${basePaths.auth}/${magicLinkViewPaths.auth.magicLinkSent}`, + }); + }, + }); + + const signInMutating = useIsMutating({ + mutationKey: authMutationKeys.signIn.all, + }); + const signUpMutating = useIsMutating({ + mutationKey: authMutationKeys.signUp.all, + }); + const isPending = signInMutating + signUpMutating > 0; + + const Captcha = plugins.find( + (plugin) => plugin.captchaComponent + )?.captchaComponent; + + const authButtons = plugins.flatMap((plugin) => + (plugin.authButtons ?? []).map((AuthButton, index) => ( + + )) + ); + + const showSeparator = Boolean(socialProviders?.length); + + const handlePasswordSubmit = (e: SyntheticEvent) => { + e.preventDefault(); + + const formData = new FormData(e.currentTarget); + const submittedEmail = formData.get("email") as string; + const rememberMe = formData.get("rememberMe") === "on"; + + signInEmail({ + email: submittedEmail, + password, + ...(emailAndPassword?.rememberMe ? { rememberMe } : {}), + fetchOptions, + }); + }; + + const handleMagicLinkSubmit = (e: SyntheticEvent) => { + e.preventDefault(); + signInMagicLink({ + email, + callbackURL: callbackUrl ?? `${baseURL}${redirectTo}`, + }); + }; + + const socialBlock = socialProviders && socialProviders.length > 0 && ( + + ); + + const separator = showSeparator && ( + + {localization.auth.or} + + ); + + const authFields = canUseEmailAndPassword ? ( + {Captcha} : null + } + emailError={fieldErrors.email} + isPasswordVisible={isPasswordVisible} + isPending={isPending} + labels={{ + email: localization.auth.email, + emailPlaceholder: localization.auth.emailPlaceholder, + fieldRequired: localization.auth.fieldRequired, + hidePassword: localization.auth.hidePassword, + invalidEmail: localization.auth.invalidEmail, + password: localization.auth.password, + showPassword: localization.auth.showPassword, + signIn: localization.auth.signIn, + tooLong: localization.auth.tooLong, + tooShort: localization.auth.tooShort, + }} + maxPasswordLength={emailAndPassword?.maxPasswordLength} + minPasswordLength={emailAndPassword?.minPasswordLength} + onSubmit={handlePasswordSubmit} + onTogglePassword={() => { + setIsPasswordVisible((visible) => !visible); + }} + password={password} + passwordError={fieldErrors.password} + passwordPlaceholder={localization.auth.passwordPlaceholder} + rememberMe={emailAndPassword?.rememberMe} + rememberMeLabel={localization.auth.rememberMe} + setFieldErrors={setFieldErrors} + setPassword={setPassword} + signInEmailPending={signInEmailPending} + /> + ) : ( + + ); + + return ( +
+ + +
+ + +
+

+ {localization.auth.signIn} +

+ + {socialPosition === "top" && ( + <> + {socialBlock} + {separator} + + )} + + {authFields} + + {socialPosition === "bottom" && ( + <> + {separator} + {socialBlock} + + )} +
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/ui/flickering-grid.tsx b/apps/web/src/components/ui/flickering-grid.tsx new file mode 100644 index 00000000..6ad4467a --- /dev/null +++ b/apps/web/src/components/ui/flickering-grid.tsx @@ -0,0 +1,190 @@ +import { cn } from "cnfast"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +type FlickeringGridProps = React.HTMLAttributes & { + className?: string; + color?: string; + flickerChance?: number; + gridGap?: number; + height?: number; + maxOpacity?: number; + squareSize?: number; + width?: number; +}; + +export function FlickeringGrid({ + squareSize = 4, + gridGap = 6, + flickerChance = 0.3, + color = "rgb(0, 0, 0)", + width, + height, + className, + maxOpacity = 0.3, + ...props +}: FlickeringGridProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isInView, setIsInView] = useState(false); + const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); + + const memoizedColor = useMemo(() => { + const toRGBA = (value: string) => { + if (typeof window === "undefined") { + return "rgba(0, 0, 0,"; + } + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 1; + const ctx = canvas.getContext("2d"); + if (!ctx) return "rgba(255, 0, 0,"; + ctx.fillStyle = value; + ctx.fillRect(0, 0, 1, 1); + const [r, g, b] = Array.from(ctx.getImageData(0, 0, 1, 1).data); + return `rgba(${r}, ${g}, ${b},`; + }; + return toRGBA(color); + }, [color]); + + const setupCanvas = useCallback( + (canvas: HTMLCanvasElement, nextWidth: number, nextHeight: number) => { + const dpr = window.devicePixelRatio || 1; + canvas.width = nextWidth * dpr; + canvas.height = nextHeight * dpr; + canvas.style.width = `${nextWidth}px`; + canvas.style.height = `${nextHeight}px`; + const cols = Math.ceil(nextWidth / (squareSize + gridGap)); + const rows = Math.ceil(nextHeight / (squareSize + gridGap)); + + const squares = new Float32Array(cols * rows); + for (let i = 0; i < squares.length; i++) { + squares[i] = Math.random() * maxOpacity; + } + + return { cols, rows, squares, dpr }; + }, + [squareSize, gridGap, maxOpacity] + ); + + const updateSquares = useCallback( + (squares: Float32Array, deltaTime: number) => { + for (let i = 0; i < squares.length; i++) { + if (Math.random() < flickerChance * deltaTime) { + squares[i] = Math.random() * maxOpacity; + } + } + }, + [flickerChance, maxOpacity] + ); + + const drawGrid = useCallback( + ( + ctx: CanvasRenderingContext2D, + canvasWidth: number, + canvasHeight: number, + cols: number, + rows: number, + squares: Float32Array, + dpr: number + ) => { + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + ctx.fillStyle = "transparent"; + ctx.fillRect(0, 0, canvasWidth, canvasHeight); + + for (let i = 0; i < cols; i++) { + for (let j = 0; j < rows; j++) { + const opacity = squares[i * rows + j]; + ctx.fillStyle = `${memoizedColor}${opacity})`; + ctx.fillRect( + i * (squareSize + gridGap) * dpr, + j * (squareSize + gridGap) * dpr, + squareSize * dpr, + squareSize * dpr + ); + } + } + }, + [memoizedColor, squareSize, gridGap] + ); + + useEffect(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + const ctx = canvas?.getContext("2d") ?? null; + let animationFrameId: number | null = null; + let resizeObserver: ResizeObserver | null = null; + let intersectionObserver: IntersectionObserver | null = null; + let gridParams: ReturnType | null = null; + + if (canvas && container && ctx) { + const updateCanvasSize = () => { + const newWidth = width || container.clientWidth; + const newHeight = height || container.clientHeight; + setCanvasSize({ width: newWidth, height: newHeight }); + gridParams = setupCanvas(canvas, newWidth, newHeight); + }; + + updateCanvasSize(); + + let lastTime = 0; + const animate = (time: number) => { + if (!(isInView && gridParams)) return; + + const deltaTime = (time - lastTime) / 1000; + lastTime = time; + + updateSquares(gridParams.squares, deltaTime); + drawGrid( + ctx, + canvas.width, + canvas.height, + gridParams.cols, + gridParams.rows, + gridParams.squares, + gridParams.dpr + ); + animationFrameId = requestAnimationFrame(animate); + }; + + resizeObserver = new ResizeObserver(() => { + updateCanvasSize(); + }); + resizeObserver.observe(container); + + intersectionObserver = new IntersectionObserver( + ([entry]) => { + setIsInView(entry.isIntersecting); + }, + { threshold: 0 } + ); + intersectionObserver.observe(canvas); + + if (isInView) { + animationFrameId = requestAnimationFrame(animate); + } + } + + return () => { + if (animationFrameId !== null) cancelAnimationFrame(animationFrameId); + if (resizeObserver) resizeObserver.disconnect(); + if (intersectionObserver) intersectionObserver.disconnect(); + }; + }, [setupCanvas, updateSquares, drawGrid, width, height, isInView]); + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/ui/input-group.tsx b/apps/web/src/components/ui/input-group.tsx index 6f0a1768..29753505 100644 --- a/apps/web/src/components/ui/input-group.tsx +++ b/apps/web/src/components/ui/input-group.tsx @@ -145,6 +145,7 @@ function InputGroupInput({ className )} data-slot="input-group-control" + unstyled {...props} /> ); diff --git a/apps/web/src/components/ui/orbiting-circles.tsx b/apps/web/src/components/ui/orbiting-circles.tsx new file mode 100644 index 00000000..b9606f14 --- /dev/null +++ b/apps/web/src/components/ui/orbiting-circles.tsx @@ -0,0 +1,72 @@ +import { cn } from "cnfast"; +import type React from "react"; +import { Children, type CSSProperties, type ReactNode } from "react"; + +export type OrbitingCirclesProps = React.HTMLAttributes & { + className?: string; + children?: ReactNode; + reverse?: boolean; + duration?: number; + delay?: number; + radius?: number; + path?: boolean; + iconSize?: number; + speed?: number; +}; + +export function OrbitingCircles({ + className, + children, + reverse, + duration = 20, + radius = 160, + path = true, + iconSize = 30, + speed = 1, + ...props +}: OrbitingCirclesProps) { + const calculatedDuration = duration / speed; + const childCount = Children.count(children); + + return ( +
+ {path && ( + + Orbit path + + + )} + {Children.map(children, (child, index) => { + const angle = (360 / childCount) * index; + return ( +
+ {child} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/constants/storage-keys.ts b/apps/web/src/constants/storage-keys.ts index f33683fa..7177c031 100644 --- a/apps/web/src/constants/storage-keys.ts +++ b/apps/web/src/constants/storage-keys.ts @@ -1,5 +1,5 @@ export const PROJECT_ORDER = "cyrus:project-order"; export const SIDEBAR_WIDTH = "cyrus:sidebar-width"; export const CONTROLLER_IDENTITY = "cyrus:controller-identity"; -export const MAGIC_LINK_SENT_STORAGE_KEY = - "cyrus:better-auth-ui:magic-link-sent"; +export const MAGIC_LINK_SENT = "cyrus:better-auth-ui:magic-link-sent"; +export const TWO_FACTOR_METHODS = "cyrus:better-auth-ui:two-factor-methods"; diff --git a/apps/web/src/hooks/auth/use-sign-in-continuation.ts b/apps/web/src/hooks/auth/use-sign-in-continuation.ts new file mode 100644 index 00000000..60fc2406 --- /dev/null +++ b/apps/web/src/hooks/auth/use-sign-in-continuation.ts @@ -0,0 +1,48 @@ +import { + isTwoFactorRedirect, + parseTwoFactorMethods, +} from "@better-auth-ui/core/plugins"; +import { useAuth } from "@better-auth-ui/react"; +import { useCallback } from "react"; +import { + TWO_FACTOR_PLUGIN_ID, + useTwoFactorMethodsStore, +} from "@/stores/two-factor-methods"; +import { resolvePostSignInRedirect } from "@/utils/callback"; + +/** Successful email/password sign-in session payload from Better Auth. */ +type SignInSessionData = { + token: string; + user: { id: string }; +}; + +export function useSignInContinuation() { + const { basePaths, navigate, plugins, redirectTo } = useAuth(); + + const twoFactorPath = plugins.find( + (plugin) => plugin.id === TWO_FACTOR_PLUGIN_ID + )?.viewPaths?.auth?.twoFactor; + + return useCallback( + (data: SignInSessionData) => { + const redirectTarget = resolvePostSignInRedirect( + window.location.search, + redirectTo + ); + + if (twoFactorPath && isTwoFactorRedirect(data)) { + useTwoFactorMethodsStore + .getState() + .setMethods(parseTwoFactorMethods(data.twoFactorMethods)); + + navigate({ + to: `${basePaths.auth}/${twoFactorPath}?redirectTo=${encodeURIComponent(redirectTarget)}`, + }); + return; + } + + navigate({ to: redirectTarget }); + }, + [basePaths.auth, navigate, redirectTo, twoFactorPath] + ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 8218bf53..0520bcfd 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -90,11 +90,25 @@ --radius-3xl: calc(var(--radius) + 12px); --radius-4xl: calc(var(--radius) + 16px); + --animate-orbit: orbit calc(var(--duration) * 1s) linear infinite; + @keyframes skeleton { to { background-position: -200% 0; } } + @keyframes orbit { + 0% { + transform: translate(-50%, -50%) rotate(calc(var(--angle) * 1deg)) + translateY(calc(var(--radius) * 1px)) rotate(calc(var(--angle) * -1deg)); + } + 100% { + transform: translate(-50%, -50%) + rotate(calc(var(--angle) * 1deg + 360deg)) + translateY(calc(var(--radius) * 1px)) + rotate(calc((var(--angle) * -1deg) - 360deg)); + } + } } @layer base { diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth/index.ts similarity index 72% rename from apps/web/src/lib/auth.ts rename to apps/web/src/lib/auth/index.ts index c13ea715..e50026be 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth/index.ts @@ -2,14 +2,22 @@ import { useSession } from "@soorya-u/better-auth-desktop/react"; import { defineAuthWebviewRPC } from "@soorya-u/better-auth-desktop/rpc/webview"; import { webDesktop, wrapForDesktop } from "@soorya-u/better-auth-desktop/web"; import { wsTicketClientPlugin } from "@soorya-u/better-auth-ws-ticket/client"; -import { deviceAuthorizationClient } from "better-auth/client/plugins"; +import { + deviceAuthorizationClient, + magicLinkClient, +} from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; -import { env } from "./env"; +import { env } from "../env"; const isDesktop = env.VITE_IS_DESKTOP; const base = createAuthClient({ - plugins: [webDesktop(), deviceAuthorizationClient(), wsTicketClientPlugin()], + plugins: [ + webDesktop(), + deviceAuthorizationClient(), + wsTicketClientPlugin(), + magicLinkClient(), + ], }); export const desktopAuth = isDesktop ? defineAuthWebviewRPC() : null; diff --git a/apps/web/src/auth/auth-plugin.ts b/apps/web/src/lib/auth/plugins/auth-plugin.ts similarity index 97% rename from apps/web/src/auth/auth-plugin.ts rename to apps/web/src/lib/auth/plugins/auth-plugin.ts index a37865b9..db09d4ba 100644 --- a/apps/web/src/auth/auth-plugin.ts +++ b/apps/web/src/lib/auth/plugins/auth-plugin.ts @@ -23,7 +23,7 @@ export type SettingsViewProps = { className?: string; }; -/** Shadcn plugin type. Plugin authors import this from `@/lib/auth/auth-plugin`. */ +/** Shadcn plugin type. Plugin authors import this from `@/lib/auth/plugins/auth-plugin`. */ export type AuthPlugin = AuthPluginPrimitive< AuthPluginComponents, AuthViewProps, diff --git a/apps/web/src/auth/magic-link-plugin.ts b/apps/web/src/lib/auth/plugins/magic-link-plugin.ts similarity index 100% rename from apps/web/src/auth/magic-link-plugin.ts rename to apps/web/src/lib/auth/plugins/magic-link-plugin.ts diff --git a/apps/web/src/lib/env.ts b/apps/web/src/lib/env.ts index 37507491..ace70c1b 100644 --- a/apps/web/src/lib/env.ts +++ b/apps/web/src/lib/env.ts @@ -6,6 +6,12 @@ export const env = createEnv({ client: { /** Worker origin for PartySocket / signaling. */ VITE_SERVER_URL: z.url(), + VITE_IS_DEV_MODE: z + .string() + .optional() + .transform((value) => + value === undefined ? import.meta.env.DEV : value === "true" + ), VITE_IS_DESKTOP: z.preprocess( () => typeof window !== "undefined" && !!window.__electrobunWebviewId, z.boolean() diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9ef6f241..e033adce 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { type AnyRouter, createRouter, + Link, RouterProvider, } from "@tanstack/react-router"; import { initLog } from "evlog/client"; @@ -16,8 +17,10 @@ import "@fontsource-variable/dm-sans"; import "@fontsource/jetbrains-mono/400.css"; import "@fontsource/jetbrains-mono/500.css"; +import { authClient } from "@/lib/auth"; +import { magicLinkPlugin } from "@/lib/auth/plugins/magic-link-plugin"; +import { env } from "@/lib/env"; import { DevTools } from "./devtools"; -import { authClient } from "./lib/auth"; import { routeTree } from "./routeTree.gen"; initLog({ @@ -55,6 +58,13 @@ const router = createRouter({ }, }); +declare module "@tanstack/react-router" { + // biome-ignore lint/style/useConsistentTypeDefinitions: required for module-augmentation merge + interface Register { + router: typeof router; + } +} + function WebQueryShell({ router, children, @@ -65,9 +75,14 @@ function WebQueryShell({ <> } navigate={({ to, replace }) => router.navigate({ to, replace })} + plugins={[magicLinkPlugin()]} queryClient={queryClient} redirectTo="/workers" + socialProviders={["github", "google"]} + viewPaths={{ auth: { signIn: "" } }} > {children} @@ -76,13 +91,6 @@ function WebQueryShell({ ); } -declare module "@tanstack/react-router" { - // biome-ignore lint/style/useConsistentTypeDefinitions: required for module-augmentation merge - interface Register { - router: typeof router; - } -} - const rootElement = document.getElementById("app"); if (!rootElement) throw new Error("Root element not found"); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index becea1b5..23b9cc96 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -12,7 +12,10 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as AuthRouteRouteImport } from './routes/auth/route' import { Route as WorkspaceRouteRouteImport } from './routes/_workspace/route' import { Route as IndexRouteImport } from './routes/index' +import { Route as AuthIndexRouteImport } from './routes/auth/index' import { Route as AuthSuccessRouteImport } from './routes/auth/success' +import { Route as AuthMagicLinkSentRouteImport } from './routes/auth/magic-link-sent' +import { Route as AuthMagicLinkRouteImport } from './routes/auth/magic-link' import { Route as AuthDeviceRouteImport } from './routes/auth/device' import { Route as AuthDesktopRouteImport } from './routes/auth/desktop' import { Route as AuthCallbackRouteImport } from './routes/auth/callback' @@ -47,11 +50,26 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const AuthIndexRoute = AuthIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AuthRouteRoute, +} as any) const AuthSuccessRoute = AuthSuccessRouteImport.update({ id: '/success', path: '/success', getParentRoute: () => AuthRouteRoute, } as any) +const AuthMagicLinkSentRoute = AuthMagicLinkSentRouteImport.update({ + id: '/magic-link-sent', + path: '/magic-link-sent', + getParentRoute: () => AuthRouteRoute, +} as any) +const AuthMagicLinkRoute = AuthMagicLinkRouteImport.update({ + id: '/magic-link', + path: '/magic-link', + getParentRoute: () => AuthRouteRoute, +} as any) const AuthDeviceRoute = AuthDeviceRouteImport.update({ id: '/device', path: '/device', @@ -168,7 +186,10 @@ export interface FileRoutesByFullPath { '/auth/callback': typeof AuthCallbackRoute '/auth/desktop': typeof AuthDesktopRoute '/auth/device': typeof AuthDeviceRoute + '/auth/magic-link': typeof AuthMagicLinkRoute + '/auth/magic-link-sent': typeof AuthMagicLinkSentRoute '/auth/success': typeof AuthSuccessRoute + '/auth/': typeof AuthIndexRoute '/workers/$workerId': typeof WorkspaceWorkersWorkerIdRouteRouteWithChildren '/settings/accounts': typeof WorkspaceSettingsAccountsRoute '/settings/archived': typeof WorkspaceSettingsArchivedRoute @@ -187,11 +208,13 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute - '/auth': typeof AuthRouteRouteWithChildren '/auth/callback': typeof AuthCallbackRoute '/auth/desktop': typeof AuthDesktopRoute '/auth/device': typeof AuthDeviceRoute + '/auth/magic-link': typeof AuthMagicLinkRoute + '/auth/magic-link-sent': typeof AuthMagicLinkSentRoute '/auth/success': typeof AuthSuccessRoute + '/auth': typeof AuthIndexRoute '/settings/accounts': typeof WorkspaceSettingsAccountsRoute '/settings/archived': typeof WorkspaceSettingsArchivedRoute '/settings/connections': typeof WorkspaceSettingsConnectionsRoute @@ -216,7 +239,10 @@ export interface FileRoutesById { '/auth/callback': typeof AuthCallbackRoute '/auth/desktop': typeof AuthDesktopRoute '/auth/device': typeof AuthDeviceRoute + '/auth/magic-link': typeof AuthMagicLinkRoute + '/auth/magic-link-sent': typeof AuthMagicLinkSentRoute '/auth/success': typeof AuthSuccessRoute + '/auth/': typeof AuthIndexRoute '/_workspace/workers/$workerId': typeof WorkspaceWorkersWorkerIdRouteRouteWithChildren '/_workspace/settings/accounts': typeof WorkspaceSettingsAccountsRoute '/_workspace/settings/archived': typeof WorkspaceSettingsArchivedRoute @@ -242,7 +268,10 @@ export interface FileRouteTypes { | '/auth/callback' | '/auth/desktop' | '/auth/device' + | '/auth/magic-link' + | '/auth/magic-link-sent' | '/auth/success' + | '/auth/' | '/workers/$workerId' | '/settings/accounts' | '/settings/archived' @@ -261,11 +290,13 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' - | '/auth' | '/auth/callback' | '/auth/desktop' | '/auth/device' + | '/auth/magic-link' + | '/auth/magic-link-sent' | '/auth/success' + | '/auth' | '/settings/accounts' | '/settings/archived' | '/settings/connections' @@ -289,7 +320,10 @@ export interface FileRouteTypes { | '/auth/callback' | '/auth/desktop' | '/auth/device' + | '/auth/magic-link' + | '/auth/magic-link-sent' | '/auth/success' + | '/auth/' | '/_workspace/workers/$workerId' | '/_workspace/settings/accounts' | '/_workspace/settings/archived' @@ -336,6 +370,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/auth/': { + id: '/auth/' + path: '/' + fullPath: '/auth/' + preLoaderRoute: typeof AuthIndexRouteImport + parentRoute: typeof AuthRouteRoute + } '/auth/success': { id: '/auth/success' path: '/success' @@ -343,6 +384,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthSuccessRouteImport parentRoute: typeof AuthRouteRoute } + '/auth/magic-link-sent': { + id: '/auth/magic-link-sent' + path: '/magic-link-sent' + fullPath: '/auth/magic-link-sent' + preLoaderRoute: typeof AuthMagicLinkSentRouteImport + parentRoute: typeof AuthRouteRoute + } + '/auth/magic-link': { + id: '/auth/magic-link' + path: '/magic-link' + fullPath: '/auth/magic-link' + preLoaderRoute: typeof AuthMagicLinkRouteImport + parentRoute: typeof AuthRouteRoute + } '/auth/device': { id: '/auth/device' path: '/device' @@ -554,14 +609,20 @@ interface AuthRouteRouteChildren { AuthCallbackRoute: typeof AuthCallbackRoute AuthDesktopRoute: typeof AuthDesktopRoute AuthDeviceRoute: typeof AuthDeviceRoute + AuthMagicLinkRoute: typeof AuthMagicLinkRoute + AuthMagicLinkSentRoute: typeof AuthMagicLinkSentRoute AuthSuccessRoute: typeof AuthSuccessRoute + AuthIndexRoute: typeof AuthIndexRoute } const AuthRouteRouteChildren: AuthRouteRouteChildren = { AuthCallbackRoute: AuthCallbackRoute, AuthDesktopRoute: AuthDesktopRoute, AuthDeviceRoute: AuthDeviceRoute, + AuthMagicLinkRoute: AuthMagicLinkRoute, + AuthMagicLinkSentRoute: AuthMagicLinkSentRoute, AuthSuccessRoute: AuthSuccessRoute, + AuthIndexRoute: AuthIndexRoute, } const AuthRouteRouteWithChildren = AuthRouteRoute._addFileChildren( diff --git a/apps/web/src/routes/auth/device.tsx b/apps/web/src/routes/auth/device.tsx index e6721c47..70f2633b 100644 --- a/apps/web/src/routes/auth/device.tsx +++ b/apps/web/src/routes/auth/device.tsx @@ -1,7 +1,6 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, redirect } from "@tanstack/react-router"; import { useState } from "react"; import { z } from "zod"; -import { ProviderButton } from "@/components/auth/provider-button"; import { Button } from "@/components/ui/button"; import { InputOTP, @@ -12,9 +11,21 @@ import { import { Spinner } from "@/components/ui/spinner"; import { useAuthDevice } from "@/hooks/auth/use-device"; import { authClient } from "@/lib/auth"; +import { buildDeviceCallbackPath } from "@/utils/callback"; export const Route = createFileRoute("/auth/device")({ validateSearch: z.object({ user_code: z.string().optional() }), + beforeLoad: async ({ search }) => { + const { data } = await authClient.getSession(); + if (data?.user) { + return; + } + + throw redirect({ + to: "/auth", + search: { callbackUrl: buildDeviceCallbackPath(search.user_code) }, + }); + }, component: DevicePage, }); @@ -29,23 +40,7 @@ function DevicePage() { const { outcome, decide, isDeciding } = useAuthDevice(); if (isPending) return ; - - if (!session?.user) { - const callbackUrl = `${window.location.origin}/auth/device${ - code ? `?user_code=${encodeURIComponent(code)}` : "" - }`; - return ( - <> -

- Authorize device -

-

- Sign in to connect a device to your Cyrus account. -

- - - ); - } + if (!session?.user) return ; if (outcome === "approved") return ( diff --git a/apps/web/src/routes/auth/index.tsx b/apps/web/src/routes/auth/index.tsx new file mode 100644 index 00000000..8dc7d5e3 --- /dev/null +++ b/apps/web/src/routes/auth/index.tsx @@ -0,0 +1,23 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { LoginForm } from "@/components/login-form"; +import { normalizeCallbackPath, toAbsoluteCallbackUrl } from "@/utils/callback"; + +const searchSchema = z.object({ + callbackUrl: z.string().optional(), +}); + +export const Route = createFileRoute("/auth/")({ + validateSearch: searchSchema, + component: AuthPage, +}); + +function AuthPage() { + const { callbackUrl } = Route.useSearch(); + const callbackPath = normalizeCallbackPath(callbackUrl); + const absoluteCallbackUrl = callbackPath + ? toAbsoluteCallbackUrl(callbackPath) + : undefined; + + return ; +} diff --git a/apps/web/src/routes/auth/magic-link-sent.tsx b/apps/web/src/routes/auth/magic-link-sent.tsx new file mode 100644 index 00000000..2a59b423 --- /dev/null +++ b/apps/web/src/routes/auth/magic-link-sent.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { MagicLinkSent } from "@/components/auth/magic-link-sent"; + +export const Route = createFileRoute("/auth/magic-link-sent")({ + component: MagicLinkSent, +}); diff --git a/apps/web/src/routes/auth/magic-link.tsx b/apps/web/src/routes/auth/magic-link.tsx new file mode 100644 index 00000000..46903a59 --- /dev/null +++ b/apps/web/src/routes/auth/magic-link.tsx @@ -0,0 +1,23 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { MagicLink } from "@/components/auth/magic-link"; +import { normalizeCallbackPath, toAbsoluteCallbackUrl } from "@/utils/callback"; + +const searchSchema = z.object({ + callbackUrl: z.string().optional(), +}); + +export const Route = createFileRoute("/auth/magic-link")({ + validateSearch: searchSchema, + component: MagicLinkPage, +}); + +function MagicLinkPage() { + const { callbackUrl } = Route.useSearch(); + const callbackPath = normalizeCallbackPath(callbackUrl); + const absoluteCallbackUrl = callbackPath + ? toAbsoluteCallbackUrl(callbackPath) + : undefined; + + return ; +} diff --git a/apps/web/src/routes/auth/route.tsx b/apps/web/src/routes/auth/route.tsx index f8382bf2..fba3897b 100644 --- a/apps/web/src/routes/auth/route.tsx +++ b/apps/web/src/routes/auth/route.tsx @@ -6,10 +6,11 @@ export const Route = createFileRoute("/auth")({ function AuthLayout() { return ( -
+
+ {/* Overflow clipped on the grid layer only — parent overflow:hidden breaks backdrop-filter. */}