From 0ed825a2fd5df690460f72bea169f8264dac4846 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:15:51 +0200 Subject: [PATCH 01/51] refactor(better-auth): login/signup --- frontend/package.json | 1 + .../app/dialogs/create-organization-frame.tsx | 14 +- frontend/src/app/login.tsx | 161 +++++----------- frontend/src/app/sign-up.tsx | 172 ++++++------------ .../components/cloud-organization-select.tsx | 32 +--- frontend/src/components/forms/login-form.tsx | 76 ++++++++ .../src/components/forms/sign-up-form.tsx | 100 ++++++++++ frontend/src/lib/auth.ts | 34 ++-- frontend/src/routeTree.gen.ts | 42 +---- .../_context/_cloud/orgs.$organization.tsx | 12 +- frontend/src/routes/join.tsx | 12 +- frontend/src/routes/login.tsx | 6 +- .../routes/onboarding/choose-organization.tsx | 60 ------ pnpm-lock.yaml | 8 + 14 files changed, 337 insertions(+), 393 deletions(-) create mode 100644 frontend/src/components/forms/login-form.tsx create mode 100644 frontend/src/components/forms/sign-up-form.tsx delete mode 100644 frontend/src/routes/onboarding/choose-organization.tsx diff --git a/frontend/package.json b/frontend/package.json index 8af2a1aa93..ea4a28e3c7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -124,6 +124,7 @@ "cmdk": "^1.1.1", "d3-array": "^3.2.4", "date-fns": "^4.1.0", + "es-toolkit": "^1.45.1", "esast-util-from-js": "^2.0.1", "escape-html": "^1.0.3", "estree-util-to-js": "^2.0.0", diff --git a/frontend/src/app/dialogs/create-organization-frame.tsx b/frontend/src/app/dialogs/create-organization-frame.tsx index 8e28b276d0..033a5bdad6 100644 --- a/frontend/src/app/dialogs/create-organization-frame.tsx +++ b/frontend/src/app/dialogs/create-organization-frame.tsx @@ -5,17 +5,6 @@ import { useCloudDataProvider } from "@/components/actors"; import { authClient } from "@/lib/auth"; import { queryClient } from "@/queries/global"; -function generateSlug(name: string): string { - const base = name - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, "") - .replace(/\s+/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); - const suffix = Math.random().toString(36).substring(2, 6); - return `${base}-${suffix}`; -} - interface CreateOrganizationContentProps extends DialogContentProps {} export default function CreateOrganizationContent({ @@ -24,9 +13,10 @@ export default function CreateOrganizationContent({ const dataProvider = useCloudDataProvider(); const { mutateAsync } = useMutation({ mutationFn: async (values: { name: string }) => { + // Slug is generated server-side; send a throwaway value to satisfy the API requirement. const result = await authClient.organization.create({ name: values.name, - slug: generateSlug(values.name), + slug: crypto.randomUUID(), }); return result; }, diff --git a/frontend/src/app/login.tsx b/frontend/src/app/login.tsx index b211f153b1..9358f9759d 100644 --- a/frontend/src/app/login.tsx +++ b/frontend/src/app/login.tsx @@ -1,8 +1,21 @@ "use client"; -import { faGoogle, faSpinnerThird, Icon } from "@rivet-gg/icons"; -import { Link, useNavigate, useSearch } from "@tanstack/react-router"; +import { faGoogle, Icon } from "@rivet-gg/icons"; +import { + isRedirect, + Link, + useNavigate, + useSearch, +} from "@tanstack/react-router"; +import { attemptAsync } from "es-toolkit"; import { motion } from "framer-motion"; -import { type FormEvent, useState } from "react"; +import { + EmailField, + Form, + PasswordField, + RootError, + Submit, + type SubmitHandler, +} from "@/components/forms/login-form"; import { Button } from "@/components/ui/button"; import { Card, @@ -12,160 +25,76 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { authClient, redirectToOrganization } from "@/lib/auth"; - export function Login() { const navigate = useNavigate(); const from = useSearch({ strict: false, select: (s) => s?.from as string }); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [isGoogleLoading, setIsGoogleLoading] = useState(false); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setError(null); - setIsLoading(true); + const handleSubmit: SubmitHandler = async ({ email, password }, form) => { + const result = await authClient.signIn.email({ email, password }); - try { - const result = await authClient.signIn.email({ - email, - password, + if (result.error) { + form.setError("root", { + message: result.error.message ?? "Invalid credentials", }); + return; + } - if (result.error) { - setError(result.error.message ?? "Invalid credentials"); - setIsLoading(false); - return; - } - - // Redirect to org page - try { - await redirectToOrganization( - from ? { from } : {}, - ); - } catch (e) { - // redirectToOrganization throws a redirect - throw e; - } + const [error] = await attemptAsync( + async () => await redirectToOrganization(), + ); - // Fallback navigation if no redirect thrown - await navigate({ to: from ?? "/", search: true }); - } catch (e) { - // Re-throw redirect errors from TanStack Router - if (e && typeof e === "object" && "to" in e) { - throw e; - } - setError("An unexpected error occurred"); - setIsLoading(false); + if (error && isRedirect(error)) { + return navigate(error.options); } }; const handleGoogleSignIn = async () => { - setError(null); - setIsGoogleLoading(true); - - try { - await authClient.signIn.social({ - provider: "google", - callbackURL: from ?? "/", - }); - } catch { - setError("Failed to initiate Google sign-in"); - setIsGoogleLoading(false); - } + await authClient.signIn.social({ + provider: "google", + callbackURL: from ?? "/", + }); }; return ( - + Welcome! Enter your email below to login to your account. -
+

or

-
- - setEmail(e.target.value)} - disabled={isLoading} - /> -
-
- - setPassword(e.target.value)} - disabled={isLoading} - /> -
- {error ? ( -

{error}

- ) : null} + + +
- + Sign in
-
+
); diff --git a/frontend/src/app/sign-up.tsx b/frontend/src/app/sign-up.tsx index 8aa234b376..a5ba74c2a8 100644 --- a/frontend/src/app/sign-up.tsx +++ b/frontend/src/app/sign-up.tsx @@ -1,8 +1,22 @@ "use client"; -import { faGoogle, faSpinnerThird, Icon } from "@rivet-gg/icons"; -import { Link, useNavigate } from "@tanstack/react-router"; +import { faGoogle, Icon } from "@rivet-gg/icons"; +import { + isRedirect, + Link, + useNavigate, + useSearch, +} from "@tanstack/react-router"; +import { attemptAsync } from "es-toolkit"; import { motion } from "framer-motion"; -import { type FormEvent, useState } from "react"; +import { + EmailField, + Form, + NameField, + PasswordField, + RootError, + Submit, + type SubmitHandler, +} from "@/components/forms/sign-up-form"; import { Button } from "@/components/ui/button"; import { Card, @@ -12,163 +26,81 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { authClient } from "@/lib/auth"; +import { authClient, redirectToOrganization } from "@/lib/auth"; export function SignUp() { const navigate = useNavigate(); + const from = useSearch({ strict: false, select: (s) => s?.from as string }); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [isGoogleLoading, setIsGoogleLoading] = useState(false); + const handleSubmit: SubmitHandler = async ( + { name, email, password }, + form, + ) => { + const result = await authClient.signUp.email({ email, password, name }); - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setError(null); - setIsLoading(true); - - try { - const result = await authClient.signUp.email({ - email, - password, - name, + if (result.error) { + form.setError("root", { + message: result.error.message ?? "Sign up failed", }); + return; + } - if (result.error) { - setError(result.error.message ?? "Sign up failed"); - setIsLoading(false); - return; - } + const [error] = await attemptAsync( + async () => await redirectToOrganization(), + ); - // On success, redirect to onboarding - await navigate({ to: "/onboarding/choose-organization" }); - } catch (e) { - // Re-throw redirect errors from TanStack Router - if (e && typeof e === "object" && "to" in e) { - throw e; - } - setError("An unexpected error occurred"); - setIsLoading(false); + if (error && isRedirect(error)) { + return navigate(error.options); } }; const handleGoogleSignUp = async () => { - setError(null); - setIsGoogleLoading(true); - - try { - await authClient.signIn.social({ - provider: "google", - callbackURL: "/onboarding/choose-organization", - }); - } catch { - setError("Failed to initiate Google sign-up"); - setIsGoogleLoading(false); - } + await authClient.signIn.social({ + provider: "google", + callbackURL: from ?? "/", + }); }; return ( - + Welcome! Create your account to get started. -
+

or

-
- - setName(e.target.value)} - disabled={isLoading} - /> -
-
- - setEmail(e.target.value)} - disabled={isLoading} - /> -
-
- - setPassword(e.target.value)} - disabled={isLoading} - /> -
- {error ? ( -

{error}

- ) : null} + + + +
- + Continue
-
+
); diff --git a/frontend/src/components/cloud-organization-select.tsx b/frontend/src/components/cloud-organization-select.tsx index a899029a38..7160b7532a 100644 --- a/frontend/src/components/cloud-organization-select.tsx +++ b/frontend/src/components/cloud-organization-select.tsx @@ -1,5 +1,5 @@ import { faPlus, Icon } from "@rivet-gg/icons"; -import { useInfiniteQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { type ComponentProps, useCallback } from "react"; import { Avatar, @@ -13,7 +13,6 @@ import { SelectValue, Skeleton, } from "@/components"; -import { VisibilitySensor } from "@/components/visibility-sensor"; import { useCloudDataProvider } from "./actors"; interface CloudOrganizationSelectProps extends ComponentProps { @@ -27,12 +26,9 @@ export function CloudOrganizationSelect({ onValueChange, ...props }: CloudOrganizationSelectProps) { - const { - data = [], - isLoading, - hasNextPage, - fetchNextPage, - } = useInfiniteQuery(useCloudDataProvider().organizationsQueryOptions()); + const { data = [], isLoading } = useQuery( + useCloudDataProvider().organizationsQueryOptions(), + ); const handleValueChange = useCallback( (value: string) => { @@ -73,29 +69,19 @@ export function CloudOrganizationSelect({ ) : null} - {data.map((membership) => ( - + {data.map((org) => ( + - + - {membership.organization.name?.[0]?.toUpperCase()} + {org.name?.[0]?.toUpperCase()} - - {membership.organization.name} - + {org.name} ))} - {hasNextPage ? ( - - ) : null} ); diff --git a/frontend/src/components/forms/login-form.tsx b/frontend/src/components/forms/login-form.tsx new file mode 100644 index 0000000000..7c54a1150c --- /dev/null +++ b/frontend/src/components/forms/login-form.tsx @@ -0,0 +1,76 @@ +import { type UseFormReturn, useFormContext } from "react-hook-form"; +import z from "zod"; +import { createSchemaForm } from "@/components/lib/create-schema-form"; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +export const formSchema = z.object({ + email: z.string().email("Invalid email address"), + password: z.string().min(1, "Password is required"), +}); + +export type FormValues = z.infer; +export type SubmitHandler = ( + values: FormValues, + form: UseFormReturn, +) => Promise; + +const { Form, Submit } = createSchemaForm(formSchema); +export { Form, Submit }; + +export const EmailField = () => { + const { control } = useFormContext(); + return ( + ( + + Email address + + + + + + )} + /> + ); +}; + +export const PasswordField = () => { + const { control } = useFormContext(); + return ( + ( + + Password + + + + + + )} + /> + ); +}; + +export const RootError = () => { + const { formState } = useFormContext(); + if (!formState.errors.root) return null; + return ( +

+ {formState.errors.root.message} +

+ ); +}; diff --git a/frontend/src/components/forms/sign-up-form.tsx b/frontend/src/components/forms/sign-up-form.tsx new file mode 100644 index 0000000000..27b6d30a41 --- /dev/null +++ b/frontend/src/components/forms/sign-up-form.tsx @@ -0,0 +1,100 @@ +import { type UseFormReturn, useFormContext } from "react-hook-form"; +import z from "zod"; +import { createSchemaForm } from "@/components/lib/create-schema-form"; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +export const formSchema = z.object({ + name: z.string().min(1, "Name is required"), + email: z.string().email("Invalid email address"), + password: z.string().min(1, "Password is required"), +}); + +export type FormValues = z.infer; +export type SubmitHandler = ( + values: FormValues, + form: UseFormReturn, +) => Promise; + +const { Form, Submit } = createSchemaForm(formSchema); +export { Form, Submit }; + +export const NameField = () => { + const { control } = useFormContext(); + return ( + ( + + Name + + + + + + )} + /> + ); +}; + +export const EmailField = () => { + const { control } = useFormContext(); + return ( + ( + + Email address + + + + + + )} + /> + ); +}; + +export const PasswordField = () => { + const { control } = useFormContext(); + return ( + ( + + Password + + + + + + )} + /> + ); +}; + +export const RootError = () => { + const { formState } = useFormContext(); + if (!formState.errors.root) return null; + return ( +

+ {formState.errors.root.message} +

+ ); +}; diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index d612e5b640..79fa0369c2 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -1,6 +1,6 @@ -import { createAuthClient } from "better-auth/react"; -import { organizationClient } from "better-auth/client/plugins"; import { redirect } from "@tanstack/react-router"; +import { organizationClient } from "better-auth/client/plugins"; +import { createAuthClient } from "better-auth/react"; import { cloudEnv } from "./env"; const createClient = () => @@ -12,30 +12,34 @@ const createClient = () => type AuthClient = ReturnType; export const authClient: AuthClient = - __APP_TYPE__ === "cloud" - ? createClient() - : (null as unknown as AuthClient); + __APP_TYPE__ === "cloud" ? createClient() : (null as unknown as AuthClient); export const redirectToOrganization = async ( - search: Record, + { from }: { from?: string } = {}, ) => { const session = await authClient.getSession(); if (session.data) { - const orgs = await authClient.organization.list(); - if (orgs.data && orgs.data.length > 0) { - await authClient.organization.setActive({ - organizationId: orgs.data[0].id, - }); + if (session.data.session.activeOrganizationId) { throw redirect({ to: "/orgs/$organization", - search: true, - params: { organization: orgs.data[0].id }, + search: from ? { from } : undefined, + params: { organization: session.data.session.activeOrganizationId }, }); } + const orgs = await authClient.organization.list(); + + if (!orgs.data?.[0]) { + return false; + } + + await authClient.organization.setActive({ + organizationId: orgs.data[0].id, + }); throw redirect({ - to: "/onboarding/choose-organization", - search: true, + to: "/orgs/$organization", + search: from ? { from } : undefined, + params: { organization: orgs.data[0].id }, }); } diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index cbddeed8d4..7e0fccead0 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -14,7 +14,6 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as JoinRouteImport } from './routes/join' import { Route as ContextRouteImport } from './routes/_context' import { Route as ContextIndexRouteImport } from './routes/_context/index' -import { Route as OnboardingChooseOrganizationRouteImport } from './routes/onboarding/choose-organization' import { Route as ContextEngineRouteImport } from './routes/_context/_engine' import { Route as ContextCloudRouteImport } from './routes/_context/_cloud' import { Route as ContextCloudNewIndexRouteImport } from './routes/_context/_cloud/new/index' @@ -64,12 +63,6 @@ const ContextIndexRoute = ContextIndexRouteImport.update({ path: '/', getParentRoute: () => ContextRoute, } as any) -const OnboardingChooseOrganizationRoute = - OnboardingChooseOrganizationRouteImport.update({ - id: '/choose-organization', - path: '/choose-organization', - getParentRoute: () => OnboardingRoute, - } as any) const ContextEngineRoute = ContextEngineRouteImport.update({ id: '/_engine', getParentRoute: () => ContextRoute, @@ -236,8 +229,7 @@ export interface FileRoutesByFullPath { '/': typeof ContextIndexRoute '/join': typeof JoinRoute '/login': typeof LoginRoute - '/onboarding': typeof OnboardingRouteWithChildren - '/onboarding/choose-organization': typeof OnboardingChooseOrganizationRoute + '/onboarding': typeof OnboardingRoute '/orgs/$organization': typeof ContextCloudOrgsOrganizationRouteWithChildren '/ns/$namespace': typeof ContextEngineNsNamespaceRouteWithChildren '/new/': typeof ContextCloudNewIndexRoute @@ -264,9 +256,8 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/join': typeof JoinRoute '/login': typeof LoginRoute - '/onboarding': typeof OnboardingRouteWithChildren + '/onboarding': typeof OnboardingRoute '/': typeof ContextIndexRoute - '/onboarding/choose-organization': typeof OnboardingChooseOrganizationRoute '/new': typeof ContextCloudNewIndexRoute '/ns/$namespace/settings': typeof ContextEngineNsNamespaceSettingsRoute '/orgs/$organization': typeof ContextCloudOrgsOrganizationIndexRoute @@ -291,10 +282,9 @@ export interface FileRoutesById { '/_context': typeof ContextRouteWithChildren '/join': typeof JoinRoute '/login': typeof LoginRoute - '/onboarding': typeof OnboardingRouteWithChildren + '/onboarding': typeof OnboardingRoute '/_context/_cloud': typeof ContextCloudRouteWithChildren '/_context/_engine': typeof ContextEngineRouteWithChildren - '/onboarding/choose-organization': typeof OnboardingChooseOrganizationRoute '/_context/': typeof ContextIndexRoute '/_context/_cloud/orgs/$organization': typeof ContextCloudOrgsOrganizationRouteWithChildren '/_context/_engine/ns/$namespace': typeof ContextEngineNsNamespaceRouteWithChildren @@ -326,7 +316,6 @@ export interface FileRouteTypes { | '/join' | '/login' | '/onboarding' - | '/onboarding/choose-organization' | '/orgs/$organization' | '/ns/$namespace' | '/new/' @@ -355,7 +344,6 @@ export interface FileRouteTypes { | '/login' | '/onboarding' | '/' - | '/onboarding/choose-organization' | '/new' | '/ns/$namespace/settings' | '/orgs/$organization' @@ -382,7 +370,6 @@ export interface FileRouteTypes { | '/onboarding' | '/_context/_cloud' | '/_context/_engine' - | '/onboarding/choose-organization' | '/_context/' | '/_context/_cloud/orgs/$organization' | '/_context/_engine/ns/$namespace' @@ -412,7 +399,7 @@ export interface RootRouteChildren { ContextRoute: typeof ContextRouteWithChildren JoinRoute: typeof JoinRoute LoginRoute: typeof LoginRoute - OnboardingRoute: typeof OnboardingRouteWithChildren + OnboardingRoute: typeof OnboardingRoute } declare module '@tanstack/react-router' { @@ -452,13 +439,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ContextIndexRouteImport parentRoute: typeof ContextRoute } - '/onboarding/choose-organization': { - id: '/onboarding/choose-organization' - path: '/choose-organization' - fullPath: '/onboarding/choose-organization' - preLoaderRoute: typeof OnboardingChooseOrganizationRouteImport - parentRoute: typeof OnboardingRoute - } '/_context/_engine': { id: '/_context/_engine' path: '' @@ -776,23 +756,11 @@ const ContextRouteChildren: ContextRouteChildren = { const ContextRouteWithChildren = ContextRoute._addFileChildren(ContextRouteChildren) -interface OnboardingRouteChildren { - OnboardingChooseOrganizationRoute: typeof OnboardingChooseOrganizationRoute -} - -const OnboardingRouteChildren: OnboardingRouteChildren = { - OnboardingChooseOrganizationRoute: OnboardingChooseOrganizationRoute, -} - -const OnboardingRouteWithChildren = OnboardingRoute._addFileChildren( - OnboardingRouteChildren, -) - const rootRouteChildren: RootRouteChildren = { ContextRoute: ContextRouteWithChildren, JoinRoute: JoinRoute, LoginRoute: LoginRoute, - OnboardingRoute: OnboardingRouteWithChildren, + OnboardingRoute: OnboardingRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/frontend/src/routes/_context/_cloud/orgs.$organization.tsx b/frontend/src/routes/_context/_cloud/orgs.$organization.tsx index c56776b3f0..ad23ee6852 100644 --- a/frontend/src/routes/_context/_cloud/orgs.$organization.tsx +++ b/frontend/src/routes/_context/_cloud/orgs.$organization.tsx @@ -7,9 +7,15 @@ export const Route = createFileRoute("/_context/_cloud/orgs/$organization")({ beforeLoad: async ({ context, params }) => { return await match(context) .with({ __type: "cloud" }, async (context) => { - await authClient.organization.setActive({ - organizationId: params.organization, - }); + const session = await authClient.getSession(); + if ( + session.data?.session.activeOrganizationId !== + params.organization + ) { + await authClient.organization.setActive({ + organizationId: params.organization, + }); + } return { dataProvider: context.getOrCreateOrganizationContext( diff --git a/frontend/src/routes/join.tsx b/frontend/src/routes/join.tsx index 50f3b511aa..9d60c0fd03 100644 --- a/frontend/src/routes/join.tsx +++ b/frontend/src/routes/join.tsx @@ -1,14 +1,16 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; import { Logo } from "@/app/logo"; import { SignUp } from "@/app/sign-up"; -import { authClient } from "@/lib/auth"; +import { authClient, redirectToOrganization } from "@/lib/auth"; export const Route = createFileRoute("/join")({ component: RouteComponent, - beforeLoad: async () => { + beforeLoad: async ({ search }) => { const session = await authClient.getSession(); if (session.data) { - throw redirect({ to: "/", search: true }); + await redirectToOrganization({ + from: "from" in search ? (search.from as string) : undefined, + }); } }, }); @@ -16,7 +18,7 @@ export const Route = createFileRoute("/join")({ function RouteComponent() { return (
-
+

diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx index fc9ce24d37..eab1d39547 100644 --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -8,7 +8,9 @@ export const Route = createFileRoute("/login")({ beforeLoad: async ({ search }) => { const session = await authClient.getSession(); if (session.data) { - await redirectToOrganization(search as Record); + await redirectToOrganization({ + from: "from" in search ? (search.from as string) : undefined, + }); } }, }); @@ -16,7 +18,7 @@ export const Route = createFileRoute("/login")({ function RouteComponent() { return (

-
+

diff --git a/frontend/src/routes/onboarding/choose-organization.tsx b/frontend/src/routes/onboarding/choose-organization.tsx deleted file mode 100644 index 2bee14fe45..0000000000 --- a/frontend/src/routes/onboarding/choose-organization.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; -import { Content } from "@/app/layout"; -import { RouteLayout } from "@/app/route-layout"; -import { authClient } from "@/lib/auth"; - -export const Route = createFileRoute("/onboarding/choose-organization")({ - component: RouteComponent, - beforeLoad: async () => { - const session = await authClient.getSession(); - if (!session.data) { - throw redirect({ to: "/login" }); - } - - const orgs = await authClient.organization.list(); - - if (orgs.data && orgs.data.length > 0) { - await authClient.organization.setActive({ - organizationId: orgs.data[0].id, - }); - throw redirect({ - to: "/orgs/$organization", - params: { organization: orgs.data[0].id }, - search: true, - }); - } - - // No orgs — auto-create a default org - const user = session.data.user; - const name = `${user.name || user.email.split("@")[0] || "Anonymous"}'s Organization`; - const slug = `${name.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "")}-${Math.random().toString(36).substring(2, 6)}`; - - const newOrg = await authClient.organization.create({ name, slug }); - - if (newOrg.data) { - await authClient.organization.setActive({ - organizationId: newOrg.data.id, - }); - throw redirect({ - to: "/orgs/$organization", - params: { organization: newOrg.data.id }, - search: true, - }); - } - - // Fallback — should not happen - throw redirect({ to: "/login" }); - }, -}); - -function RouteComponent() { - return ( - - -

- Creating your organization... -
- - - ); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c467d84755..d4796beb26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3501,6 +3501,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + es-toolkit: + specifier: ^1.45.1 + version: 1.45.1 esast-util-from-js: specifier: ^2.0.1 version: 2.0.1 @@ -13246,6 +13249,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -29755,6 +29761,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.45.1: {} + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 From 3b2bd3b853c8018d86e7566fc9eeb0d429785bf5 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:18:07 +0200 Subject: [PATCH 02/51] feat(frontend): add feature flags module, remove __APP_TYPE__ vite injection --- frontend/src/lib/features.ts | 25 +++++++++++++++++++++++++ frontend/src/vite-env.d.ts | 6 +++++- frontend/vite.base.config.ts | 1 - frontend/vite.cloud.config.ts | 3 --- frontend/vite.engine.config.ts | 3 --- 5 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/features.ts diff --git a/frontend/src/lib/features.ts b/frontend/src/lib/features.ts new file mode 100644 index 0000000000..c4753c1e02 --- /dev/null +++ b/frontend/src/lib/features.ts @@ -0,0 +1,25 @@ +const envValue = import.meta.env.VITE_FEATURE_FLAGS as string | undefined; + +const raw = import.meta.env.DEV + ? (localStorage.getItem("FEATURE_FLAGS") ?? envValue) + : envValue; + +// null means all flags are on (env var not set = full cloud build) +const enabled = + raw === undefined + ? null + : new Set(raw.split(",").map((s) => s.trim()).filter(Boolean)); + +function isEnabled(flag: string): boolean { + return enabled === null || enabled.has(flag); +} + +export const features = { + auth: isEnabled("auth"), + billing: isEnabled("billing"), + support: isEnabled("support"), + branding: isEnabled("branding"), + datacenter: isEnabled("datacenter"), + namespaceManagement: isEnabled("namespace-management"), + multitenancy: isEnabled("multitenancy"), +} as const; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index f6aabeedf7..f6e5429dca 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1,7 +1,11 @@ /// declare const __APP_BUILD_ID__: string; -declare const __APP_TYPE__: "engine" | "inspector" | "cloud"; + +interface ImportMetaEnv { + readonly VITE_FEATURE_FLAGS?: string; +} + declare const Plain: { // This takes the same arguments as Plain.init. It will update the chat widget in-place with the new configuration. // Only top-level fields are updated, nested fields are not merged. diff --git a/frontend/vite.base.config.ts b/frontend/vite.base.config.ts index 01af10851f..26f2b53c10 100644 --- a/frontend/vite.base.config.ts +++ b/frontend/vite.base.config.ts @@ -8,7 +8,6 @@ export function baseViteConfig(): UserConfig { return { plugins: [tsconfigPaths()], define: { - __APP_TYPE__: JSON.stringify(process.env.APP_TYPE || "engine"), __APP_BUILD_ID__: JSON.stringify( `${new Date().toISOString()}@${crypto.randomUUID()}`, ), diff --git a/frontend/vite.cloud.config.ts b/frontend/vite.cloud.config.ts index a2ba6630af..132e1c6648 100644 --- a/frontend/vite.cloud.config.ts +++ b/frontend/vite.cloud.config.ts @@ -32,9 +32,6 @@ export default defineConfig((config) => { enforce: "pre", }, ], - define: { - __APP_TYPE__: JSON.stringify("cloud"), - }, server: { port: 43710, }, diff --git a/frontend/vite.engine.config.ts b/frontend/vite.engine.config.ts index ebb3ef8d0f..fedc3e5041 100644 --- a/frontend/vite.engine.config.ts +++ b/frontend/vite.engine.config.ts @@ -77,9 +77,6 @@ export default defineConfig(({ mode }) => { preview: { port: 43708, }, - define: { - __APP_TYPE__: JSON.stringify(env.APP_TYPE || "engine"), - }, build: { sourcemap: true, commonjsOptions: { From f01270983f633575583b261e2ec2c8780d71b40d Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:21:45 +0200 Subject: [PATCH 03/51] feat(frontend): migrate auth, utils, __root to feature flags --- frontend/src/lib/auth.ts | 3 ++- frontend/src/queries/utils.ts | 5 +++-- frontend/src/routes/__root.tsx | 6 ++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 79fa0369c2..76f329cc5d 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -2,6 +2,7 @@ import { redirect } from "@tanstack/react-router"; import { organizationClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; import { cloudEnv } from "./env"; +import { features } from "./features"; const createClient = () => createAuthClient({ @@ -12,7 +13,7 @@ const createClient = () => type AuthClient = ReturnType; export const authClient: AuthClient = - __APP_TYPE__ === "cloud" ? createClient() : (null as unknown as AuthClient); + features.auth ? createClient() : (null as unknown as AuthClient); export const redirectToOrganization = async ( { from }: { from?: string } = {}, diff --git a/frontend/src/queries/utils.ts b/frontend/src/queries/utils.ts index 2b77ef88da..ab6f389569 100644 --- a/frontend/src/queries/utils.ts +++ b/frontend/src/queries/utils.ts @@ -1,10 +1,11 @@ import type { Query } from "@tanstack/react-query"; +import { features } from "@/lib/features"; export const shouldRetryAllExpect403 = (failureCount: number, error: Error) => { if (error && "statusCode" in error) { if (error.statusCode === 403 || error.statusCode === 401) { - // Don't retry on auth errors, when app is not engine - return __APP_TYPE__ !== "engine"; + // Don't retry on auth errors when auth is enabled + return features.auth; } if (error.statusCode === 404) { // Don't retry on not found errors, as they are unlikely to succeed on retry diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 08032cecae..4be4d2b38d 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -4,7 +4,6 @@ import { Outlet, } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; -import { match } from "ts-pattern"; import type { CloudContext, CloudNamespaceContext, @@ -14,6 +13,7 @@ import type { ProjectContext, } from "@/app/data-providers/cache"; import { FullscreenLoading } from "@/components"; +import { features } from "@/lib/features"; function RootRoute() { return ( @@ -65,8 +65,6 @@ interface RootRouteContext { } export const Route = createRootRouteWithContext()({ - component: match(__APP_TYPE__) - .with("cloud", () => CloudRoute) - .otherwise(() => RootRoute), + component: features.auth && features.multitenancy ? CloudRoute : RootRoute, pendingComponent: FullscreenLoading, }); From 4ff9cfdf0d1577bb6fe14dd9aebfe7c7ebf19977 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:23:34 +0200 Subject: [PATCH 04/51] fix(frontend): fix inverted comment in shouldRetryAllExpect403 --- frontend/src/queries/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/queries/utils.ts b/frontend/src/queries/utils.ts index ab6f389569..47fc181686 100644 --- a/frontend/src/queries/utils.ts +++ b/frontend/src/queries/utils.ts @@ -4,7 +4,7 @@ import { features } from "@/lib/features"; export const shouldRetryAllExpect403 = (failureCount: number, error: Error) => { if (error && "statusCode" in error) { if (error.statusCode === 403 || error.statusCode === 401) { - // Don't retry on auth errors when auth is enabled + // Retry on auth errors when auth is enabled (auth system handles the redirect) return features.auth; } if (error.statusCode === 404) { From cc769561ae9722367f1d811829471d377df767e0 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:25:20 +0200 Subject: [PATCH 05/51] feat(frontend): migrate onboarding, engine-data-provider, getting-started to feature flags --- frontend/src/app/data-providers/engine-data-provider.tsx | 3 ++- frontend/src/app/getting-started.tsx | 3 ++- frontend/src/routes/onboarding.tsx | 5 +++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/data-providers/engine-data-provider.tsx b/frontend/src/app/data-providers/engine-data-provider.tsx index f692d8e690..9053eb0b0a 100644 --- a/frontend/src/app/data-providers/engine-data-provider.tsx +++ b/frontend/src/app/data-providers/engine-data-provider.tsx @@ -14,6 +14,7 @@ import { getConfig, ls } from "@/components"; import type { ActorId } from "@/components/actors"; import { engineEnv } from "@/lib/env"; import { convertStringToId } from "@/lib/utils"; +import { features } from "@/lib/features"; import { noThrow, shouldRetryAllExpect403 } from "@/queries/utils"; import { type ActorQueryOptions, @@ -22,7 +23,7 @@ import { RECORDS_PER_PAGE, } from "./default-data-provider"; -const mightRequireAuth = __APP_TYPE__ === "engine"; +const mightRequireAuth = !features.auth; export type CreateNamespace = { displayName: string; diff --git a/frontend/src/app/getting-started.tsx b/frontend/src/app/getting-started.tsx index 72625577e1..30e577675e 100644 --- a/frontend/src/app/getting-started.tsx +++ b/frontend/src/app/getting-started.tsx @@ -26,6 +26,7 @@ import { toast } from "sonner"; import { match } from "ts-pattern"; import z from "zod"; import * as ConnectServerlessForm from "@/app/forms/connect-manual-serverless-form"; +import { features } from "@/lib/features"; import { AccordionItem, AccordionTrigger, @@ -264,7 +265,7 @@ export function GettingStarted({ } await Promise.all([ - ...(__APP_TYPE__ === "cloud" + ...(features.auth ? [ queryClient.prefetchQuery( dataProvider.publishableTokenQueryOptions(), diff --git a/frontend/src/routes/onboarding.tsx b/frontend/src/routes/onboarding.tsx index 3797cd9b66..de52ced2f5 100644 --- a/frontend/src/routes/onboarding.tsx +++ b/frontend/src/routes/onboarding.tsx @@ -1,10 +1,11 @@ import { createFileRoute, notFound, Outlet, redirect } from "@tanstack/react-router"; import { authClient } from "@/lib/auth"; +import { features } from "@/lib/features"; export const Route = createFileRoute("/onboarding")({ component: RouteComponent, beforeLoad: async () => { - if (__APP_TYPE__ !== "cloud") { + if (!features.auth) { throw notFound(); } @@ -16,5 +17,5 @@ export const Route = createFileRoute("/onboarding")({ }); function RouteComponent() { - return __APP_TYPE__ === "cloud" ? : null; + return features.auth ? : null; } From 1f4ea7ab435500fb079891e24e676fa1c724837f Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:29:19 +0200 Subject: [PATCH 06/51] feat(frontend): migrate support, branding, datacenter to feature flags --- frontend/src/app/help-dropdown.tsx | 3 +- .../actors/actor-filters-context.tsx | 5 +- .../actors/dialogs/create-actor-dialog.tsx | 8 +-- frontend/src/components/onboarding/footer.tsx | 55 ++++++------------- 4 files changed, 25 insertions(+), 46 deletions(-) diff --git a/frontend/src/app/help-dropdown.tsx b/frontend/src/app/help-dropdown.tsx index 0ca73228c6..fe2236fd57 100644 --- a/frontend/src/app/help-dropdown.tsx +++ b/frontend/src/app/help-dropdown.tsx @@ -14,6 +14,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components"; +import { features } from "@/lib/features"; export const HelpDropdown = ({ children }: { children: ReactNode }) => { const navigate = useNavigate(); @@ -59,7 +60,7 @@ export const HelpDropdown = ({ children }: { children: ReactNode }) => { > Feedback - {__APP_TYPE__ === "cloud" ? ( + {features.support ? ( } onSelect={() => { diff --git a/frontend/src/components/actors/actor-filters-context.tsx b/frontend/src/components/actors/actor-filters-context.tsx index 4b181f6180..12348bebc5 100644 --- a/frontend/src/components/actors/actor-filters-context.tsx +++ b/frontend/src/components/actors/actor-filters-context.tsx @@ -3,6 +3,7 @@ import { useSearch } from "@tanstack/react-router"; import { createContext, useContext } from "react"; import { useReadLocalStorage } from "usehooks-ts"; import { ls } from "../lib/utils"; +import { features } from "@/lib/features"; import { createFiltersPicker, createFiltersRemover, @@ -28,7 +29,7 @@ export const ACTORS_FILTERS_DEFINITIONS = { operators: [FilterOp.EQUAL], excludes: ["id"], }, - ...(__APP_TYPE__ === "engine" || __APP_TYPE__ === "cloud" + ...(features.datacenter ? { showDestroyed: { type: "boolean", @@ -43,7 +44,7 @@ export const ACTORS_FILTERS_DEFINITIONS = { category: "display", ephemeral: true, }, - ...(__APP_TYPE__ === "engine" || __APP_TYPE__ === "cloud" + ...(features.datacenter ? { showDatacenter: { type: "boolean", diff --git a/frontend/src/components/actors/dialogs/create-actor-dialog.tsx b/frontend/src/components/actors/dialogs/create-actor-dialog.tsx index 8d5a8dea3c..5e9a1611a2 100644 --- a/frontend/src/components/actors/dialogs/create-actor-dialog.tsx +++ b/frontend/src/components/actors/dialogs/create-actor-dialog.tsx @@ -2,6 +2,7 @@ import { Rivet } from "@rivetkit/engine-api-full"; import { useMutation } from "@tanstack/react-query"; import { useSearch } from "@tanstack/react-router"; import type { DialogContentProps } from "@/components/hooks"; +import { features } from "@/lib/features"; import { Accordion, AccordionContent, @@ -40,8 +41,7 @@ export default function CreateActorDialog({ onClose }: ContentProps) { name: values.name, input: values.input ? JSON.parse(values.input) : undefined, key: values.key, - datacenter: - __APP_TYPE__ === "inspector" ? "" : values.datacenter, + datacenter: values.datacenter, crashPolicy: values.crashPolicy || Rivet.CrashPolicy.Sleep, runnerNameSelector: values.runnerNameSelector || "default", }); @@ -63,7 +63,7 @@ export default function CreateActorDialog({ onClose }: ContentProps) { {!name ? : null} - {["engine", "cloud"].includes(__APP_TYPE__) ? ( + {features.datacenter ? ( <> @@ -75,7 +75,7 @@ export default function CreateActorDialog({ onClose }: ContentProps) { Advanced - {["engine", "cloud"].includes(__APP_TYPE__) ? ( + {features.datacenter ? ( <> diff --git a/frontend/src/components/onboarding/footer.tsx b/frontend/src/components/onboarding/footer.tsx index 929aec0632..698b7e3ae2 100644 --- a/frontend/src/components/onboarding/footer.tsx +++ b/frontend/src/components/onboarding/footer.tsx @@ -1,7 +1,6 @@ import { faArrowUpRight, Icon } from "@rivet-gg/icons"; -import { Link } from "@tanstack/react-router"; -import { match } from "ts-pattern"; import { Button } from "../ui/button"; +import { features } from "@/lib/features"; export function OnboardingFooter() { return ( @@ -20,43 +19,21 @@ export function OnboardingFooter() { Documentation - {match(__APP_TYPE__) - .with("cloud", () => ( - - )) - .otherwise(() => ( - - ))} + {features.branding ? ( + + ) : null} - {features.branding ? ( + {features.support ? ( ) : null} - {__APP_TYPE__ === "engine" ? ( + {!features.multitenancy ? ( + +
+ + + + ); + } + + return ( + + + + Welcome! + + Create your account to get started. + + + + +
+ +
+

+ or +

+ + + + + {features.captcha && turnstileSiteKey && ( + setTurnstileToken(null)} + onError={() => setTurnstileToken(null)} + onTimeout={() => setTurnstileToken(null)} + /> + )} +
+ +
+ Continue + +
+
+ +
+
+ ); +} +``` + +- [ ] Apply the above file content to `frontend/src/app/sign-up.tsx` + +- [ ] **Commit** +```bash +git commit -m "feat(frontend): add email verification pending state to sign-up form" +``` + +--- + +### Step 2 — Create the verify-email component + +Create `frontend/src/app/verify-email.tsx`: + +```tsx +import { isRedirect, Link, useNavigate, useSearch } from "@tanstack/react-router"; +import { attemptAsync } from "es-toolkit"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { authClient, redirectToOrganization } from "@/lib/auth"; + +type Status = "loading" | "success" | "error"; + +export function VerifyEmail() { + const navigate = useNavigate(); + const token = useSearch({ + strict: false, + select: (s) => s?.token as string | undefined, + }); + const [status, setStatus] = useState("loading"); + + useEffect(() => { + if (!token) { + setStatus("error"); + return; + } + + authClient.verifyEmail({ query: { token } }).then(async (result) => { + if (result.error) { + setStatus("error"); + return; + } + + setStatus("success"); + + const [error] = await attemptAsync( + async () => await redirectToOrganization(), + ); + + if (error && isRedirect(error)) { + navigate(error.options); + } + }); + }, [token, navigate]); + + if (status === "loading") { + return ( +
+

Verifying your email…

+
+ ); + } + + if (status === "success") { + return ( +
+

Email verified! Redirecting…

+
+ ); + } + + return ( +
+ + + Link invalid or expired + + This verification link is invalid or has expired. + + + +
+ + +
+
+
+
+ ); +} +``` + +- [ ] Create `frontend/src/app/verify-email.tsx` with the above content + +--- + +### Step 3 — Create the verify-email route + +Create `frontend/src/routes/verify-email.tsx`: + +```tsx +import { createFileRoute } from "@tanstack/react-router"; +import { VerifyEmail } from "@/app/verify-email"; + +export const Route = createFileRoute("/verify-email")({ + component: VerifyEmail, +}); +``` + +- [ ] Create `frontend/src/routes/verify-email.tsx` with the above content + +- [ ] **Verify the app builds:** Run `cd frontend && pnpm tsc --noEmit` and confirm no type errors in the new files. + +- [ ] **Commit** +```bash +git commit -m "feat(frontend): add email verification landing page" +``` + +--- + +## Task 2: Reset Password Flow + +**Files:** +- Modify: `frontend/src/app/login.tsx` +- Create: `frontend/src/components/forms/forgot-password-form.tsx` +- Create: `frontend/src/routes/forgot-password.tsx` +- Create: `frontend/src/app/forgot-password.tsx` +- Create: `frontend/src/components/forms/reset-password-form.tsx` +- Create: `frontend/src/routes/reset-password.tsx` +- Create: `frontend/src/app/reset-password.tsx` + +### Step 1 — Add "Forgot password?" link to login page + +In `frontend/src/app/login.tsx`, add a link between `` and ``: + +```tsx +// After and before : +
+ + Forgot password? + +
+``` + +The `Link` import is already present in the file. The full updated `` block: + +```tsx + +
+ +
+

+ or +

+ + +
+ + Forgot password? + +
+ + {features.captcha && turnstileSiteKey && ( + setTurnstileToken(null)} + onError={() => setTurnstileToken(null)} + onTimeout={() => setTurnstileToken(null)} + /> + )} +
+``` + +- [ ] Apply the CardContent change to `frontend/src/app/login.tsx` + +- [ ] **Commit** +```bash +git commit -m "feat(frontend): add forgot password link to login page" +``` + +--- + +### Step 2 — Create forgot-password-form + +Create `frontend/src/components/forms/forgot-password-form.tsx`: + +```tsx +import { type UseFormReturn, useFormContext } from "react-hook-form"; +import z from "zod"; +import { createSchemaForm } from "@/components/lib/create-schema-form"; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +export const formSchema = z.object({ + email: z.string().email("Invalid email address"), +}); + +export type FormValues = z.infer; +export type SubmitHandler = ( + values: FormValues, + form: UseFormReturn, +) => Promise; + +const { Form, Submit } = createSchemaForm(formSchema); +export { Form, Submit }; + +export const EmailField = () => { + const { control } = useFormContext(); + return ( + ( + + Email address + + + + + + )} + /> + ); +}; + +export const RootError = () => { + const { formState } = useFormContext(); + if (!formState.errors.root) return null; + return ( +

+ {formState.errors.root.message} +

+ ); +}; +``` + +- [ ] Create `frontend/src/components/forms/forgot-password-form.tsx` with the above content + +--- + +### Step 3 — Create forgot-password component and route + +Create `frontend/src/app/forgot-password.tsx`: + +```tsx +import { Link } from "@tanstack/react-router"; +import { motion } from "framer-motion"; +import { useState } from "react"; +import { + EmailField, + Form, + RootError, + Submit, + type SubmitHandler, +} from "@/components/forms/forgot-password-form"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { authClient } from "@/lib/auth"; + +export function ForgotPassword() { + const [sent, setSent] = useState(false); + + const handleSubmit: SubmitHandler = async ({ email }, form) => { + const result = await authClient.requestPasswordReset({ + email, + redirectTo: `${window.location.origin}/reset-password`, + }); + + if (result.error) { + form.setError("root", { + message: result.error.message ?? "Failed to send reset email", + }); + return; + } + + setSent(true); + }; + + if (sent) { + return ( + + + + Check your email + + We sent a password reset link. Check your inbox and follow + the instructions. + + + + + + + + ); + } + + return ( + + + + Reset your password + + Enter your email and we'll send you a reset link. + + +
+ + + + + +
+ Send reset link + +
+
+
+
+
+ ); +} +``` + +Create `frontend/src/routes/forgot-password.tsx`: + +```tsx +import { createFileRoute } from "@tanstack/react-router"; +import { ForgotPassword } from "@/app/forgot-password"; +import { Logo } from "@/app/logo"; + +export const Route = createFileRoute("/forgot-password")({ + component: RouteComponent, +}); + +function RouteComponent() { + return ( +
+
+ + +
+
+ ); +} +``` + +- [ ] Create `frontend/src/app/forgot-password.tsx` with the above content +- [ ] Create `frontend/src/routes/forgot-password.tsx` with the above content + +- [ ] **Commit** +```bash +git commit -m "feat(frontend): add forgot password page" +``` + +--- + +### Step 4 — Create reset-password-form + +Create `frontend/src/components/forms/reset-password-form.tsx`: + +```tsx +import { type UseFormReturn, useFormContext } from "react-hook-form"; +import z from "zod"; +import { createSchemaForm } from "@/components/lib/create-schema-form"; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +export const formSchema = z + .object({ + newPassword: z.string().min(8, "Password must be at least 8 characters"), + confirmPassword: z.string().min(1, "Please confirm your password"), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); + +export type FormValues = z.infer; +export type SubmitHandler = ( + values: FormValues, + form: UseFormReturn, +) => Promise; + +const { Form, Submit } = createSchemaForm(formSchema); +export { Form, Submit }; + +export const NewPasswordField = () => { + const { control } = useFormContext(); + return ( + ( + + New password + + + + + + )} + /> + ); +}; + +export const ConfirmPasswordField = () => { + const { control } = useFormContext(); + return ( + ( + + Confirm password + + + + + + )} + /> + ); +}; + +export const RootError = () => { + const { formState } = useFormContext(); + if (!formState.errors.root) return null; + return ( +

+ {formState.errors.root.message} +

+ ); +}; +``` + +- [ ] Create `frontend/src/components/forms/reset-password-form.tsx` with the above content + +--- + +### Step 5 — Create reset-password component and route + +Create `frontend/src/app/reset-password.tsx`: + +```tsx +import { isRedirect, Link, useNavigate, useSearch } from "@tanstack/react-router"; +import { attemptAsync } from "es-toolkit"; +import { motion } from "framer-motion"; +import { + ConfirmPasswordField, + Form, + NewPasswordField, + RootError, + Submit, + type SubmitHandler, +} from "@/components/forms/reset-password-form"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { authClient, redirectToOrganization } from "@/lib/auth"; + +export function ResetPassword() { + const navigate = useNavigate(); + const token = useSearch({ + strict: false, + select: (s) => s?.token as string | undefined, + }); + + const handleSubmit: SubmitHandler = async ({ newPassword }, form) => { + if (!token) { + form.setError("root", { + message: "Missing reset token. Please use the link from your email.", + }); + return; + } + + const result = await authClient.resetPassword({ newPassword, token }); + + if (result.error) { + form.setError("root", { + message: result.error.message ?? "Failed to reset password", + }); + return; + } + + const [error] = await attemptAsync( + async () => await redirectToOrganization(), + ); + + if (error && isRedirect(error)) { + navigate(error.options); + return; + } + + navigate({ to: "/login" }); + }; + + if (!token) { + return ( + + + + Link invalid or expired + + This password reset link is invalid or has expired. + + + +
+ +
+
+
+
+ ); + } + + return ( + + + + Choose a new password + + Enter a new password for your account. + + +
+ + + + + + + + Set new password + + +
+
+
+ ); +} +``` + +Create `frontend/src/routes/reset-password.tsx`: + +```tsx +import { createFileRoute } from "@tanstack/react-router"; +import { Logo } from "@/app/logo"; +import { ResetPassword } from "@/app/reset-password"; + +export const Route = createFileRoute("/reset-password")({ + component: RouteComponent, +}); + +function RouteComponent() { + return ( +
+
+ + +
+
+ ); +} +``` + +- [ ] Create `frontend/src/app/reset-password.tsx` with the above content +- [ ] Create `frontend/src/routes/reset-password.tsx` with the above content + +- [ ] **Verify the app builds:** Run `cd frontend && pnpm tsc --noEmit` and confirm no type errors in the new files. + +- [ ] **Commit** +```bash +git commit -m "feat(frontend): add reset password flow" +``` + +--- + +## Task 3: Org Members Dialog + +**Files:** +- Create: `frontend/src/app/dialogs/org-members-frame.tsx` +- Modify: `frontend/src/app/use-dialog.tsx` +- Modify: `frontend/src/app/user-dropdown.tsx` +- Modify: `frontend/src/routes/_context.tsx` + +### Step 1 — Create org-members-frame + +Create `frontend/src/app/dialogs/org-members-frame.tsx`: + +```tsx +import { faTrash, Icon } from "@rivet-gg/icons"; +import { useState } from "react"; +import { + Avatar, + AvatarFallback, + AvatarImage, + Button, + type DialogContentProps, + Frame, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { authClient } from "@/lib/auth"; + +interface OrgMembersFrameContentProps extends DialogContentProps {} + +export default function OrgMembersFrameContent({ + onClose, +}: OrgMembersFrameContentProps) { + const { data: org, isPending } = authClient.useActiveOrganization(); + const { data: session } = authClient.useSession(); + + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteRole, setInviteRole] = useState<"member" | "admin" | "owner">( + "member", + ); + const [inviteError, setInviteError] = useState(null); + const [invitePending, setInvitePending] = useState(false); + + const handleInvite = async () => { + if (!org || !inviteEmail.trim()) return; + setInviteError(null); + setInvitePending(true); + + const result = await authClient.organization.createInvitation({ + email: inviteEmail.trim(), + role: inviteRole, + organizationId: org.id, + }); + + setInvitePending(false); + + if (result.error) { + setInviteError(result.error.message ?? "Failed to send invitation"); + return; + } + + setInviteEmail(""); + }; + + const handleRemoveMember = async (memberIdOrEmail: string) => { + if (!org) return; + await authClient.organization.removeMember({ + memberIdOrEmail, + organizationId: org.id, + }); + }; + + const handleCancelInvitation = async (invitationId: string) => { + await authClient.organization.cancelInvitation({ invitationId }); + }; + + return ( + <> + + Manage Members + + View members and invite people to your organization. + + + + {isPending ? ( +
+ + + +
+ ) : ( + <> + + + + Member + Role + + + + + {org?.members.length === 0 ? ( + + + No members yet. + + + ) : ( + org?.members.map((member) => ( + + +
+ + + + {( + ( + member as { + user?: { + name?: string; + email?: string; + }; + } + ).user?.name ?? + ( + member as { + user?: { + email?: string; + }; + } + ).user?.email ?? + "?" + )[0].toUpperCase()} + + +
+

+ { + ( + member as { + user?: { + name?: string; + }; + } + ).user?.name + } +

+

+ { + ( + member as { + user?: { + email?: string; + }; + } + ).user?.email + } +

+
+
+
+ + + {member.role} + + + + {member.userId !== + session?.user.id && ( + + )} + +
+ )) + )} +
+
+ + {(org?.invitations?.length ?? 0) > 0 && ( +
+

+ Pending invitations +

+ + + + Email + Role + + + + + {org?.invitations.map((inv) => ( + + + {inv.email} + + + + {inv.role} + + + + + + + ))} + +
+
+ )} + +
+

Invite a member

+
+
+ + + setInviteEmail(e.target.value) + } + /> +
+ + +
+ {inviteError && ( +

+ {inviteError} +

+ )} +
+ + )} +
+ + + + + ); +} +``` + +- [ ] Create `frontend/src/app/dialogs/org-members-frame.tsx` with the above content + +--- + +### Step 2 — Register OrgMembers in useDialog + +In `frontend/src/app/use-dialog.tsx`, add the `OrgMembers` entry to the exported `useDialog` object: + +```tsx +// Add this import alongside the other dialog imports: +OrgMembers: createDialogHook( + () => import("@/app/dialogs/org-members-frame"), +), +``` + +The full updated file: + +```tsx +import { useDialog as baseUseDialog, createDialogHook } from "@/components"; + +export const useDialog = { + ...baseUseDialog, + CreateNamespace: createDialogHook( + () => import("@/app/dialogs/create-namespace-frame"), + ), + CreateProject: createDialogHook( + () => import("@/app/dialogs/create-project-frame"), + ), + ConnectRivet: createDialogHook( + () => import("@/app/dialogs/connect-rivet-frame"), + ), + ConnectVercel: createDialogHook( + () => import("@/app/dialogs/connect-vercel-frame"), + ), + ConnectQuickVercel: createDialogHook( + () => import("@/app/dialogs/connect-quick-vercel-frame"), + ), + ConnectRailway: createDialogHook( + () => import("@/app/dialogs/connect-railway-frame"), + ), + ConnectQuickRailway: createDialogHook( + () => import("@/app/dialogs/connect-quick-railway-frame"), + ), + ConnectManual: createDialogHook( + () => import("@/app/dialogs/connect-manual-frame"), + ), + ConnectCloudflare: createDialogHook( + () => import("@/app/dialogs/connect-cloudflare-frame"), + ), + ConnectAws: createDialogHook( + () => import("@/app/dialogs/connect-aws-frame"), + ), + ConnectGcp: createDialogHook( + () => import("@/app/dialogs/connect-gcp-frame"), + ), + ConnectHetzner: createDialogHook( + () => import("@/app/dialogs/connect-hetzner-frame"), + ), + EditProviderConfig: createDialogHook( + () => import("@/app/dialogs/edit-runner-config"), + ), + DeleteConfig: createDialogHook( + () => import("@/app/dialogs/confirm-delete-config-frame"), + ), + DeleteNamespace: createDialogHook( + () => import("@/app/dialogs/confirm-delete-namespace-frame"), + ), + DeleteProject: createDialogHook( + () => import("@/app/dialogs/confirm-delete-project-frame"), + ), + Billing: createDialogHook(() => import("@/app/dialogs/billing-frame")), + ProvideEngineCredentials: createDialogHook( + () => import("@/app/dialogs/provide-engine-credentials-frame"), + ), + Tokens: createDialogHook(() => import("@/app/dialogs/tokens-frame")), + ApiTokens: createDialogHook(() => import("@/app/dialogs/api-tokens-frame")), + CreateApiToken: createDialogHook( + () => import("@/app/dialogs/create-api-token-frame"), + ), + CreateOrganization: createDialogHook( + () => import("@/app/dialogs/create-organization-frame"), + ), + UpsertDeployment: createDialogHook( + () => import("@/app/dialogs/upsert-deployment-frame"), + ), + OrgMembers: createDialogHook( + () => import("@/app/dialogs/org-members-frame"), + ), +}; +``` + +- [ ] Apply the above to `frontend/src/app/use-dialog.tsx` + +--- + +### Step 3 — Add modal enum value and CloudModals render in _context.tsx + +In `frontend/src/routes/_context.tsx`: + +1. Add `"org-members"` to the `modal` enum in `searchSchema`: + +```tsx +modal: z + .enum([ + "go-to-actor", + "feedback", + "create-ns", + "create-project", + "billing", + "org-members", + ]) + .or(z.string()) + .optional(), +``` + +2. Update `CloudModals` to render the OrgMembers dialog: + +```tsx +function CloudModals() { + const navigate = useNavigate(); + const search = useSearch({ strict: false }); + + const CreateProjectDialog = useDialog.CreateProject.Dialog; + const CreateOrganizationDialog = useDialog.CreateOrganization.Dialog; + const OrgMembersDialog = useDialog.OrgMembers.Dialog; + + return ( + <> + { + if (!value) { + return navigate({ + to: ".", + search: (old) => ({ ...old, modal: undefined }), + }); + } + }, + }} + /> + { + if (!value) { + return navigate({ + to: ".", + search: (old) => ({ ...old, modal: undefined }), + }); + } + }, + }} + /> + { + if (!value) { + return navigate({ + to: ".", + search: (old) => ({ ...old, modal: undefined }), + }); + } + }, + }} + /> + + ); +} +``` + +- [ ] Apply both changes to `frontend/src/routes/_context.tsx` + +--- + +### Step 4 — Add "Manage Members" to user dropdown + +In `frontend/src/app/user-dropdown.tsx`, add a "Manage Members" `DropdownMenuItem` inside the block that's already gated on `params.organization`, before the Logout item: + +```tsx +{params.organization ? ( + <> + + + Switch Organization + + + + + + + + { + navigate({ + to: ".", + search: (old) => ({ + ...old, + modal: "org-members", + }), + }); + }} + > + Manage Members + + +) : null} +``` + +- [ ] Apply the change to `frontend/src/app/user-dropdown.tsx` + +- [ ] **Verify the app builds:** Run `cd frontend && pnpm tsc --noEmit` and confirm no type errors. + +- [ ] **Commit** +```bash +git commit -m "feat(frontend): add org members management dialog" +``` + +--- + +## Task 4: Org Invitation Landing Page + +**Files:** +- Create: `frontend/src/routes/accept-invitation.tsx` +- Create: `frontend/src/app/accept-invitation.tsx` + +### Step 1 — Create accept-invitation component + +Create `frontend/src/app/accept-invitation.tsx`: + +```tsx +import { faGoogle, Icon } from "@rivet-gg/icons"; +import { isRedirect, useNavigate, useSearch } from "@tanstack/react-router"; +import { attemptAsync } from "es-toolkit"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { authClient, redirectToOrganization } from "@/lib/auth"; + +type Status = "loading" | "ready" | "accepting" | "error"; + +export function AcceptInvitation() { + const navigate = useNavigate(); + const invitationId = useSearch({ + strict: false, + select: (s) => s?.invitationId as string | undefined, + }); + const token = useSearch({ + strict: false, + select: (s) => s?.token as string | undefined, + }); + const { data: session, isPending: sessionPending } = + authClient.useSession(); + const [status, setStatus] = useState("loading"); + const [errorMessage, setErrorMessage] = useState(null); + const [orgName, setOrgName] = useState(null); + + // Resolve the invitation ID — it may come as ?invitationId= or ?token= + const resolvedInvitationId = invitationId ?? token; + + useEffect(() => { + if (sessionPending) return; + if (!resolvedInvitationId) { + setStatus("error"); + setErrorMessage("No invitation ID found in this link."); + return; + } + setStatus("ready"); + }, [sessionPending, resolvedInvitationId]); + + const handleAccept = async () => { + if (!resolvedInvitationId) return; + setStatus("accepting"); + + const result = await authClient.organization.acceptInvitation({ + invitationId: resolvedInvitationId, + }); + + if (result.error) { + setStatus("error"); + setErrorMessage( + result.error.message ?? "Failed to accept invitation", + ); + return; + } + + const [error] = await attemptAsync( + async () => await redirectToOrganization(), + ); + + if (error && isRedirect(error)) { + navigate(error.options); + } + }; + + const handleReject = async () => { + if (!resolvedInvitationId) return; + await authClient.organization.rejectInvitation({ + invitationId: resolvedInvitationId, + }); + navigate({ to: "/" }); + }; + + const handleGoogleSignIn = async () => { + const callbackURL = window.location.href; + await authClient.signIn.social({ provider: "google", callbackURL }); + }; + + if (sessionPending || status === "loading") { + return ( +
+

Loading…

+
+ ); + } + + if (status === "error") { + return ( +
+ + + Invitation unavailable + + {errorMessage ?? + "This invitation link is invalid, expired, or has already been used."} + + + + + + +
+ ); + } + + if (!session) { + return ( +
+ + + You've been invited{orgName ? ` to ${orgName}` : ""} + + Sign in or create an account to accept this invitation. + + + + + + + + +
+ ); + } + + return ( +
+ + + + You've been invited{orgName ? ` to ${orgName}` : ""} + + + Accept the invitation to join the organization. + + + + + + + +
+ ); +} +``` + +- [ ] Create `frontend/src/app/accept-invitation.tsx` with the above content + +--- + +### Step 2 — Create accept-invitation route + +Create `frontend/src/routes/accept-invitation.tsx`: + +```tsx +import { createFileRoute } from "@tanstack/react-router"; +import { AcceptInvitation } from "@/app/accept-invitation"; +import { features } from "@/lib/features"; + +export const Route = createFileRoute("/accept-invitation")({ + component: features.auth ? AcceptInvitation : () => null, +}); +``` + +- [ ] Create `frontend/src/routes/accept-invitation.tsx` with the above content + +- [ ] **Verify the app builds:** Run `cd frontend && pnpm tsc --noEmit` and confirm no type errors. + +- [ ] **Commit** +```bash +git commit -m "feat(frontend): add org invitation acceptance landing page" +``` + +--- + +## Self-Review Notes + +- All four tasks are independent and share no state. +- `authClient.requestPasswordReset` (not `forgetPassword`) is the correct method name confirmed from `better-auth@1.5.6` types. +- `authClient.organization.createInvitation` (not `inviteMember`) is confirmed from the org plugin client. +- `authClient.useActiveOrganization()` in Task 3 returns members and invitations as part of the active org — no extra API calls needed. +- The `invitationId` URL param name matches what better-auth puts in invitation email links by default (verify this against the server configuration if links use a different param name like `token`). +- Task 3's member row user data (`member.user.name`, `member.user.email`) is accessed via type assertions because better-auth's `InferMember` type doesn't always include the joined user object in its TS type — the actual runtime response includes it from `getFullOrganization`. If TypeScript errors arise here, add a local type cast or extract `user` from the member response explicitly. From 6a4fcd6332efe158b42d85e2f083c5ede84a0283 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:22:26 +0200 Subject: [PATCH 32/51] feat(frontend): add email verification flow --- frontend/src/app/sign-up.tsx | 49 ++++++++++++++++ frontend/src/app/verify-email.tsx | 86 ++++++++++++++++++++++++++++ frontend/src/routes/verify-email.tsx | 6 ++ 3 files changed, 141 insertions(+) create mode 100644 frontend/src/app/verify-email.tsx create mode 100644 frontend/src/routes/verify-email.tsx diff --git a/frontend/src/app/sign-up.tsx b/frontend/src/app/sign-up.tsx index 74eaa3b1c6..252edb3edf 100644 --- a/frontend/src/app/sign-up.tsx +++ b/frontend/src/app/sign-up.tsx @@ -37,6 +37,7 @@ export function SignUp() { const from = useSearch({ strict: false, select: (s) => s?.from as string }); const [turnstileToken, setTurnstileToken] = useState(null); const turnstileSiteKey = cloudEnv().VITE_APP_TURNSTILE_SITE_KEY; + const [sentEmail, setSentEmail] = useState(null); const handleSubmit: SubmitHandler = async ( { name, email, password }, @@ -65,6 +66,7 @@ export function SignUp() { setTurnstileToken(null); + // If email verification is not required server-side, session is available immediately. const [error] = await attemptAsync( async () => await redirectToOrganization(), ); @@ -72,6 +74,9 @@ export function SignUp() { if (error && isRedirect(error)) { return navigate(error.options); } + + // Email verification required — show check-your-inbox state. + setSentEmail(email); }; const handleGoogleSignUp = async () => { @@ -81,6 +86,50 @@ export function SignUp() { }); }; + const handleResend = async () => { + if (!sentEmail) return; + await authClient.sendVerificationEmail({ email: sentEmail }); + }; + + if (sentEmail) { + return ( + + + + Check your email + + We sent a verification link to{" "} + + {sentEmail} + + . Click it to activate your account. + + + +
+ + +
+
+
+
+ ); + } + return ( s?.token as string | undefined, + }); + const [status, setStatus] = useState("loading"); + + useEffect(() => { + if (!token) { + setStatus("error"); + return; + } + + authClient.verifyEmail({ query: { token } }).then(async (result) => { + if (result.error) { + setStatus("error"); + return; + } + + setStatus("success"); + + const [error] = await attemptAsync( + async () => await redirectToOrganization(), + ); + + if (error && isRedirect(error)) { + navigate(error.options); + } + }); + }, [token, navigate]); + + if (status === "loading") { + return ( +
+

Verifying your email…

+
+ ); + } + + if (status === "success") { + return ( +
+

Email verified! Redirecting…

+
+ ); + } + + return ( +
+ + + Link invalid or expired + + This verification link is invalid or has expired. + + + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/routes/verify-email.tsx b/frontend/src/routes/verify-email.tsx new file mode 100644 index 0000000000..4ae8789a76 --- /dev/null +++ b/frontend/src/routes/verify-email.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { VerifyEmail } from "@/app/verify-email"; + +export const Route = createFileRoute("/verify-email")({ + component: VerifyEmail, +}); From 5cedd2414ffd3017e8f798e73e9cf1a64de6c310 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:30:58 +0200 Subject: [PATCH 33/51] feat(frontend): add reset password flow --- frontend/src/app/forgot-password.tsx | 108 ++++++++++++++++ frontend/src/app/login.tsx | 8 ++ frontend/src/app/reset-password.tsx | 121 ++++++++++++++++++ .../components/forms/forgot-password-form.tsx | 51 ++++++++ .../components/forms/reset-password-form.tsx | 82 ++++++++++++ frontend/src/routes/forgot-password.tsx | 18 +++ frontend/src/routes/reset-password.tsx | 18 +++ 7 files changed, 406 insertions(+) create mode 100644 frontend/src/app/forgot-password.tsx create mode 100644 frontend/src/app/reset-password.tsx create mode 100644 frontend/src/components/forms/forgot-password-form.tsx create mode 100644 frontend/src/components/forms/reset-password-form.tsx create mode 100644 frontend/src/routes/forgot-password.tsx create mode 100644 frontend/src/routes/reset-password.tsx diff --git a/frontend/src/app/forgot-password.tsx b/frontend/src/app/forgot-password.tsx new file mode 100644 index 0000000000..7e8d20d605 --- /dev/null +++ b/frontend/src/app/forgot-password.tsx @@ -0,0 +1,108 @@ +import { Link } from "@tanstack/react-router"; +import { motion } from "framer-motion"; +import { useState } from "react"; +import { + EmailField, + Form, + RootError, + Submit, + type SubmitHandler, +} from "@/components/forms/forgot-password-form"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { authClient } from "@/lib/auth"; + +export function ForgotPassword() { + const [sent, setSent] = useState(false); + + const handleSubmit: SubmitHandler = async ({ email }, form) => { + const result = await authClient.requestPasswordReset({ + email, + redirectTo: `${window.location.origin}/reset-password`, + }); + + if (result.error) { + form.setError("root", { + message: result.error.message ?? "Failed to send reset email", + }); + return; + } + + setSent(true); + }; + + if (sent) { + return ( + + + + Check your email + + We sent a password reset link. Check your inbox and + follow the instructions. + + + + + + + + ); + } + + return ( + + + + Reset your password + + Enter your email and we'll send you a reset link. + + +
+ + + + + +
+ Send reset link + +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/login.tsx b/frontend/src/app/login.tsx index b86e3d79a9..e95478270f 100644 --- a/frontend/src/app/login.tsx +++ b/frontend/src/app/login.tsx @@ -111,6 +111,14 @@ export function Login() {

+
+ + Forgot password? + +
{features.captcha && turnstileSiteKey && ( s?.token as string | undefined, + }); + + const handleSubmit: SubmitHandler = async ({ newPassword }, form) => { + if (!token) { + form.setError("root", { + message: + "Missing reset token. Please use the link from your email.", + }); + return; + } + + const result = await authClient.resetPassword({ newPassword, token }); + + if (result.error) { + form.setError("root", { + message: result.error.message ?? "Failed to reset password", + }); + return; + } + + const [error] = await attemptAsync( + async () => await redirectToOrganization(), + ); + + if (error && isRedirect(error)) { + navigate(error.options); + return; + } + + navigate({ to: "/login" }); + }; + + if (!token) { + return ( + + + + Link invalid or expired + + This password reset link is invalid or has expired. + + + +
+ +
+
+
+
+ ); + } + + return ( + + + + Choose a new password + + Enter a new password for your account. + + +
+ + + + + + + + Set new password + + +
+
+
+ ); +} diff --git a/frontend/src/components/forms/forgot-password-form.tsx b/frontend/src/components/forms/forgot-password-form.tsx new file mode 100644 index 0000000000..82db839488 --- /dev/null +++ b/frontend/src/components/forms/forgot-password-form.tsx @@ -0,0 +1,51 @@ +import { type UseFormReturn, useFormContext } from "react-hook-form"; +import z from "zod"; +import { createSchemaForm } from "@/components/lib/create-schema-form"; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +export const formSchema = z.object({ + email: z.string().email("Invalid email address"), +}); + +export type FormValues = z.infer; +export type SubmitHandler = ( + values: FormValues, + form: UseFormReturn, +) => Promise; + +const { Form, Submit } = createSchemaForm(formSchema); +export { Form, Submit }; + +export const EmailField = () => { + const { control } = useFormContext(); + return ( + ( + + Email address + + + + + + )} + /> + ); +}; + +export const RootError = () => { + const { formState } = useFormContext(); + if (!formState.errors.root) return null; + return ( +

+ {formState.errors.root.message} +

+ ); +}; diff --git a/frontend/src/components/forms/reset-password-form.tsx b/frontend/src/components/forms/reset-password-form.tsx new file mode 100644 index 0000000000..17315931ea --- /dev/null +++ b/frontend/src/components/forms/reset-password-form.tsx @@ -0,0 +1,82 @@ +import { type UseFormReturn, useFormContext } from "react-hook-form"; +import z from "zod"; +import { createSchemaForm } from "@/components/lib/create-schema-form"; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +export const formSchema = z + .object({ + newPassword: z.string().min(8, "Password must be at least 8 characters"), + confirmPassword: z.string().min(1, "Please confirm your password"), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); + +export type FormValues = z.infer; +export type SubmitHandler = ( + values: FormValues, + form: UseFormReturn, +) => Promise; + +const { Form, Submit } = createSchemaForm(formSchema); +export { Form, Submit }; + +export const NewPasswordField = () => { + const { control } = useFormContext(); + return ( + ( + + New password + + + + + + )} + /> + ); +}; + +export const ConfirmPasswordField = () => { + const { control } = useFormContext(); + return ( + ( + + Confirm password + + + + + + )} + /> + ); +}; + +export const RootError = () => { + const { formState } = useFormContext(); + if (!formState.errors.root) return null; + return ( +

+ {formState.errors.root.message} +

+ ); +}; diff --git a/frontend/src/routes/forgot-password.tsx b/frontend/src/routes/forgot-password.tsx new file mode 100644 index 0000000000..b11cbe28ef --- /dev/null +++ b/frontend/src/routes/forgot-password.tsx @@ -0,0 +1,18 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ForgotPassword } from "@/app/forgot-password"; +import { Logo } from "@/app/logo"; + +export const Route = createFileRoute("/forgot-password")({ + component: RouteComponent, +}); + +function RouteComponent() { + return ( +
+
+ + +
+
+ ); +} diff --git a/frontend/src/routes/reset-password.tsx b/frontend/src/routes/reset-password.tsx new file mode 100644 index 0000000000..bfa82b1e6e --- /dev/null +++ b/frontend/src/routes/reset-password.tsx @@ -0,0 +1,18 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Logo } from "@/app/logo"; +import { ResetPassword } from "@/app/reset-password"; + +export const Route = createFileRoute("/reset-password")({ + component: RouteComponent, +}); + +function RouteComponent() { + return ( +
+
+ + +
+
+ ); +} From 5712a8881031aadc1c2cfb3ee4b0f179aef88c2f Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:35:52 +0200 Subject: [PATCH 34/51] feat(frontend): add org members management dialog --- .../src/app/dialogs/org-members-frame.tsx | 294 ++++++++++++++++++ frontend/src/app/use-dialog.tsx | 3 + frontend/src/app/user-dropdown.tsx | 39 ++- frontend/src/routes/_context.tsx | 15 + 4 files changed, 339 insertions(+), 12 deletions(-) create mode 100644 frontend/src/app/dialogs/org-members-frame.tsx diff --git a/frontend/src/app/dialogs/org-members-frame.tsx b/frontend/src/app/dialogs/org-members-frame.tsx new file mode 100644 index 0000000000..efc2a39e52 --- /dev/null +++ b/frontend/src/app/dialogs/org-members-frame.tsx @@ -0,0 +1,294 @@ +import { faTrash, Icon } from "@rivet-gg/icons"; +import { useState } from "react"; +import { + Avatar, + AvatarFallback, + AvatarImage, + Button, + type DialogContentProps, + Frame, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { authClient } from "@/lib/auth"; + +interface OrgMembersFrameContentProps extends DialogContentProps {} + +type Role = "member" | "admin" | "owner"; + +export default function OrgMembersFrameContent({ + onClose, +}: OrgMembersFrameContentProps) { + const { data: org, isPending } = authClient.useActiveOrganization(); + const { data: session } = authClient.useSession(); + + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteRole, setInviteRole] = useState("member"); + const [inviteError, setInviteError] = useState(null); + const [invitePending, setInvitePending] = useState(false); + + const handleInvite = async () => { + if (!org || !inviteEmail.trim()) return; + setInviteError(null); + setInvitePending(true); + + const result = await authClient.organization.inviteMember({ + email: inviteEmail.trim(), + role: inviteRole, + organizationId: org.id, + }); + + setInvitePending(false); + + if (result.error) { + setInviteError(result.error.message ?? "Failed to send invitation"); + return; + } + + setInviteEmail(""); + }; + + const handleRemoveMember = async (userId: string) => { + if (!org) return; + await authClient.organization.removeMember({ + memberIdOrEmail: userId, + organizationId: org.id, + }); + }; + + const handleCancelInvitation = async (invitationId: string) => { + await authClient.organization.cancelInvitation({ invitationId }); + }; + + return ( + <> + + Manage Members + + View members and invite people to your organization. + + + + {isPending ? ( +
+ + + +
+ ) : ( + <> + + + + Member + Role + + + + + {org?.members.length === 0 ? ( + + + No members yet. + + + ) : ( + org?.members.map((member) => { + const user = ( + member as unknown as { + user: { + id: string; + name: string; + email: string; + image?: string | null; + }; + } + ).user; + return ( + + +
+ + + + {( + user?.name ?? + user?.email ?? + "?" + )[0].toUpperCase()} + + +
+

+ {user?.name} +

+

+ {user?.email} +

+
+
+
+ + + {member.role} + + + + {member.userId !== + session?.user.id && ( + + )} + +
+ ); + }) + )} +
+
+ + {(org?.invitations?.length ?? 0) > 0 && ( +
+

+ Pending invitations +

+ + + + Email + Role + + + + + {org?.invitations.map((inv) => ( + + + {inv.email} + + + + {inv.role} + + + + + + + ))} + +
+
+ )} + +
+

Invite a member

+
+
+ + + setInviteEmail(e.target.value) + } + /> +
+ + +
+ {inviteError && ( +

+ {inviteError} +

+ )} +
+ + )} +
+ + + + + ); +} diff --git a/frontend/src/app/use-dialog.tsx b/frontend/src/app/use-dialog.tsx index ac304d8b54..952f86658b 100644 --- a/frontend/src/app/use-dialog.tsx +++ b/frontend/src/app/use-dialog.tsx @@ -65,4 +65,7 @@ export const useDialog = { UpsertDeployment: createDialogHook( () => import("@/app/dialogs/upsert-deployment-frame"), ), + OrgMembers: createDialogHook( + () => import("@/app/dialogs/org-members-frame"), + ), }; diff --git a/frontend/src/app/user-dropdown.tsx b/frontend/src/app/user-dropdown.tsx index 167628265f..ee3f5e7c84 100644 --- a/frontend/src/app/user-dropdown.tsx +++ b/frontend/src/app/user-dropdown.tsx @@ -72,18 +72,33 @@ export function UserDropdown({ children }: { children?: React.ReactNode }) { ) : null} {params.organization ? ( - - - Switch Organization - - - - - - - + <> + + + Switch Organization + + + + + + + + { + return navigate({ + to: ".", + search: (old) => ({ + ...old, + modal: "org-members", + }), + }); + }} + > + Manage Members + + ) : null} { diff --git a/frontend/src/routes/_context.tsx b/frontend/src/routes/_context.tsx index bc2b237198..a9e285abbf 100644 --- a/frontend/src/routes/_context.tsx +++ b/frontend/src/routes/_context.tsx @@ -22,6 +22,7 @@ const searchSchema = z "create-ns", "create-project", "billing", + "org-members", ]) .or(z.string()) .optional(), @@ -133,6 +134,7 @@ function CloudModals() { const CreateProjectDialog = useDialog.CreateProject.Dialog; const CreateOrganizationDialog = useDialog.CreateOrganization.Dialog; + const OrgMembersDialog = useDialog.OrgMembers.Dialog; return ( <> @@ -163,6 +165,19 @@ function CloudModals() { }, }} /> + { + if (!value) { + return navigate({ + to: ".", + search: (old) => ({ ...old, modal: undefined }), + }); + } + }, + }} + /> ); } From 4ece873f23e82dec7c2afd34e62a36c6375c928f Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:37:14 +0200 Subject: [PATCH 35/51] feat(frontend): add org invitation acceptance landing page --- frontend/src/app/accept-invitation.tsx | 193 ++++++++++++++++++++++ frontend/src/routes/accept-invitation.tsx | 7 + 2 files changed, 200 insertions(+) create mode 100644 frontend/src/app/accept-invitation.tsx create mode 100644 frontend/src/routes/accept-invitation.tsx diff --git a/frontend/src/app/accept-invitation.tsx b/frontend/src/app/accept-invitation.tsx new file mode 100644 index 0000000000..8ad0fc730d --- /dev/null +++ b/frontend/src/app/accept-invitation.tsx @@ -0,0 +1,193 @@ +import { faGoogle, Icon } from "@rivet-gg/icons"; +import { isRedirect, useNavigate, useSearch } from "@tanstack/react-router"; +import { attemptAsync } from "es-toolkit"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { authClient, redirectToOrganization } from "@/lib/auth"; + +type Status = "loading" | "ready" | "accepting" | "error"; + +export function AcceptInvitation() { + const navigate = useNavigate(); + const invitationId = useSearch({ + strict: false, + select: (s) => s?.invitationId as string | undefined, + }); + const token = useSearch({ + strict: false, + select: (s) => s?.token as string | undefined, + }); + const { data: session, isPending: sessionPending } = + authClient.useSession(); + const [status, setStatus] = useState("loading"); + const [errorMessage, setErrorMessage] = useState(null); + + // The invitation ID may arrive as ?invitationId= or ?token= depending on server config. + const resolvedInvitationId = invitationId ?? token; + + useEffect(() => { + if (sessionPending) return; + if (!resolvedInvitationId) { + setStatus("error"); + setErrorMessage("No invitation ID found in this link."); + return; + } + setStatus("ready"); + }, [sessionPending, resolvedInvitationId]); + + const handleAccept = async () => { + if (!resolvedInvitationId) return; + setStatus("accepting"); + + const result = await authClient.organization.acceptInvitation({ + invitationId: resolvedInvitationId, + }); + + if (result.error) { + setStatus("error"); + setErrorMessage( + result.error.message ?? "Failed to accept invitation", + ); + return; + } + + const [error] = await attemptAsync( + async () => await redirectToOrganization(), + ); + + if (error && isRedirect(error)) { + navigate(error.options); + } + }; + + const handleReject = async () => { + if (!resolvedInvitationId) return; + await authClient.organization.rejectInvitation({ + invitationId: resolvedInvitationId, + }); + navigate({ to: "/" }); + }; + + const handleGoogleSignIn = async () => { + await authClient.signIn.social({ + provider: "google", + callbackURL: window.location.href, + }); + }; + + if (sessionPending || status === "loading") { + return ( +
+

Loading…

+
+ ); + } + + if (status === "error") { + return ( +
+ + + Invitation unavailable + + {errorMessage ?? + "This invitation link is invalid, expired, or has already been used."} + + + + + + +
+ ); + } + + if (!session) { + return ( +
+ + + You've been invited + + Sign in or create an account to accept this + invitation. + + + + + + + + +
+ ); + } + + return ( +
+ + + You've been invited + + Accept the invitation to join the organization. + + + + + + + +
+ ); +} diff --git a/frontend/src/routes/accept-invitation.tsx b/frontend/src/routes/accept-invitation.tsx new file mode 100644 index 0000000000..27f7d7cacc --- /dev/null +++ b/frontend/src/routes/accept-invitation.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { AcceptInvitation } from "@/app/accept-invitation"; +import { features } from "@/lib/features"; + +export const Route = createFileRoute("/accept-invitation")({ + component: features.auth ? AcceptInvitation : () => null, +}); From a0549e2b157b24f63095310a78492b7a17d4ac45 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:46:31 +0200 Subject: [PATCH 36/51] feat(frontend): gate dashboard on emailVerified, add verify-email-pending page --- frontend/src/app/login.tsx | 8 +++ frontend/src/app/sign-up.tsx | 52 +----------------- frontend/src/app/verify-email-pending.tsx | 56 ++++++++++++++++++++ frontend/src/routes/_context.tsx | 4 ++ frontend/src/routes/verify-email-pending.tsx | 22 ++++++++ 5 files changed, 92 insertions(+), 50 deletions(-) create mode 100644 frontend/src/app/verify-email-pending.tsx create mode 100644 frontend/src/routes/verify-email-pending.tsx diff --git a/frontend/src/app/login.tsx b/frontend/src/app/login.tsx index e95478270f..4e16cac1c0 100644 --- a/frontend/src/app/login.tsx +++ b/frontend/src/app/login.tsx @@ -53,6 +53,14 @@ export function Login() { ); if (result.error) { + const code = result.error.code; + if ( + code === "EMAIL_NOT_VERIFIED" || + code === "VERIFY_YOUR_EMAIL" + ) { + navigate({ to: "/verify-email-pending" }); + return; + } form.setError("root", { message: result.error.message ?? "Invalid credentials", }); diff --git a/frontend/src/app/sign-up.tsx b/frontend/src/app/sign-up.tsx index 252edb3edf..ead7df7017 100644 --- a/frontend/src/app/sign-up.tsx +++ b/frontend/src/app/sign-up.tsx @@ -37,8 +37,6 @@ export function SignUp() { const from = useSearch({ strict: false, select: (s) => s?.from as string }); const [turnstileToken, setTurnstileToken] = useState(null); const turnstileSiteKey = cloudEnv().VITE_APP_TURNSTILE_SITE_KEY; - const [sentEmail, setSentEmail] = useState(null); - const handleSubmit: SubmitHandler = async ( { name, email, password }, form, @@ -66,7 +64,8 @@ export function SignUp() { setTurnstileToken(null); - // If email verification is not required server-side, session is available immediately. + // redirectToOrganization redirects into _context, which gates on emailVerified. + // Unverified users land on /verify-email-pending automatically. const [error] = await attemptAsync( async () => await redirectToOrganization(), ); @@ -74,9 +73,6 @@ export function SignUp() { if (error && isRedirect(error)) { return navigate(error.options); } - - // Email verification required — show check-your-inbox state. - setSentEmail(email); }; const handleGoogleSignUp = async () => { @@ -86,50 +82,6 @@ export function SignUp() { }); }; - const handleResend = async () => { - if (!sentEmail) return; - await authClient.sendVerificationEmail({ email: sentEmail }); - }; - - if (sentEmail) { - return ( - - - - Check your email - - We sent a verification link to{" "} - - {sentEmail} - - . Click it to activate your account. - - - -
- - -
-
-
-
- ); - } - return ( { + if (!email) return; + await authClient.sendVerificationEmail({ email }); + }; + + return ( +
+ + + Check your email + + We sent a verification link to{" "} + {email ? ( + + {email} + + ) : ( + "your email address" + )} + . Click it to activate your account. + + + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/routes/_context.tsx b/frontend/src/routes/_context.tsx index a9e285abbf..9a24f9e3b8 100644 --- a/frontend/src/routes/_context.tsx +++ b/frontend/src/routes/_context.tsx @@ -65,6 +65,10 @@ export const Route = createFileRoute("/_context")({ }), }); } + + if (!session.data.user.emailVerified) { + throw redirect({ to: "/verify-email-pending" }); + } } }, }); diff --git a/frontend/src/routes/verify-email-pending.tsx b/frontend/src/routes/verify-email-pending.tsx new file mode 100644 index 0000000000..cf82ea772d --- /dev/null +++ b/frontend/src/routes/verify-email-pending.tsx @@ -0,0 +1,22 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { VerifyEmailPending } from "@/app/verify-email-pending"; +import { authClient } from "@/lib/auth"; +import { features } from "@/lib/features"; + +export const Route = createFileRoute("/verify-email-pending")({ + component: VerifyEmailPending, + beforeLoad: async () => { + if (!features.auth) return; + + const session = await authClient.getSession(); + + if (!session.data) { + throw redirect({ to: "/login" }); + } + + // Already verified — send them to the app. + if (session.data.user.emailVerified) { + throw redirect({ to: "/" }); + } + }, +}); From d5661652dc41d4a2164e29b2ce436d7127312e18 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:48:38 +0200 Subject: [PATCH 37/51] fix(frontend): add toast feedback to resend verification email button --- frontend/src/app/verify-email-pending.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/verify-email-pending.tsx b/frontend/src/app/verify-email-pending.tsx index a2d219d819..4fd8bf082c 100644 --- a/frontend/src/app/verify-email-pending.tsx +++ b/frontend/src/app/verify-email-pending.tsx @@ -1,4 +1,6 @@ import { Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Card, @@ -12,10 +14,18 @@ import { authClient } from "@/lib/auth"; export function VerifyEmailPending() { const { data: session } = authClient.useSession(); const email = session?.user.email; + const [isPending, setIsPending] = useState(false); const handleResend = async () => { if (!email) return; - await authClient.sendVerificationEmail({ email }); + setIsPending(true); + const result = await authClient.sendVerificationEmail({ email }); + setIsPending(false); + if (result.error) { + toast.error("Failed to resend verification email. Please try again."); + } else { + toast.success("Verification email sent. Check your inbox."); + } }; return ( @@ -37,7 +47,11 @@ export function VerifyEmailPending() {
- -
-

- or -

- - -
- - Forgot password? - + +
+
+ +
+

+ or +

+ + +
+ + Forgot password? + +
+
- {features.captcha && turnstileSiteKey && ( { if (features.captcha && !turnstileToken) { form.setError("root", { - message: "Captcha verification is still loading, please try again", + message: + "Captcha verification is still loading, please try again", }); return; } const result = await authClient.signUp.email( - { email, password, name }, + { email, password, name, callbackURL: window.location.origin }, features.captcha && turnstileToken ? { headers: { "x-captcha-response": turnstileToken } } : undefined, diff --git a/frontend/src/app/verify-email-pending.tsx b/frontend/src/app/verify-email-pending.tsx index 688e38ecbb..d193e94cc7 100644 --- a/frontend/src/app/verify-email-pending.tsx +++ b/frontend/src/app/verify-email-pending.tsx @@ -1,8 +1,7 @@ +import { useMutation } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { useState } from "react"; import { toast } from "sonner"; -import { useInterval } from "@/components/hooks/use-interval"; -import { formatDuration } from "@/components/lib/formatter"; +import { useTimeout } from "usehooks-ts"; import { RelativeTime } from "@/components/relative-time"; import { Button } from "@/components/ui/button"; import { @@ -13,65 +12,67 @@ import { CardTitle, } from "@/components/ui/card"; import { authClient } from "@/lib/auth"; +import { isAuthError, isDate } from "@/lib/utils"; export function VerifyEmailPending() { const { data: session } = authClient.useSession(); const email = session?.user.email; - const [isPending, setIsPending] = useState(false); - const [retryUntil, setRetryUntil] = useState(null); - useInterval( - () => { - setRetryUntil((prev) => { - if (!prev || Date.now() >= prev.getTime()) return null; - // New object with same timestamp forces RelativeTime's useMemo to recompute. - return new Date(prev.getTime()); - }); - }, - retryUntil ? 1000 : null, - ); - - const handleResend = async () => { - if (!email) return; - setIsPending(true); - try { - let retryAfter: string | null = null; - const result = await authClient.sendVerificationEmail( - { email }, - { - onError(ctx) { - retryAfter = ctx.response.headers.get("x-retry-after"); + const { mutate, isPending, isError, error, reset } = useMutation({ + mutationFn: async () => { + if (!email) return; + let retryAfter: Date | null = null; + const result = await authClient.sendVerificationEmail({ + email, + callbackURL: window.location.origin, + fetchOptions: { + onError: async (context) => { + const { response } = context; + if (response.status === 429) { + const retryAfterHeader = + response.headers.get("X-Retry-After"); + retryAfter = retryAfterHeader + ? new Date( + Date.now() + + Number.parseInt( + retryAfterHeader, + 10, + ) * + 1000, + ) + : null; + } }, }, - ); + }); if (result.error) { - if (result.error.status === 429) { - const seconds = retryAfter - ? Number.parseInt(retryAfter, 10) - : null; - if (seconds && !Number.isNaN(seconds)) { - setRetryUntil(new Date(Date.now() + seconds * 1000)); - toast.error( - `Too many requests. Please try again in ${formatDuration(seconds * 1000, { showSeconds: true })}.`, - ); - } else { - toast.error("Too many requests. Please try again later."); - } - } else { - toast.error( - "Failed to resend verification email. Please try again.", - ); - } - } else { - toast.success("Verification email sent. Check your inbox."); + throw { ...result.error, retryAfter }; } - } finally { - setIsPending(false); - } - }; + return result.data; + }, + onSuccess: () => { + toast.success("Verification email resent"); + }, + }); + + const retryAfter = + isError && isAuthError(error) && isDate(error.retryAfter) + ? error.retryAfter + : null; + const rateLimited = isError && isAuthError(error) && error.status === 429; + + useTimeout( + () => { + reset(); + }, + retryAfter ? retryAfter.getTime() - Date.now() : null, + ); - const isRateLimited = retryUntil !== null; + const handleResend = () => { + if (!email) return; + mutate(); + }; return (
@@ -96,11 +97,11 @@ export function VerifyEmailPending() { variant="outline" onClick={handleResend} isLoading={isPending} - disabled={isRateLimited} + disabled={rateLimited} > - {isRateLimited && retryUntil ? ( + {rateLimited && retryAfter ? ( <> - Retry + Retry ) : ( "Resend email" diff --git a/frontend/src/components/relative-time.tsx b/frontend/src/components/relative-time.tsx index 34cb0121eb..4dd2577e4d 100644 --- a/frontend/src/components/relative-time.tsx +++ b/frontend/src/components/relative-time.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useMemo } from "react"; +import { forwardRef, useEffect, useMemo, useState } from "react"; interface RelativeTimeProps { time: Date; @@ -19,27 +19,83 @@ function decompose(duration: number) { return { years, days, hours, minutes, seconds, milliseconds }; } +// Shared per-tier clock. Each tier has one global interval shared by all +// subscribers, so N mounted components only create at most 4 +// intervals total rather than one per component. +type Listener = (now: number) => void; + +const tiers = [1_000, 10_000, 60_000, 60_000 * 60] as const; +type Tier = (typeof tiers)[number]; + +const subscribers = new Map>(); +const intervals = new Map>(); + +function subscribe(tier: Tier, listener: Listener) { + let set = subscribers.get(tier); + if (!set) { + set = new Set(); + subscribers.set(tier, set); + } + set.add(listener); + + if (!intervals.has(tier)) { + intervals.set( + tier, + setInterval(() => { + const now = Date.now(); + for (const l of subscribers.get(tier) ?? []) l(now); + }, tier), + ); + } + + return () => { + set.delete(listener); + if (set.size === 0) { + clearInterval(intervals.get(tier)); + intervals.delete(tier); + subscribers.delete(tier); + } + }; +} + +function getTier(duration: number): Tier { + const { days, hours, minutes } = decompose(Math.abs(duration)); + if (days > 0) return 60_000 * 60; + if (hours > 0) return 60_000; + if (minutes > 0) return 10_000; + return 1_000; +} + +function useNow(tier: Tier) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => subscribe(tier, setNow), [tier]); + return now; +} + export const RelativeTime = forwardRef( ({ time, ...props }, ref) => { + const tier = getTier(Date.now() - time.getTime()); + const now = useNow(tier); + const value = useMemo(() => { - const duration = Date.now() - time.getTime(); + const duration = now - time.getTime(); const { years, days, hours, minutes, seconds } = decompose(duration); - if (Math.abs(years) > 0) { + if (Math.trunc(years) > 0) { return relativeTimeFormat.format(-years, "years"); } - if (Math.abs(days) > 0) { + if (Math.trunc(days) > 0) { return relativeTimeFormat.format(-days, "days"); } - if (Math.abs(hours) > 0) { + if (Math.trunc(hours) > 0) { return relativeTimeFormat.format(-hours, "hours"); } - if (Math.abs(minutes) > 0) { + if (Math.trunc(minutes) > 0) { return relativeTimeFormat.format(-minutes, "minutes"); } return relativeTimeFormat.format(-seconds, "seconds"); - }, [time]); + }, [now, time]); return (

Invite a member

@@ -246,27 +238,6 @@ export default function OrgMembersFrameContent({ } />
-
-
+
{ + e.preventDefault(); + handleInvite(); + }} + >

Invite a member

@@ -239,7 +245,7 @@ export default function OrgMembersFrameContent({ />
+
)} From 2338021ec659481bfa79d7c0891dbb6dc070e030 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:48:22 +0200 Subject: [PATCH 47/51] fix(frontend): show owner tag inline, remove role column from members table --- .../src/app/dialogs/org-members-frame.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/dialogs/org-members-frame.tsx b/frontend/src/app/dialogs/org-members-frame.tsx index 420ee3d96d..033db0d372 100644 --- a/frontend/src/app/dialogs/org-members-frame.tsx +++ b/frontend/src/app/dialogs/org-members-frame.tsx @@ -87,7 +87,6 @@ export default function OrgMembersFrameContent({ Member - Role @@ -95,7 +94,7 @@ export default function OrgMembersFrameContent({ {org?.members.length === 0 ? ( No members yet. @@ -133,20 +132,22 @@ export default function OrgMembersFrameContent({
-

- {user?.name} -

+
+

+ {user?.name} +

+ {member.role === "owner" && ( + + owner + + )} +

{user?.email}

- - - {member.role} - - {member.userId !== session?.user.id && ( From c01b57636ad3921f4891df1f65ba43dc87b0d381 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:48:54 +0200 Subject: [PATCH 48/51] fix(frontend): remove role column from pending invitations table --- frontend/src/app/dialogs/org-members-frame.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/frontend/src/app/dialogs/org-members-frame.tsx b/frontend/src/app/dialogs/org-members-frame.tsx index 033db0d372..97eee0c86b 100644 --- a/frontend/src/app/dialogs/org-members-frame.tsx +++ b/frontend/src/app/dialogs/org-members-frame.tsx @@ -183,7 +183,6 @@ export default function OrgMembersFrameContent({ Email - Role @@ -193,11 +192,6 @@ export default function OrgMembersFrameContent({ {inv.email} - - - {inv.role} - - -
diff --git a/frontend/src/app/dialogs/org-members-frame.tsx b/frontend/src/app/dialogs/org-members-frame.tsx index 538de76b65..aaaa2474d1 100644 --- a/frontend/src/app/dialogs/org-members-frame.tsx +++ b/frontend/src/app/dialogs/org-members-frame.tsx @@ -1,5 +1,7 @@ -import { faTrash, Icon } from "@rivet-gg/icons"; -import { useState } from "react"; +import { faPaperPlaneTop, faTrash, Icon } from "@rivet-gg/icons"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import * as InviteMemberForm from "@/app/forms/invite-member-form"; import { Avatar, AvatarFallback, @@ -14,56 +16,131 @@ import { TableHead, TableHeader, TableRow, + WithTooltip, } from "@/components"; import { Badge } from "@/components/ui/badge"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { authClient } from "@/lib/auth"; -interface OrgMembersFrameContentProps extends DialogContentProps {} - -export default function OrgMembersFrameContent({ - onClose, -}: OrgMembersFrameContentProps) { - const { data: org, isPending } = authClient.useActiveOrganization(); - const { data: session } = authClient.useSession(); - - const [inviteEmail, setInviteEmail] = useState(""); - const [inviteError, setInviteError] = useState(null); - const [invitePending, setInvitePending] = useState(false); +function RemoveMemberButton({ + organizationId, + userId, +}: { + organizationId: string; + userId: string; +}) { + const { mutate, isPending } = useMutation({ + mutationFn: async () => { + const result = await authClient.organization.removeMember({ + memberIdOrEmail: userId, + organizationId, + }); + if (result.error) throw result.error; + }, + onSuccess: () => toast.success("Member removed."), + }); - const handleInvite = async () => { - if (!org || !inviteEmail.trim()) return; - setInviteError(null); - setInvitePending(true); + return ( + mutate()} + > + + + } + /> + ); +} - const result = await authClient.organization.inviteMember({ - email: inviteEmail.trim(), - role: "member", - organizationId: org.id, - }); +function InvitationActions({ + organizationId, + invitationId, + email, +}: { + organizationId: string; + invitationId: string; + email: string; +}) { + const { mutate: resend, isPending: isResendPending } = useMutation({ + mutationFn: async () => { + const result = await authClient.organization.inviteMember({ + email, + role: "owner", + organizationId, + resend: true, + }); + if (result.error) throw result.error; + }, + onSuccess: () => toast.success("Invitation resent."), + }); - setInvitePending(false); + const { mutate: revoke, isPending: isRevokePending } = useMutation({ + mutationFn: async () => { + const result = await authClient.organization.cancelInvitation({ + invitationId, + }); + if (result.error) throw result.error; + }, + onSuccess: () => toast.success("Invitation revoked."), + }); - if (result.error) { - setInviteError(result.error.message ?? "Failed to send invitation"); - return; - } + return ( +
+ resend()} + > + + + } + /> + revoke()} + > + + + } + /> +
+ ); +} - setInviteEmail(""); - }; +interface OrgMembersFrameContentProps extends DialogContentProps {} - const handleRemoveMember = async (userId: string) => { - if (!org) return; - await authClient.organization.removeMember({ - memberIdOrEmail: userId, - organizationId: org.id, - }); - }; +export default function OrgMembersFrameContent(_: OrgMembersFrameContentProps) { + const { data: org, isPending } = authClient.useActiveOrganization(); + const { data: session } = authClient.useSession(); - const handleCancelInvitation = async (invitationId: string) => { - await authClient.organization.cancelInvitation({ invitationId }); - }; + const { mutateAsync: inviteMember } = useMutation({ + mutationFn: async (email: string) => { + if (!org) return; + const result = await authClient.organization.inviteMember({ + email, + role: "owner", + organizationId: org.id, + }); + if (result.error) throw result.error; + }, + onSuccess: () => toast.success("Invitation sent."), + }); return ( <> @@ -82,39 +159,22 @@ export default function OrgMembersFrameContent({
) : ( <> -
- - - - Member - - - - - {org?.members.length === 0 ? ( +
+
+ - - No members yet. - + + Member + + - ) : ( - org?.members.map((member) => { - const user = ( - member as unknown as { - user: { - id: string; - name: string; - email: string; - image?: string | null; - }; - } - ).user; + + + {org?.members.map((member) => { + const user = member.user; return ( - +
- {( - user?.name ?? + {(user?.name ?? user?.email ?? - "?" - )[0].toUpperCase()} + "?")[0].toUpperCase()}
@@ -136,16 +194,17 @@ export default function OrgMembersFrameContent({

{user?.name}

- {member.userId === session?.user.id && ( - + {member.userId === + session + ?.user + .id && ( + You )} - {member.role === "owner" && ( - - owner - - )}

{user?.email} @@ -154,118 +213,111 @@ export default function OrgMembersFrameContent({

- {member.userId !== - session?.user.id && ( - - )} +
+ {member.userId !== + session?.user.id && + org && ( + + )} +
); - }) - )} -
-
- - {(org?.invitations?.length ?? 0) > 0 && ( -
-

- Pending invitations -

- - - - Email - - - - - {org?.invitations.map((inv) => ( + })} + {org?.invitations + .filter( + (inv) => inv.status === "pending", + ) + .map((inv) => ( - - {inv.email} + +
+ + + {inv.email[0].toUpperCase()} + + +
+

+ {inv.email} +

+

+ Invitation sent +

+
+
- + {org && ( + + )}
))} -
-
-
- )} + {!org?.members.length && + !org?.invitations.filter( + (inv) => inv.status === "pending", + ).length && ( + + + No members yet. + + + )} + +
-
{ - e.preventDefault(); - handleInvite(); - }} - > -

Invite a member

-
-
- - - setInviteEmail(e.target.value) - } - /> +
+

+ Invite a member +

+ { + try { + await inviteMember(email); + form.reset(); + } catch { + form.setError("root", { + message: + "Failed to send invitation.", + }); + } + }} + > +
+
+ +
+ + Invite +
- -
- {inviteError && ( -

- {inviteError} -

- )} - + + +
)} - - - ); } diff --git a/frontend/src/app/forms/invite-member-form.tsx b/frontend/src/app/forms/invite-member-form.tsx new file mode 100644 index 0000000000..35a8fcd881 --- /dev/null +++ b/frontend/src/app/forms/invite-member-form.tsx @@ -0,0 +1,55 @@ +import { type UseFormReturn, useFormContext } from "react-hook-form"; +import z from "zod"; +import { + createSchemaForm, + FormControl, + FormField, + FormItem, + FormMessage, + Input, +} from "@/components"; + +export const formSchema = z.object({ + email: z.string().email("Invalid email address"), +}); + +export type FormValues = z.infer; +export type SubmitHandler = ( + values: FormValues, + form: UseFormReturn, +) => Promise; + +const { Form, Submit } = createSchemaForm(formSchema); +export { Form, Submit }; + +export const EmailField = () => { + const { control } = useFormContext(); + return ( + ( + + + + + + + )} + /> + ); +}; + +export const RootError = () => { + const { formState } = useFormContext(); + if (!formState.errors.root) return null; + return ( +

+ {formState.errors.root.message} +

+ ); +}; diff --git a/frontend/src/app/login.tsx b/frontend/src/app/login.tsx index 473b88a5bd..560dd86f29 100644 --- a/frontend/src/app/login.tsx +++ b/frontend/src/app/login.tsx @@ -54,16 +54,16 @@ export function Login() { ); if (result.error) { - const code = result.error.code; - if (code === "EMAIL_NOT_VERIFIED" || code === "VERIFY_YOUR_EMAIL") { - return navigate({ to: "/verify-email-pending" }); - } form.setError("root", { message: result.error.message ?? "Invalid credentials", }); return; } + if (result.data?.user.emailVerified === false) { + return navigate({ to: "/verify-email-pending", search: { email } }); + } + setTurnstileToken(null); const [error] = await attemptAsync( diff --git a/frontend/src/app/reset-password.tsx b/frontend/src/app/reset-password.tsx index e3e4329157..8ed2094fbc 100644 --- a/frontend/src/app/reset-password.tsx +++ b/frontend/src/app/reset-password.tsx @@ -1,4 +1,9 @@ -import { isRedirect, Link, useNavigate, useSearch } from "@tanstack/react-router"; +import { + isRedirect, + Link, + useNavigate, + useSearch, +} from "@tanstack/react-router"; import { attemptAsync } from "es-toolkit"; import { motion } from "framer-motion"; import { toast } from "sonner"; @@ -46,7 +51,9 @@ export function ResetPassword() { return; } - toast.success("Password updated. You can now sign in."); + toast.success("Password updated. You can now sign in.", { + position: "top-center", + }); const [error] = await attemptAsync( async () => await redirectToOrganization(), diff --git a/frontend/src/app/sign-up.tsx b/frontend/src/app/sign-up.tsx index 04d65c9d3b..4d24644fc8 100644 --- a/frontend/src/app/sign-up.tsx +++ b/frontend/src/app/sign-up.tsx @@ -1,12 +1,6 @@ "use client"; import { faGoogle, Icon } from "@rivet-gg/icons"; -import { - isRedirect, - Link, - useNavigate, - useSearch, -} from "@tanstack/react-router"; -import { attemptAsync } from "es-toolkit"; +import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import { motion } from "framer-motion"; import { useState } from "react"; import { @@ -28,7 +22,7 @@ import { CardTitle, } from "@/components/ui/card"; import { TurnstileWidget } from "@/components/ui/turnstile"; -import { authClient, redirectToOrganization } from "@/lib/auth"; +import { authClient } from "@/lib/auth"; import { cloudEnv } from "@/lib/env"; import { features } from "@/lib/features"; @@ -50,7 +44,7 @@ export function SignUp() { } const result = await authClient.signUp.email( - { email, password, name, callbackURL: window.location.origin }, + { email, password, name, callbackURL: `${window.location.origin}/?emailVerified=1` }, features.captcha && turnstileToken ? { headers: { "x-captcha-response": turnstileToken } } : undefined, @@ -64,16 +58,7 @@ export function SignUp() { } setTurnstileToken(null); - - // redirectToOrganization redirects into _context, which gates on emailVerified. - // Unverified users land on /verify-email-pending automatically. - const [error] = await attemptAsync( - async () => await redirectToOrganization(), - ); - - if (error && isRedirect(error)) { - return navigate(error.options); - } + navigate({ to: "/verify-email-pending", search: { email } }); }; const handleGoogleSignUp = async () => { diff --git a/frontend/src/app/verify-email-pending.tsx b/frontend/src/app/verify-email-pending.tsx index 8f23b65e97..e2b1aa215c 100644 --- a/frontend/src/app/verify-email-pending.tsx +++ b/frontend/src/app/verify-email-pending.tsx @@ -1,5 +1,5 @@ import { useMutation } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useSearch } from "@tanstack/react-router"; import { toast } from "sonner"; import { useTimeout } from "usehooks-ts"; import { RelativeTime } from "@/components/relative-time"; @@ -16,7 +16,8 @@ import { isAuthError, isDate } from "@/lib/utils"; export function VerifyEmailPending() { const { data: session } = authClient.useSession(); - const email = session?.user.email; + const searchEmail = useSearch({ strict: false, select: (s: { email?: string }) => s?.email }); + const email = session?.user.email ?? searchEmail; const navigate = useNavigate(); const handleBackToSignIn = async () => { @@ -30,7 +31,7 @@ export function VerifyEmailPending() { let retryAfter: Date | null = null; const result = await authClient.sendVerificationEmail({ email, - callbackURL: window.location.origin, + callbackURL: `${window.location.origin}/?emailVerified=1`, fetchOptions: { onError: async (context) => { const { response } = context; @@ -58,7 +59,9 @@ export function VerifyEmailPending() { return result.data; }, onSuccess: () => { - toast.success("Verification email resent"); + toast.success("Verification email sent", { + position: "top-center", + }); }, }); diff --git a/frontend/src/app/verify-email.tsx b/frontend/src/app/verify-email.tsx deleted file mode 100644 index 574d6a820b..0000000000 --- a/frontend/src/app/verify-email.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { isRedirect, Link, useNavigate, useSearch } from "@tanstack/react-router"; -import { attemptAsync } from "es-toolkit"; -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { authClient, redirectToOrganization } from "@/lib/auth"; - -type Status = "loading" | "success" | "error"; - -export function VerifyEmail() { - const navigate = useNavigate(); - const token = useSearch({ - strict: false, - select: (s) => s?.token as string | undefined, - }); - const [status, setStatus] = useState("loading"); - - useEffect(() => { - if (!token) { - setStatus("error"); - return; - } - - authClient.verifyEmail({ query: { token } }).then(async (result) => { - if (result.error) { - setStatus("error"); - return; - } - - setStatus("success"); - - const [error] = await attemptAsync( - async () => await redirectToOrganization(), - ); - - if (error && isRedirect(error)) { - navigate(error.options); - } - }); - }, [token, navigate]); - - if (status === "loading") { - return ( -
-

Verifying your email…

-
- ); - } - - if (status === "success") { - return ( -
-

Email verified! Redirecting…

-
- ); - } - - return ( -
- - - Link invalid or expired - - This verification link is invalid or has expired. - - - -
- - -
-
-
-
- ); -} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 66d833d452..181c076a13 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -10,7 +10,6 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as VerifyEmailPendingRouteImport } from './routes/verify-email-pending' -import { Route as VerifyEmailRouteImport } from './routes/verify-email' import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as OnboardingRouteImport } from './routes/onboarding' import { Route as LoginRouteImport } from './routes/login' @@ -47,11 +46,6 @@ const VerifyEmailPendingRoute = VerifyEmailPendingRouteImport.update({ path: '/verify-email-pending', getParentRoute: () => rootRouteImport, } as any) -const VerifyEmailRoute = VerifyEmailRouteImport.update({ - id: '/verify-email', - path: '/verify-email', - getParentRoute: () => rootRouteImport, -} as any) const ResetPasswordRoute = ResetPasswordRouteImport.update({ id: '/reset-password', path: '/reset-password', @@ -238,7 +232,6 @@ export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/onboarding': typeof OnboardingRoute '/reset-password': typeof ResetPasswordRoute - '/verify-email': typeof VerifyEmailRoute '/verify-email-pending': typeof VerifyEmailPendingRoute '/ns/$namespace': typeof ContextNsNamespaceRouteWithChildren '/orgs/$organization': typeof ContextOrgsOrganizationRouteWithChildren @@ -270,7 +263,6 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/onboarding': typeof OnboardingRoute '/reset-password': typeof ResetPasswordRoute - '/verify-email': typeof VerifyEmailRoute '/verify-email-pending': typeof VerifyEmailPendingRoute '/': typeof ContextIndexRoute '/new': typeof ContextNewIndexRoute @@ -301,7 +293,6 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/onboarding': typeof OnboardingRoute '/reset-password': typeof ResetPasswordRoute - '/verify-email': typeof VerifyEmailRoute '/verify-email-pending': typeof VerifyEmailPendingRoute '/_context/': typeof ContextIndexRoute '/_context/ns/$namespace': typeof ContextNsNamespaceRouteWithChildren @@ -337,7 +328,6 @@ export interface FileRouteTypes { | '/login' | '/onboarding' | '/reset-password' - | '/verify-email' | '/verify-email-pending' | '/ns/$namespace' | '/orgs/$organization' @@ -369,7 +359,6 @@ export interface FileRouteTypes { | '/login' | '/onboarding' | '/reset-password' - | '/verify-email' | '/verify-email-pending' | '/' | '/new' @@ -399,7 +388,6 @@ export interface FileRouteTypes { | '/login' | '/onboarding' | '/reset-password' - | '/verify-email' | '/verify-email-pending' | '/_context/' | '/_context/ns/$namespace' @@ -434,7 +422,6 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRoute OnboardingRoute: typeof OnboardingRoute ResetPasswordRoute: typeof ResetPasswordRoute - VerifyEmailRoute: typeof VerifyEmailRoute VerifyEmailPendingRoute: typeof VerifyEmailPendingRoute } @@ -447,13 +434,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof VerifyEmailPendingRouteImport parentRoute: typeof rootRouteImport } - '/verify-email': { - id: '/verify-email' - path: '/verify-email' - fullPath: '/verify-email' - preLoaderRoute: typeof VerifyEmailRouteImport - parentRoute: typeof rootRouteImport - } '/reset-password': { id: '/reset-password' path: '/reset-password' @@ -790,7 +770,6 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRoute, OnboardingRoute: OnboardingRoute, ResetPasswordRoute: ResetPasswordRoute, - VerifyEmailRoute: VerifyEmailRoute, VerifyEmailPendingRoute: VerifyEmailPendingRoute, } export const routeTree = rootRouteImport diff --git a/frontend/src/routes/_context.tsx b/frontend/src/routes/_context.tsx index 9a24f9e3b8..77f37a15d1 100644 --- a/frontend/src/routes/_context.tsx +++ b/frontend/src/routes/_context.tsx @@ -69,6 +69,7 @@ export const Route = createFileRoute("/_context")({ if (!session.data.user.emailVerified) { throw redirect({ to: "/verify-email-pending" }); } + } }, }); diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx index eab1d39547..8464de51ce 100644 --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -1,11 +1,21 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { toast } from "sonner"; +import { z } from "zod"; import { Login } from "@/app/login"; import { Logo } from "@/app/logo"; import { authClient, redirectToOrganization } from "@/lib/auth"; export const Route = createFileRoute("/login")({ component: RouteComponent, + validateSearch: z.object({ emailVerified: z.coerce.number().optional() }), beforeLoad: async ({ search }) => { + if (search.emailVerified) { + toast.success("Email verified successfully. You can now sign in.", { + position: "top-center", + }); + throw redirect({ to: ".", search: { emailVerified: undefined } }); + } + const session = await authClient.getSession(); if (session.data) { await redirectToOrganization({ diff --git a/frontend/src/routes/verify-email-pending.tsx b/frontend/src/routes/verify-email-pending.tsx index cf82ea772d..e205af5b70 100644 --- a/frontend/src/routes/verify-email-pending.tsx +++ b/frontend/src/routes/verify-email-pending.tsx @@ -1,21 +1,24 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; +import { z } from "zod"; import { VerifyEmailPending } from "@/app/verify-email-pending"; import { authClient } from "@/lib/auth"; import { features } from "@/lib/features"; export const Route = createFileRoute("/verify-email-pending")({ component: VerifyEmailPending, - beforeLoad: async () => { + validateSearch: z.object({ email: z.string().optional() }), + beforeLoad: async ({ search }) => { if (!features.auth) return; const session = await authClient.getSession(); - if (!session.data) { + // No session and no email from sign-up — nothing to verify. + if (!session.data && !search.email) { throw redirect({ to: "/login" }); } // Already verified — send them to the app. - if (session.data.user.emailVerified) { + if (session.data?.user.emailVerified) { throw redirect({ to: "/" }); } }, diff --git a/frontend/src/routes/verify-email.tsx b/frontend/src/routes/verify-email.tsx deleted file mode 100644 index 4ae8789a76..0000000000 --- a/frontend/src/routes/verify-email.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { VerifyEmail } from "@/app/verify-email"; - -export const Route = createFileRoute("/verify-email")({ - component: VerifyEmail, -}); From 24ccaf2b185020617349eb10eed36ffc5169581a Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:05:22 +0200 Subject: [PATCH 51/51] feat(frontend): identify user in Sentry and PostHog after login Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/_context.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/frontend/src/routes/_context.tsx b/frontend/src/routes/_context.tsx index 77f37a15d1..c2f137b8eb 100644 --- a/frontend/src/routes/_context.tsx +++ b/frontend/src/routes/_context.tsx @@ -1,3 +1,4 @@ +import * as Sentry from "@sentry/react"; import { createFileRoute, Outlet, @@ -6,6 +7,8 @@ import { useSearch, } from "@tanstack/react-router"; import { zodValidator } from "@tanstack/zod-adapter"; +import posthog from "posthog-js"; +import { useEffect } from "react"; import z from "zod"; import { getConfig, ls } from "@/components"; import { useDialog } from "@/app/use-dialog"; @@ -74,9 +77,24 @@ export const Route = createFileRoute("/_context")({ }, }); +function IdentifyUser() { + const { data: session } = authClient.useSession(); + + useEffect(() => { + const user = session?.user; + if (!user) return; + + Sentry.setUser({ id: user.id, email: user.email }); + posthog.setPersonProperties({ id: user.id, email: user.email }); + }, [session?.user]); + + return null; +} + function RouteComponent() { return ( <> + {features.auth && }