diff --git a/.agent/specs/2026-04-08-better-auth-missing-flows-design.md b/.agent/specs/2026-04-08-better-auth-missing-flows-design.md new file mode 100644 index 0000000000..d5d5d21bc4 --- /dev/null +++ b/.agent/specs/2026-04-08-better-auth-missing-flows-design.md @@ -0,0 +1,162 @@ +# Better-Auth Missing Flows — Design Spec + +**Date:** 2026-04-08 + +## Overview + +After migrating from Clerk to better-auth, four auth/org flows are missing. This spec covers all four as independent, parallelizable implementation tasks. + +## Shared Patterns + +All new UI follows existing conventions: +- Routes in `frontend/src/routes/` +- Page components in `frontend/src/app/` +- Dialogs as lazy-loaded frames in `frontend/src/app/dialogs/`, registered in `frontend/src/app/use-dialog.tsx` +- Forms in `frontend/src/components/forms/` using controlled `Form` wrappers with `RootError` +- `authClient` from `frontend/src/lib/auth.ts` (better-auth React client with `organizationClient` plugin) +- Cards styled with `Card`, `CardHeader`, `CardContent`, `CardFooter` from `@/components/ui/card` +- Auth routes guard with `features.auth` check and redirect to `/login` if unauthenticated + +--- + +## Task 1: Email Verification Flow + +### Scope +- Post-sign-up success state in `SignUp` component +- New route `/verify-email` for the email link landing page + +### Sign-up success state +After `authClient.signUp.email()` succeeds, replace the form content inline with a "Check your inbox" message inside the same `Card`. Add a `verified` state boolean to `SignUp`. When `true`, swap `
` children for a static message: + +``` +Title: "Check your email" +Body: "We sent a verification link to . Click it to activate your account." +Footer: "Resend email" (calls authClient.sendVerificationEmail) + "Back to sign in" link +``` + +OAuth sign-up (`handleGoogleSignUp`) skips this — redirect immediately as before. + +### `/verify-email` route +File: `frontend/src/routes/verify-email.tsx` +Component file: `frontend/src/app/verify-email.tsx` + +- Reads `?token=` from search params +- On mount calls `authClient.verifyEmail({ query: { token } })` +- Three UI states: + - Loading: spinner + - Success: "Email verified! Redirecting…" then `redirectToOrganization()` + - Error: "This link is invalid or has expired." + "Request a new link" button (calls `authClient.sendVerificationEmail` if session exists, else links to `/join`) +- No auth required to load this route (link arrives in email before login) + +--- + +## Task 2: Reset Password Flow + +### Scope +- "Forgot password?" link on the login page +- New route `/forgot-password` — email input form +- New route `/reset-password` — new password form (token from URL) + +### Login page change +Add a small "Forgot password?" link below the `` in `login.tsx`, linking to `/forgot-password`. + +### `/forgot-password` route +File: `frontend/src/routes/forgot-password.tsx` +Component: `frontend/src/app/forgot-password.tsx` +Form: `frontend/src/components/forms/forgot-password-form.tsx` + +Card layout matching `/login`: +``` +Title: "Reset your password" +Body: EmailField + RootError +Footer: Submit "Send reset link" + "Back to sign in" link +``` + +On submit: `authClient.forgetPassword({ email, redirectTo: window.location.origin + "/reset-password" })` + +After success, swap form content inline for: +``` +"Reset link sent. Check your inbox." +``` + +### `/reset-password` route +File: `frontend/src/routes/reset-password.tsx` +Component: `frontend/src/app/reset-password.tsx` +Form: `frontend/src/components/forms/reset-password-form.tsx` + +Reads `?token=` from search params. Card layout: +``` +Title: "Choose a new password" +Body: PasswordField (label "New password") + PasswordField (label "Confirm password") + RootError +Footer: Submit "Set new password" +``` + +On submit: validates passwords match client-side, calls `authClient.resetPassword({ newPassword, token })`. + +On success: redirect to `/login` with a success toast/message. +On error (expired token): show error with "Request a new link" → `/forgot-password`. + +--- + +## Task 3: Org Members Dialog + +### Scope +- New dialog frame `org-members-frame.tsx` +- "Manage Members" entry in user dropdown +- Registered in `useDialog` + +### Dialog frame +File: `frontend/src/app/dialogs/org-members-frame.tsx` + +Three sections: + +**Members list** — calls `authClient.organization.getMembers({ query: { organizationId } })`. Each row: avatar, name/email, role badge, "Remove" button (calls `authClient.organization.removeMember`). Current user's row has no Remove button. + +**Invite member form** — inline form below the list: +``` +EmailField + role select (owner | admin | member, default member) + "Send Invite" button +``` +Calls `authClient.organization.inviteMember({ email, role, organizationId })`. + +**Pending invitations** — calls `authClient.organization.listInvitations({ query: { organizationId } })`. Each row: email, role, "Revoke" button (calls `authClient.organization.cancelInvitation`). Hidden section if empty. + +### User dropdown change +In `frontend/src/app/user-dropdown.tsx`, add a "Manage Members" `DropdownMenuItem` above "Logout" (only when `params.organization` exists). Opens dialog via `navigate` with `modal: "org-members"` search param, following the same pattern as other modals. + +### `useDialog` registration +Add `OrgMembers: createDialogHook(() => import("@/app/dialogs/org-members-frame"))` to `frontend/src/app/use-dialog.tsx`. + +Add `"org-members"` to the modal enum in `frontend/src/routes/_context.tsx` and render `` in `CloudModals`. + +--- + +## Task 4: Org Invitation Landing Page + +### Scope +- New route `/accept-invitation` — landing page for invited users + +### Route +File: `frontend/src/routes/accept-invitation.tsx` +Component: `frontend/src/app/accept-invitation.tsx` + +Reads `?invitationId=` from search params (better-auth puts this in the link). + +**Auth-aware rendering:** +- If user is not logged in: show a card with the org name (fetched from the invitation details if the API allows unauthenticated lookup, otherwise just a generic message), with "Sign in to accept" and "Create account to accept" buttons. After auth, the user returns to this URL (pass `callbackURL` to social sign-in; redirect `from` for email sign-in). +- If user is logged in: show "You've been invited to join [Org Name]" with "Accept" and "Decline" buttons. + +On accept: `authClient.organization.acceptInvitation({ invitationId })` → redirect to org. +On decline: `authClient.organization.rejectInvitation({ invitationId })` → redirect to `/`. +On error (expired/invalid): show error message with link to contact org admin. + +No `beforeLoad` auth guard — the page must render for unauthenticated users. + +--- + +## Implementation Notes + +- Each task is fully independent and can be assigned to a separate agent. +- All new routes should have `features.auth` guards where appropriate (Tasks 1, 2 public; Tasks 3, 4 see spec above). +- Use `authClient.useSession()` in React components for reactive session state. +- No new dependencies required — better-auth's `organizationClient` plugin already includes all needed methods. +- Invitation list/revoke methods: confirm exact API names against `better-auth@1.5.6` docs before implementing (`authClient.organization.listInvitations`, `authClient.organization.cancelInvitation`). diff --git a/.agent/specs/2026-04-08-better-auth-missing-flows-plan.md b/.agent/specs/2026-04-08-better-auth-missing-flows-plan.md new file mode 100644 index 0000000000..d862bd6b7e --- /dev/null +++ b/.agent/specs/2026-04-08-better-auth-missing-flows-plan.md @@ -0,0 +1,1723 @@ +# Better-Auth Missing Flows Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add four missing auth/org flows to the frontend after migrating from Clerk to better-auth: email verification, password reset, org members management, and org invitation acceptance. + +**Architecture:** Four fully independent tasks — each adds new routes and/or UI components following existing patterns (TanStack Router file-based routing, `Card`-based auth pages, lazy-loaded dialog frames). All API calls go through the existing `authClient` from `frontend/src/lib/auth.ts`. + +**Tech Stack:** React, TanStack Router, better-auth 1.5.6 (organizationClient plugin), react-hook-form + zod via `createSchemaForm`, Tailwind/shadcn UI components. + +**Each task is fully independent and can be assigned to a separate agent.** + +--- + +## Confirmed better-auth API method names (v1.5.6) + +- `authClient.sendVerificationEmail({ email, callbackURL? })` — send/resend verification email +- `authClient.verifyEmail({ query: { token } })` — verify with token from link +- `authClient.requestPasswordReset({ email, redirectTo? })` — send reset email +- `authClient.resetPassword({ newPassword, token? })` — set new password (token in body) +- `authClient.organization.listMembers({ query: { organizationId } })` — list org members +- `authClient.organization.createInvitation({ email, role, organizationId })` — invite by email +- `authClient.organization.listInvitations({ query: { organizationId } })` — list pending invitations +- `authClient.organization.cancelInvitation({ invitationId })` — cancel a pending invitation +- `authClient.organization.removeMember({ memberIdOrEmail, organizationId })` — remove member +- `authClient.organization.acceptInvitation({ invitationId })` — accept invitation +- `authClient.organization.rejectInvitation({ invitationId })` — reject invitation +- `authClient.useActiveOrganization()` — reactive hook returning `{ data: { id, name, slug, members, invitations } }` + +--- + +## File Map + +### Task 1 — Email Verification +- Modify: `frontend/src/app/sign-up.tsx` — add inline "check your email" success state +- Create: `frontend/src/routes/verify-email.tsx` — route definition +- Create: `frontend/src/app/verify-email.tsx` — verification landing page component + +### Task 2 — Reset Password +- Modify: `frontend/src/app/login.tsx` — add "Forgot password?" link +- Create: `frontend/src/components/forms/forgot-password-form.tsx` — email form +- Create: `frontend/src/routes/forgot-password.tsx` — route definition +- Create: `frontend/src/app/forgot-password.tsx` — forgot password page component +- Create: `frontend/src/components/forms/reset-password-form.tsx` — new password form +- Create: `frontend/src/routes/reset-password.tsx` — route definition +- Create: `frontend/src/app/reset-password.tsx` — reset password page component + +### Task 3 — Org Members Dialog +- Create: `frontend/src/app/dialogs/org-members-frame.tsx` — dialog with member list + invite form +- Modify: `frontend/src/app/use-dialog.tsx` — register OrgMembers dialog hook +- Modify: `frontend/src/app/user-dropdown.tsx` — add "Manage Members" menu item +- Modify: `frontend/src/routes/_context.tsx` — add `"org-members"` to modal enum, render dialog in CloudModals + +### Task 4 — Org Invitation Landing Page +- Create: `frontend/src/routes/accept-invitation.tsx` — route definition +- Create: `frontend/src/app/accept-invitation.tsx` — invitation acceptance page component + +--- + +## Task 1: Email Verification Flow + +**Files:** +- Modify: `frontend/src/app/sign-up.tsx` +- Create: `frontend/src/routes/verify-email.tsx` +- Create: `frontend/src/app/verify-email.tsx` + +### Step 1 — Add "check your email" state to sign-up + +Replace `frontend/src/app/sign-up.tsx` with the version below. The key change: after `signUp.email()` succeeds, set `sentEmail` state and render a success card instead of the form. + +```tsx +"use client"; +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 { 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, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { TurnstileWidget } from "@/components/ui/turnstile"; +import { authClient, redirectToOrganization } from "@/lib/auth"; +import { cloudEnv } from "@/lib/env"; +import { features } from "@/lib/features"; + +export function SignUp() { + const navigate = useNavigate(); + 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, + ) => { + if (features.captcha && !turnstileToken) { + form.setError("root", { + message: "Captcha verification is still loading, please try again", + }); + return; + } + + const result = await authClient.signUp.email( + { email, password, name }, + features.captcha && turnstileToken + ? { headers: { "x-captcha-response": turnstileToken } } + : undefined, + ); + + if (result.error) { + form.setError("root", { + message: result.error.message ?? "Sign up failed", + }); + return; + } + + setTurnstileToken(null); + + // If already has a session (e.g. email verification not required server-side), redirect. + const [error] = await attemptAsync( + async () => await redirectToOrganization(), + ); + + if (error && isRedirect(error)) { + return navigate(error.options); + } + + // Email verification required — show the check-your-inbox state. + setSentEmail(email); + }; + + const handleGoogleSignUp = async () => { + await authClient.signIn.social({ + provider: "google", + callbackURL: from ?? "/", + }); + }; + + 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 ( + + + + 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. diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 715fda7761..953e25bee5 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -108,77 +108,4 @@ jobs: # - run: pnpm build # - run: pnpm check-types # working-directory: ./website - dashboard-e2e-cloud: - name: Dashboard / E2E Tests / Cloud (${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shardIndex: [1] - shardTotal: [1] - steps: - - uses: actions/checkout@v4 - with: - lfs: 'true' - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - uses: actions/cache@v4 - with: - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-turbo- - - uses: actions/cache@v4 - id: playwright-cache - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package.json') }} - restore-keys: | - ${{ runner.os }}-playwright- - - run: pnpm install --frozen-lockfile - - name: Install Playwright browsers - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: pnpm exec playwright install --with-deps chromium - working-directory: ./frontend - - name: Install Playwright system deps - if: steps.playwright-cache.outputs.cache-hit == 'true' - run: pnpm exec playwright install-deps chromium - working-directory: ./frontend - - name: Build frontend workspace dependencies - run: pnpm build --filter='./frontend' - env: - NODE_OPTIONS: '--max-old-space-size=8192' - - name: Build cloud dashboard - run: pnpm build:cloud - working-directory: ./frontend - env: - NODE_OPTIONS: '--max-old-space-size=8192' - VITE_APP_CLERK_PUBLISHABLE_KEY: ${{ secrets.E2E_CLERK_PUBLISHABLE_KEY }} - VITE_APP_CLOUD_API_URL: ${{ secrets.E2E_CLOUD_API_URL }} - VITE_APP_API_URL: https://api.staging.rivet.dev - - name: Run cloud E2E tests - run: pnpm exec playwright test --project=cloud --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=list,github - working-directory: ./frontend - env: - CLERK_SECRET_KEY: ${{ secrets.E2E_CLERK_SECRET_KEY }} - CLERK_PUBLISHABLE_KEY: ${{ secrets.E2E_CLERK_PUBLISHABLE_KEY }} - E2E_CLERK_USER_EMAIL: ${{ secrets.E2E_CLERK_USER_EMAIL }} - E2E_CLERK_USER_PASSWORD: ${{ secrets.E2E_CLERK_USER_PASSWORD }} - - name: Upload HTML report - uses: actions/upload-artifact@v4 - if: always() - with: - name: cloud-e2e-report-${{ matrix.shardIndex }} - path: frontend/playwright-report/ - retention-days: 7 - - name: Upload traces - uses: actions/upload-artifact@v4 - if: always() - with: - name: cloud-e2e-traces-${{ matrix.shardIndex }} - path: frontend/test-results/ - retention-days: 7 diff --git a/frontend/.env b/frontend/.env index d9709783be..a28623dee4 100644 --- a/frontend/.env +++ b/frontend/.env @@ -12,3 +12,6 @@ DEPLOYMENT_TYPE=staging # Overridden in CI SENTRY_AUTH_TOKEN= + +# Cloudflare Turnstile site key (required when captcha feature flag is enabled) +# VITE_APP_TURNSTILE_SITE_KEY= diff --git a/frontend/cloud.Dockerfile b/frontend/cloud.Dockerfile index 6eaf4bd332..87a5e570a0 100644 --- a/frontend/cloud.Dockerfile +++ b/frontend/cloud.Dockerfile @@ -48,7 +48,6 @@ RUN --mount=type=cache,id=s/47975eb7-74fd-4043-a505-62b995ff5718-pnpm-store,targ ARG VITE_APP_API_URL="https://VITE_APP_API_URL.placeholder.rivet.dev" ARG VITE_APP_CLOUD_API_URL="https://VITE_APP_CLOUD_API_URL.placeholder.rivet.dev" ARG VITE_APP_ASSETS_URL="https://VITE_APP_ASSETS_URL.placeholder.rivet.dev" -ARG VITE_APP_CLERK_PUBLISHABLE_KEY="pk_placeholder_clerk_key" ARG VITE_APP_SENTRY_DSN="https://VITE_APP_SENTRY_DSN.placeholder.rivet.dev/0" ARG VITE_APP_SENTRY_PROJECT_ID="0" ARG VITE_APP_POSTHOG_API_KEY="" @@ -58,7 +57,6 @@ ARG DEPLOYMENT_TYPE="staging" ENV VITE_APP_API_URL=${VITE_APP_API_URL} ENV VITE_APP_CLOUD_API_URL=${VITE_APP_CLOUD_API_URL} ENV VITE_APP_ASSETS_URL=${VITE_APP_ASSETS_URL} -ENV VITE_APP_CLERK_PUBLISHABLE_KEY=${VITE_APP_CLERK_PUBLISHABLE_KEY} ENV VITE_APP_SENTRY_DSN=${VITE_APP_SENTRY_DSN} ENV VITE_APP_SENTRY_PROJECT_ID=${VITE_APP_SENTRY_PROJECT_ID} ENV VITE_APP_POSTHOG_API_KEY=${VITE_APP_POSTHOG_API_KEY} diff --git a/frontend/docker-entrypoint.sh b/frontend/docker-entrypoint.sh index 8418c33fb7..078b4ca0b6 100644 --- a/frontend/docker-entrypoint.sh +++ b/frontend/docker-entrypoint.sh @@ -26,7 +26,6 @@ replace_env_var() { replace_env_var "https://VITE_APP_API_URL.placeholder.rivet.dev" "VITE_APP_API_URL" replace_env_var "https://VITE_APP_CLOUD_API_URL.placeholder.rivet.dev" "VITE_APP_CLOUD_API_URL" replace_env_var "https://VITE_APP_ASSETS_URL.placeholder.rivet.dev" "VITE_APP_ASSETS_URL" -replace_env_var "pk_placeholder_clerk_key" "VITE_APP_CLERK_PUBLISHABLE_KEY" replace_env_var "https://VITE_APP_SENTRY_DSN.placeholder.rivet.dev/0" "VITE_APP_SENTRY_DSN" echo "Environment variable substitution complete" diff --git a/frontend/docs/testing/CLOUD-ONBOARDING.md b/frontend/docs/testing/CLOUD-ONBOARDING.md index b13b244d41..0542c586d9 100644 --- a/frontend/docs/testing/CLOUD-ONBOARDING.md +++ b/frontend/docs/testing/CLOUD-ONBOARDING.md @@ -13,7 +13,7 @@ This document outlines critical testing scenarios for the Rivet Cloud onboarding **Development URL**: http://localhost:5173 (or configured port) **Production URL**: https://rivet.gg -**Authentication**: Clerk +**Authentication**: Better Auth --- @@ -36,7 +36,7 @@ This document outlines critical testing scenarios for the Rivet Cloud onboarding **Scenario**: User logs in and system determines where to route them based on account state -**Prerequisites**: User has successfully authenticated via Clerk +**Prerequisites**: User has successfully authenticated **Routing Logic** (evaluated in order): diff --git a/frontend/docs/testing/references/onboarding-states.md b/frontend/docs/testing/references/onboarding-states.md index 537d1cd6f4..bf1f2a2a3e 100644 --- a/frontend/docs/testing/references/onboarding-states.md +++ b/frontend/docs/testing/references/onboarding-states.md @@ -6,7 +6,7 @@ This document defines all possible states during the Rivet Cloud onboarding flow ### 1. Anonymous (Unauthenticated) -**Description**: User is not logged in via Clerk +**Description**: User is not logged in **User is shown**: - Authentication page diff --git a/frontend/e2e/auth.setup.ts b/frontend/e2e/auth.setup.ts deleted file mode 100644 index f3ea37806f..0000000000 --- a/frontend/e2e/auth.setup.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { expect, test as setup } from "@playwright/test"; - -const authFile = ".auth/cloud/user.json"; - -setup("authenticate", async ({ page, request }) => { - // Get credentials from environment - const email = process.env.E2E_USER_EMAIL; - const password = process.env.E2E_USER_PASSWORD; - - if (!email || !password) { - throw new Error( - "E2E_USER_EMAIL and E2E_USER_PASSWORD must be set in .env.local", - ); - } - - // Sign in via Better Auth API endpoint - const baseURL = "http://localhost:43710"; - const response = await request.post( - `${baseURL}/api/auth/sign-in/email`, - { - data: { email, password }, - headers: { "Content-Type": "application/json" }, - }, - ); - - expect(response.ok()).toBeTruthy(); - - // Navigate to trigger cookie storage in browser context - await page.goto("/"); - - // Wait for redirect away from login (session cookies are set) - await expect(page).not.toHaveURL(/login/); - - // Save authentication state (cookies + storage) - await page.context().storageState({ path: authFile }); -}); diff --git a/frontend/e2e/cloud/fixtures/index.ts b/frontend/e2e/cloud/fixtures/index.ts deleted file mode 100644 index fccce2f5d0..0000000000 --- a/frontend/e2e/cloud/fixtures/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { test as base, type Page } from "@playwright/test"; -import { OnboardingIntegrationPage } from "./onboarding-integration-page"; -import { OnboardingPage } from "./onboarding-page"; - -type Fixtures = { - authenticated: Page; - onboardingPage: OnboardingPage; - onboardingIntegrationPage: OnboardingIntegrationPage; -}; - -export const test = base.extend({ - authenticated: async ({ page }, use) => { - // Auth state is loaded from .auth/cloud/user.json via Playwright config - await use(page); - }, - onboardingPage: async ({ authenticated }, use) => { - const page = new OnboardingPage(authenticated); - await page.navigateToNewProject(); - await use(page); - }, - onboardingIntegrationPage: async ({ authenticated }, use) => { - await use(new OnboardingIntegrationPage(authenticated)); - }, -}); diff --git a/frontend/e2e/cloud/fixtures/onboarding-integration-page.ts b/frontend/e2e/cloud/fixtures/onboarding-integration-page.ts deleted file mode 100644 index 789920c16d..0000000000 --- a/frontend/e2e/cloud/fixtures/onboarding-integration-page.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { expect, type Page } from "@playwright/test"; -import { TEST_IDS } from "../../../src/utils/test-ids"; - -export class OnboardingIntegrationPage { - constructor(private page: Page) {} - - async waitForProviderStep() { - await expect( - this.page.getByTestId( - TEST_IDS.Onboarding.IntegrationProviderSelection, - ), - ).toBeVisible(); - } - - async selectProvider(providerName: string) { - const providerOption = this.page.getByTestId( - TEST_IDS.Onboarding.IntegrationProviderOption(providerName), - ); - await providerOption.click(); - } - - async fillEndpoint(url: string) { - await this.page.getByLabel(/endpoint/i).fill(url); - } - - async selectFirstDatacenter() { - // Open the datacenter combobox and pick the first available option - await this.page.getByRole("combobox").click(); - await this.page.getByRole("option").first().click(); - } - - async assertConnectionSuccess() { - await expect( - this.page.getByText(/is running with RivetKit/i), - ).toBeVisible({ timeout: 10_000 }); - } - - async assertConnectionFailure() { - await expect( - this.page.getByText(/Health check failed, verify/i), - ).toBeVisible({ timeout: 10_000 }); - } - - async assertConnectionPending() { - await expect( - this.page.getByText(/Waiting for Runner to connect/i), - ).toBeVisible({ timeout: 10_000 }); - } - - async submitBackendStep() { - // The stepper Next button is an icon-only submit button. - // Wait for it to be enabled (form becomes valid after successful connection check). - const submitButton = this.page.locator( - '[data-component="stepper"] button[type="submit"]', - ); - await submitButton.waitFor({ state: "visible" }); - await expect(submitButton).toBeEnabled({ timeout: 10_000 }); - await submitButton.click(); - } - - async waitForVerificationStep() { - await expect( - this.page.getByTestId(TEST_IDS.Onboarding.VerificationStep), - ).toBeVisible(); - } -} diff --git a/frontend/e2e/cloud/fixtures/onboarding-page.ts b/frontend/e2e/cloud/fixtures/onboarding-page.ts deleted file mode 100644 index b6dfa35b9c..0000000000 --- a/frontend/e2e/cloud/fixtures/onboarding-page.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { expect, type Page } from "@playwright/test"; -import { TEST_IDS } from "../../../src/utils/test-ids"; - -export class OnboardingPage { - constructor(private page: Page) {} - - async navigateToNewProject() { - // Mock runner configs + runners to return empty so the wizard always - // shows for a fresh project regardless of the test account's state. - await this.page.route(/\/runner-configs(\?|$)/, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ runnerConfigs: {}, cursor: null }), - }), - ); - await this.page.route(/\/runners\/names(\?|$)/, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ names: [], cursor: null }), - }), - ); - await this.page.route(/\/actors\/names(\?|$)/, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ names: {}, cursor: null }), - }), - ); - await this.page.goto("/new"); - // /new redirects to /orgs/$orgId/new — wait for create project card - await expect( - this.page.getByTestId(TEST_IDS.Onboarding.CreateProjectCard), - ).toBeVisible({ timeout: 10_000 }); - } - - async createProject(name: string) { - const createProjectCard = this.page.getByTestId( - TEST_IDS.Onboarding.CreateProjectCard, - ); - await expect(createProjectCard).toBeVisible(); - - await createProjectCard.getByLabel(/name/i).fill(name); - await createProjectCard - .getByRole("button", { name: /create/i }) - .click(); - } - - async waitForWizardMount() { - await expect( - this.page.getByTestId(TEST_IDS.Onboarding.GettingStartedWizard), - ).toBeVisible({ timeout: 15_000 }); - } - - async assertActiveStep(stepTitle: string) { - await expect( - this.page.getByRole("heading", { name: stepTitle }), - ).toBeVisible(); - } - - async clickNext() { - // The stepper Next button is an icon-only submit button - await this.page - .locator('[data-component="stepper"] button[type="submit"]') - .click(); - } - - async skipToDeploy() { - await this.page - .getByTestId(TEST_IDS.Onboarding.StepperSkipToDeploy) - .click(); - } - - getByTestId(testId: string) { - return this.page.getByTestId(testId); - } -} diff --git a/frontend/e2e/cloud/onboarding.spec.ts b/frontend/e2e/cloud/onboarding.spec.ts deleted file mode 100644 index 2918dd7c4b..0000000000 --- a/frontend/e2e/cloud/onboarding.spec.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { expect } from "@playwright/test"; -import { TEST_IDS } from "../../src/utils/test-ids"; -import { test } from "./fixtures"; - -test.describe("project creation", () => { - test("user can create a project and reach the onboarding wizard", async ({ - onboardingPage, - page, - }) => { - await onboardingPage.createProject("Test Project"); - // should be redirected to the namespace route - await expect(page).toHaveURL(/orgs\/[^/]+\/projects\/test-project/); - // wizard should mount at the namespace route - await onboardingPage.waitForWizardMount(); - }); -}); - -test.describe("onboarding wizard", () => { - test("provider selection grid renders all expected providers", async ({ - onboardingPage, - }) => { - await onboardingPage.createProject("Provider Test Project"); - await onboardingPage.waitForWizardMount(); - - // skip local steps to reach provider selection - await onboardingPage.skipToDeploy(); - - // all expected providers should be visible (cloudflare-workers is filtered out as specializedPlatform) - for (const provider of [ - "vercel", - "gcp-cloud-run", - "railway", - "hetzner", - "aws-ecs", - "kubernetes", - ]) { - await expect( - onboardingPage.getByTestId( - TEST_IDS.Onboarding.IntegrationProviderOption(provider), - ), - ).toBeVisible(); - } - }); - - test("skip to deploy link jumps from local step to provider step", async ({ - onboardingPage, - page, - }) => { - await onboardingPage.createProject("Skip Deploy Test Project"); - await onboardingPage.waitForWizardMount(); - - // skip to deploy should be visible on install step (first local step) - await expect( - page.getByTestId(TEST_IDS.Onboarding.StepperSkipToDeploy), - ).toBeVisible(); - - await onboardingPage.skipToDeploy(); - - // provider selection grid should now be visible - await expect( - page.getByTestId(TEST_IDS.Onboarding.IntegrationProviderSelection), - ).toBeVisible(); - }); - - test("backend step Next button becomes enabled after successful connection check and datacenter selection", async ({ - onboardingPage, - onboardingIntegrationPage, - page, - }) => { - // Mock datacenters to return a known region so the datacenter combobox is populated. - // Must include pagination and use the correct Datacenter shape (label, name, url). - await page.route(/\/datacenters(\?|$)/, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - datacenters: [ - { label: 1, name: "atl", url: "https://atl.rivet.run" }, - ], - pagination: { cursor: null }, - }), - }), - ); - - // Mock health check to return success - await page.route(/runner-configs\/serverless-health-check/, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ success: { version: "1.0.0" } }), - }), - ); - - await onboardingPage.createProject( - "Next Button Regression Test Project", - ); - await onboardingPage.waitForWizardMount(); - - await onboardingPage.skipToDeploy(); - await onboardingIntegrationPage.waitForProviderStep(); - await onboardingIntegrationPage.selectProvider("vercel"); - await onboardingPage.clickNext(); - - // Select a datacenter (required by configurationSchema) - await onboardingIntegrationPage.selectFirstDatacenter(); - - // Fill a valid endpoint - await onboardingIntegrationPage.fillEndpoint( - "https://my-app.vercel.app", - ); - - // Wait for connection success — sets success=true in the form - await onboardingIntegrationPage.assertConnectionSuccess(); - - // The Next button must become enabled. - // Before the fix (missing runnerName default + datacenters: []) it stayed disabled permanently. - const submitButton = page.locator( - '[data-component="stepper"] button[type="submit"]', - ); - await expect(submitButton).toBeEnabled({ timeout: 10_000 }); - }); - - test("selecting a provider and entering a valid endpoint shows connection success", async ({ - onboardingPage, - onboardingIntegrationPage, - page, - }) => { - // mock the runner health check endpoint to return success - await page.route(/runner-configs\/serverless-health-check/, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ success: { version: "1.0.0" } }), - }), - ); - - await onboardingPage.createProject("Health Check Test Project"); - await onboardingPage.waitForWizardMount(); - - await onboardingPage.skipToDeploy(); - - await onboardingIntegrationPage.waitForProviderStep(); - await onboardingIntegrationPage.selectProvider("vercel"); - - // click next to go to backend config step - await onboardingPage.clickNext(); - - // fill in a valid endpoint - await onboardingIntegrationPage.fillEndpoint( - "https://my-app.vercel.app", - ); - - // should show connection check success - await onboardingIntegrationPage.assertConnectionSuccess(); - }); - - test("selecting a provider and entering an invalid endpoint shows failure state", async ({ - onboardingPage, - onboardingIntegrationPage, - page, - }) => { - // mock the runner health check endpoint to return failure - await page.route(/runner-configs\/serverless-health-check/, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - failure: { - error: { requestFailed: {} }, - }, - }), - }), - ); - - await onboardingPage.createProject("Failure Check Test Project"); - await onboardingPage.waitForWizardMount(); - - await onboardingPage.skipToDeploy(); - - await onboardingIntegrationPage.waitForProviderStep(); - await onboardingIntegrationPage.selectProvider("vercel"); - - await onboardingPage.clickNext(); - - await onboardingIntegrationPage.fillEndpoint( - "https://unreachable.example.com", - ); - - await onboardingIntegrationPage.assertConnectionFailure(); - }); - - test("verification step renders waiting state when backend is configured but no actors exist", async ({ - onboardingPage, - page, - }) => { - // Mock runner configs to return a configured backend (so displayFrontendOnboarding=true) - await page.route(/\/runner-configs(\?|$)/, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - runnerConfigs: { - default: { - datacenters: { - atl: { - serverless: { - url: "https://my-app.vercel.app/api/rivet", - headers: {}, - }, - metadata: { provider: "vercel" }, - }, - }, - }, - }, - cursor: null, - }), - }), - ); - - // Mock actors list names to return empty so verification step stays in waiting state - await page.route(/\/actors\/names/, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ names: {}, cursor: null }), - }), - ); - - // Mock runners/names to return empty (triggers hasBackendConfigured via runnerConfigs only) - await page.route(/\/runners\/names/, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ names: [], cursor: null }), - }), - ); - - await onboardingPage.createProject("Verification Test Project"); - - // With mocked runner configs, wizard should load in displayFrontendOnboarding mode - await onboardingPage.waitForWizardMount(); - - // The verification step (FrontendSetup) should be visible since backend is configured - await expect( - page.getByTestId(TEST_IDS.Onboarding.VerificationStep), - ).toBeVisible({ timeout: 15_000 }); - await expect( - page.getByTestId(TEST_IDS.Onboarding.WaitingForActor), - ).toBeVisible(); - }); -}); diff --git a/frontend/e2e/global.setup.ts b/frontend/e2e/global.setup.ts deleted file mode 100644 index 71e8f062d9..0000000000 --- a/frontend/e2e/global.setup.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default async function globalSetup() { - // No global setup required for Better Auth - // Clerk's clerkSetup() is no longer needed -} diff --git a/frontend/e2e/onboarding.spec.ts-snapshots/onboarding-create-project-card-chromium-darwin.png b/frontend/e2e/onboarding.spec.ts-snapshots/onboarding-create-project-card-chromium-darwin.png deleted file mode 100644 index 19dd3c1288..0000000000 Binary files a/frontend/e2e/onboarding.spec.ts-snapshots/onboarding-create-project-card-chromium-darwin.png and /dev/null differ diff --git a/frontend/e2e/onboarding.spec.ts-snapshots/onboarding-create-template-project-card-chromium-darwin.png b/frontend/e2e/onboarding.spec.ts-snapshots/onboarding-create-template-project-card-chromium-darwin.png deleted file mode 100644 index 40500fff50..0000000000 Binary files a/frontend/e2e/onboarding.spec.ts-snapshots/onboarding-create-template-project-card-chromium-darwin.png and /dev/null differ diff --git a/frontend/e2e/onboarding.spec.ts-snapshots/onboarding-path-selection-chromium-darwin.png b/frontend/e2e/onboarding.spec.ts-snapshots/onboarding-path-selection-chromium-darwin.png deleted file mode 100644 index 380c4b105d..0000000000 Binary files a/frontend/e2e/onboarding.spec.ts-snapshots/onboarding-path-selection-chromium-darwin.png and /dev/null differ diff --git a/frontend/package.json b/frontend/package.json index 8af2a1aa93..f6ea976387 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,22 +4,12 @@ "version": "2.0.21", "type": "module", "scripts": { - "dev": "pnpm run '/^dev:.*/'", - "dev:inspector": "vite --config vite.inspector.config.ts", - "dev:engine": "vite --config vite.engine.config.ts", - "dev:cloud": "vite --config vite.cloud.config.ts", + "dev": "vite", "check-types": "tsc --noEmit", - "build:inspector": "NODE_OPTIONS='--max-old-space-size=8192' vite build --mode=production --base=/ui/ --config vite.inspector.config.ts", - "build:engine": "NODE_OPTIONS='--max-old-space-size=8192' vite build --mode=production --config vite.engine.config.ts", - "build:cloud": "NODE_OPTIONS='--max-old-space-size=8192' vite build --mode=production --config vite.cloud.config.ts", - "preview:inspector": "vite preview --config vite.inspector.config.ts", - "preview:engine": "vite preview --config vite.engine.config.ts", - "preview:cloud": "vite preview --config vite.cloud.config.ts", - "test:e2e": "playwright test", - "test:e2e:ui": "playwright test --ui", + "build": "NODE_OPTIONS='--max-old-space-size=8192' vite build --mode=production", + "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", - "test:e2e:update-snapshots": "playwright test --update-snapshots", "dev:ladle": "ladle dev", "build:ladle": "ladle build" }, @@ -39,6 +29,7 @@ "@fortawesome/react-fontawesome": "^0.2.6", "@hookform/resolvers": "^5.2", "@ladle/react": "^5.1.1", + "@marsidev/react-turnstile": "^1.5.0", "@microsoft/fetch-event-source": "^2.0.1", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", @@ -124,6 +115,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", @@ -163,8 +155,6 @@ "zod": "^3.25.76" }, "devDependencies": { - "@playwright/test": "^1.57.0", - "dotenv": "^17.2.3", "vitest": "^4.0.18" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts deleted file mode 100644 index 3f6d623a22..0000000000 --- a/frontend/playwright.config.ts +++ /dev/null @@ -1,47 +0,0 @@ -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { defineConfig, devices } from "@playwright/test"; -import dotenv from "dotenv"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -// Load environment variables -// dotenv.config({ path: path.resolve(__dirname, ".env") }); -dotenv.config({ path: path.resolve(__dirname, ".env.local") }); - -export default defineConfig({ - testDir: "./e2e", - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - reporter: "html", - use: { - baseURL: "http://localhost:43710", - trace: "on", - }, - globalSetup: "./e2e/global.setup.ts", - projects: [ - { - name: "cloud:setup", - testMatch: /auth\.setup\.ts/, - }, - { - name: "cloud", - use: { - ...devices["Desktop Chrome"], - storageState: ".auth/cloud/user.json", - }, - dependencies: ["cloud:setup"], - testDir: "./e2e/cloud", - }, - ], - webServer: [ - { - name: "Cloud", - command: process.env.CI ? "pnpm preview:cloud" : "pnpm dev:cloud", - url: "http://localhost:43710", - reuseExistingServer: !process.env.CI, - }, - ], -}); diff --git a/frontend/src/app/accept-invitation.tsx b/frontend/src/app/accept-invitation.tsx new file mode 100644 index 0000000000..d4b07043e1 --- /dev/null +++ b/frontend/src/app/accept-invitation.tsx @@ -0,0 +1,226 @@ +import { faGoogle, Icon } from "@rivet-gg/icons"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { isRedirect, useNavigate, useSearch } from "@tanstack/react-router"; +import { attemptAsync } from "es-toolkit"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { authClient, redirectToOrganization } from "@/lib/auth"; + +type InvitationDetails = { + id: string; + email: string; + role: string; + organizationName: string; + status: string; + expiresAt: string; +}; + +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 resolvedInvitationId = invitationId ?? token; + + const { + data: invitation, + isPending: invitationPending, + error: invitationError, + } = useQuery({ + queryKey: ["invitation", resolvedInvitationId], + queryFn: async () => { + const result = await authClient.organization.getInvitation({ + query: { id: resolvedInvitationId! }, + }); + if (result.error || !result.data) { + throw new Error( + result.error?.message ?? + "This invitation link is invalid, expired, or has already been used.", + ); + } + const data = result.data as unknown as InvitationDetails; + if (data.status !== "pending") { + throw new Error( + data.status === "accepted" + ? "This invitation has already been accepted." + : "This invitation has expired or is no longer valid.", + ); + } + return data; + }, + enabled: !!resolvedInvitationId, + retry: false, + }); + + const acceptMutation = useMutation({ + mutationFn: async () => { + const result = await authClient.organization.acceptInvitation({ + invitationId: resolvedInvitationId!, + }); + if (result.error) { + throw new Error( + result.error.message ?? "Failed to accept invitation", + ); + } + const [error] = await attemptAsync( + async () => await redirectToOrganization(), + ); + if (error && isRedirect(error)) { + navigate(error.options); + } + }, + }); + + const rejectMutation = useMutation({ + mutationFn: async () => { + await authClient.organization.rejectInvitation({ + invitationId: resolvedInvitationId!, + }); + }, + onSuccess: () => navigate({ to: "/" }), + }); + + const handleGoogleSignIn = async () => { + await authClient.signIn.social({ + provider: "google", + callbackURL: window.location.href, + }); + }; + + if (sessionPending || invitationPending) { + return ( +
+

Loading…

+
+ ); + } + + if (!resolvedInvitationId || invitationError) { + return ( +
+ + + Invitation unavailable + + {invitationError?.message ?? + "No invitation ID found in this link."} + + + + + + +
+ ); + } + + if (!session) { + return ( +
+ + + You've been invited + + {invitation + ? `Sign in or create an account to join ${invitation.organizationName}.` + : "Sign in or create an account to accept this invitation."} + + + + + + + + +
+ ); + } + + return ( +
+ + + You've been invited + + {invitation + ? `You've been invited to join ${invitation.organizationName} as ${invitation.role}.` + : "Accept the invitation to join the organization."} + + + {acceptMutation.error && ( + +

+ {acceptMutation.error.message} +

+
+ )} + + + + +
+
+ ); +} diff --git a/frontend/src/app/actor-builds-list.tsx b/frontend/src/app/actor-builds-list.tsx index 9e730ad07e..c3e8b5962c 100644 --- a/frontend/src/app/actor-builds-list.tsx +++ b/frontend/src/app/actor-builds-list.tsx @@ -2,10 +2,10 @@ import { faActorsBorderless, Icon, type IconProp } from "@rivet-gg/icons"; import { useInfiniteQuery } from "@tanstack/react-query"; import { Link, useNavigate } from "@tanstack/react-router"; import { Fragment, Suspense, use } from "react"; -import { match } from "ts-pattern"; import { Button, cn, Skeleton } from "@/components"; import { useEngineCompatDataProvider } from "@/components/actors"; import { VisibilitySensor } from "@/components/visibility-sensor"; +import { features } from "@/lib/features"; import { RECORDS_PER_PAGE } from "./data-providers/default-data-provider"; const emojiRegex = @@ -101,17 +101,12 @@ export function ActorBuildsList() { variant={"ghost"} size="sm" onClick={() => { - return navigate({ - to: match(__APP_TYPE__) - .with("engine", () => "/ns/$namespace") - .with( - "cloud", - () => - "/orgs/$organization/projects/$project/ns/$namespace", - ) - .otherwise(() => "/"), - - search: (old) => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (navigate as any)({ + to: features.multitenancy + ? "/orgs/$organization/projects/$project/ns/$namespace" + : "/ns/$namespace", + search: (old: Record) => ({ ...old, actorId: undefined, n: [build.id], diff --git a/frontend/src/app/billing/billing-limit-alert.tsx b/frontend/src/app/billing/billing-limit-alert.tsx index 155255a3ef..9585ebd9bc 100644 --- a/frontend/src/app/billing/billing-limit-alert.tsx +++ b/frontend/src/app/billing/billing-limit-alert.tsx @@ -3,9 +3,15 @@ import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { Button, cn } from "@/components"; import { useCloudProjectDataProvider } from "@/components/actors"; +import { features } from "@/lib/features"; import { useHighestUsagePercent } from "./hooks"; export function BillingLimitAlert() { + if (!features.billing) return null; + return ; +} + +function BillingLimitAlertInner() { const dataProvider = useCloudProjectDataProvider(); const { data: billingData } = useQuery({ ...dataProvider.currentProjectBillingDetailsQueryOptions(), diff --git a/frontend/src/app/billing/billing-usage-gauge.tsx b/frontend/src/app/billing/billing-usage-gauge.tsx index 38124e48ba..a6809e858c 100644 --- a/frontend/src/app/billing/billing-usage-gauge.tsx +++ b/frontend/src/app/billing/billing-usage-gauge.tsx @@ -2,6 +2,7 @@ import { faExclamationTriangle, Icon } from "@rivet-gg/icons"; import { useQuery } from "@tanstack/react-query"; import { cn, WithTooltip } from "@/components"; import { useCloudProjectDataProvider } from "@/components/actors"; +import { features } from "@/lib/features"; import { useHighestUsagePercent } from "./hooks"; const progressColors = { @@ -26,6 +27,11 @@ const radius = 9; const circumference = 2 * Math.PI * radius; export function BillingUsageGauge() { + if (!features.billing) return null; + return ; +} + +function BillingUsageGaugeInner() { const progress = useHighestUsagePercent(); const dataProvider = useCloudProjectDataProvider(); diff --git a/frontend/src/app/context-switcher.tsx b/frontend/src/app/context-switcher.tsx index b8fdf25e21..c15e3faba8 100644 --- a/frontend/src/app/context-switcher.tsx +++ b/frontend/src/app/context-switcher.tsx @@ -34,6 +34,7 @@ import { import { SafeHover } from "@/components/safe-hover"; import { VisibilitySensor } from "@/components/visibility-sensor"; import { authClient } from "@/lib/auth"; +import { features } from "@/lib/features"; import { LazyBillingPlanBadge } from "./billing/billing-plan-badge"; export function ContextSwitcher({ inline }: { inline?: boolean }) { @@ -62,7 +63,7 @@ function ContextSwitcherInner({ }) { const [isOpen, setIsOpen] = useState(false); - if (__APP_TYPE__ === "cloud") { + if (features.multitenancy) { // biome-ignore lint/correctness/useHookAtTopLevel: guaranteed by build condition usePrefetchInfiniteQuery({ // biome-ignore lint/correctness/useHookAtTopLevel: guaranteed by build condition @@ -469,10 +470,12 @@ function ProjectListItem({ onFocus={onHover} > {displayName} - + {features.billing && ( + + )} ); 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/dialogs/create-api-token-frame.tsx b/frontend/src/app/dialogs/create-api-token-frame.tsx index 65202c4d3b..216d306c85 100644 --- a/frontend/src/app/dialogs/create-api-token-frame.tsx +++ b/frontend/src/app/dialogs/create-api-token-frame.tsx @@ -54,7 +54,7 @@ export default function CreateApiTokenFrameContent({ onClose, }: CreateApiTokenFrameContentProps) { const { dataProvider } = useRouteContext({ - from: "/_context/_cloud/orgs/$organization/projects/$project", + from: "/_context/orgs/$organization/projects/$project", }); const [createdToken, setCreatedToken] = useState(null); diff --git a/frontend/src/app/dialogs/create-namespace-frame.tsx b/frontend/src/app/dialogs/create-namespace-frame.tsx index 6d6acb3ac7..9e69700308 100644 --- a/frontend/src/app/dialogs/create-namespace-frame.tsx +++ b/frontend/src/app/dialogs/create-namespace-frame.tsx @@ -7,31 +7,22 @@ import { import { match } from "ts-pattern"; import * as CreateNamespaceForm from "@/app/forms/create-namespace-form"; import { Flex, Frame } from "@/components"; +import { features } from "@/lib/features"; import { convertStringToId } from "@/lib/utils"; const useDataProvider = () => { - return match(__APP_TYPE__) - .with("cloud", () => { - // biome-ignore lint/correctness/useHookAtTopLevel: match will only run once per app load - return useRouteContext({ - from: "/_context/_cloud/orgs/$organization/projects/$project", - select: (ctx) => ctx.dataProvider, - }); - }) - .with("engine", () => { - return match( - // biome-ignore lint/correctness/useHookAtTopLevel: match will only run once per app load - useRouteContext({ - from: "/_context", - }), - ) - .with({ __type: "engine" }, (ctx) => ctx.dataProvider) - .otherwise(() => { - throw new Error("Invalid context"); - }); - }) + if (features.multitenancy) { + // biome-ignore lint/correctness/useHookAtTopLevel: guarded by build constant + return useRouteContext({ + from: "/_context/orgs/$organization/projects/$project", + select: (ctx) => ctx.dataProvider, + }); + } + // biome-ignore lint/correctness/useHookAtTopLevel: guarded by build constant + return match(useRouteContext({ from: "/_context" })) + .with({ __type: "engine" }, (ctx) => ctx.dataProvider) .otherwise(() => { - throw new Error("Invalid app type"); + throw new Error("Invalid context"); }); }; @@ -51,7 +42,7 @@ const useCreateNamespace = () => { dataProivder.namespacesQueryOptions(), ); - if (__APP_TYPE__ === "cloud") { + if (features.namespaceManagement) { if (!params.project || !params.organization) { throw new Error("Missing required parameters"); } diff --git a/frontend/src/app/dialogs/create-organization-frame.tsx b/frontend/src/app/dialogs/create-organization-frame.tsx index 8e28b276d0..9448be2026 100644 --- a/frontend/src/app/dialogs/create-organization-frame.tsx +++ b/frontend/src/app/dialogs/create-organization-frame.tsx @@ -1,39 +1,39 @@ import { useMutation } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; import * as CreateOrganizationForm from "@/app/forms/create-organization-form"; import { Button, type DialogContentProps, Frame } from "@/components"; 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({ onClose, }: CreateOrganizationContentProps) { + const navigate = useNavigate(); 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(), }); + if (result.error) { + throw result.error; + } return result; }, - onSuccess: async () => { + onSuccess: async (data) => { await queryClient.invalidateQueries( dataProvider.organizationsQueryOptions(), ); + await navigate({ + to: "/orgs/$organization", + params: { organization: data.data.slug }, + }); + onClose?.(); }, }); @@ -50,12 +50,20 @@ export default function CreateOrganizationContent({ { - await mutateAsync(values); + onSubmit={async (values, form) => { + try { + await mutateAsync(values); + } catch { + form.setError("root", { + message: "Failed to create organization.", + }); + return; + } }} > + + } + /> + ); +} + +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."), + }); + + 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."), + }); + + return ( +
+ resend()} + > + + + } + /> + revoke()} + > + + + } + /> +
+ ); +} + +interface OrgMembersFrameContentProps extends DialogContentProps {} + +export default function OrgMembersFrameContent(_: OrgMembersFrameContentProps) { + const { data: org, isPending } = authClient.useActiveOrganization(); + const { data: session } = authClient.useSession(); + + 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 ( + <> + + Manage Members + + View members and invite people to your organization. + + + + {isPending ? ( +
+ + + +
+ ) : ( + <> +
+ + + + + Member + + + + + + {org?.members.map((member) => { + const user = member.user; + return ( + + +
+ + + + {(user?.name ?? + user?.email ?? + "?")[0].toUpperCase()} + + +
+
+

+ {user?.name} +

+ {member.userId === + session + ?.user + .id && ( + + You + + )} +
+

+ {user?.email} +

+
+
+
+ +
+ {member.userId !== + session?.user.id && + org && ( + + )} +
+
+
+ ); + })} + {org?.invitations + .filter( + (inv) => inv.status === "pending", + ) + .map((inv) => ( + + +
+ + + {inv.email[0].toUpperCase()} + + +
+

+ {inv.email} +

+

+ Invitation sent +

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

+ Invite a member +

+ { + try { + await inviteMember(email); + form.reset(); + } catch { + form.setError("root", { + message: + "Failed to send invitation.", + }); + } + }} + > +
+
+ +
+ + Invite + +
+ +
+
+ + )} +
+ + ); +} diff --git a/frontend/src/app/dialogs/tokens-frame.tsx b/frontend/src/app/dialogs/tokens-frame.tsx index 8689a2c974..ecd5ffdb75 100644 --- a/frontend/src/app/dialogs/tokens-frame.tsx +++ b/frontend/src/app/dialogs/tokens-frame.tsx @@ -2,7 +2,6 @@ import { faQuestionCircle, Icon } from "@rivet-gg/icons"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useRouteContext } from "@tanstack/react-router"; import { useEffect, useState } from "react"; -import { match } from "ts-pattern"; import { HelpDropdown } from "@/app/help-dropdown"; import { PublishableTokenCodeGroup } from "@/app/publishable-token-code-group"; import { @@ -17,6 +16,7 @@ import { } from "@/components"; import { RegionSelect } from "@/components/actors/region-select"; import { cloudEnv } from "@/lib/env"; +import { features } from "@/lib/features"; interface TokensFrameContentProps extends DialogContentProps {} @@ -55,7 +55,7 @@ export default function TokensFrameContent({ function SecretToken() { const dataProvider = useRouteContext({ - from: "/_context/_cloud/orgs/$organization/projects/$project/ns/$namespace", + from: "/_context/orgs/$organization/projects/$project/ns/$namespace", select: (c) => c.dataProvider, }); const { data: token, isLoading: isTokenLoading } = useQuery( @@ -77,15 +77,9 @@ function SecretToken() { const namespace = dataProvider.engineNamespace; - const endpoint = match(__APP_TYPE__) - .with("cloud", () => { - const region = regions.find((r) => r.name === selectedDatacenter); - return region?.url || cloudEnv().VITE_APP_API_URL; - }) - .with("engine", () => getConfig().apiUrl) - .otherwise(() => { - throw new Error("Not in a valid context"); - }); + const endpoint = features.multitenancy + ? regions.find((r) => r.name === selectedDatacenter)?.url || cloudEnv().VITE_APP_API_URL + : getConfig().apiUrl; const envVars = `RIVET_ENDPOINT=${endpoint} RIVET_NAMESPACE=${namespace} @@ -131,7 +125,7 @@ registry.start();`; function PublishableToken() { const dataProvider = useRouteContext({ - from: "/_context/_cloud/orgs/$organization/projects/$project/ns/$namespace", + from: "/_context/orgs/$organization/projects/$project/ns/$namespace", select: (c) => c.dataProvider, }); const { data: token, isLoading } = useQuery( @@ -140,12 +134,9 @@ function PublishableToken() { const namespace = dataProvider.engineNamespace; - const endpoint = match(__APP_TYPE__) - .with("cloud", () => cloudEnv().VITE_APP_API_URL) - .with("engine", () => getConfig().apiUrl) - .otherwise(() => { - throw new Error("Not in a valid context"); - }); + const endpoint = features.multitenancy + ? cloudEnv().VITE_APP_API_URL + : getConfig().apiUrl; return (
diff --git a/frontend/src/app/env-variables.tsx b/frontend/src/app/env-variables.tsx index 1d11189758..d210fcba18 100644 --- a/frontend/src/app/env-variables.tsx +++ b/frontend/src/app/env-variables.tsx @@ -1,9 +1,9 @@ import { useId } from "react"; -import { match } from "ts-pattern"; import { Button, CopyTrigger, DiscreteInput, getConfig } from "@/components"; import { useEngineCompatDataProvider } from "@/components/actors"; import { Label } from "@/components/ui/label"; import { cloudEnv } from "@/lib/env"; +import { features } from "@/lib/features"; import { useAdminToken, usePublishableToken } from "@/queries/accessors"; export function EnvVariables({ @@ -98,10 +98,9 @@ export const useRivetDsn = ({ endpoint?: string; kind: "publishable" | "secret"; }) => { - const globalEndpoint = match(__APP_TYPE__) - .with("cloud", () => cloudEnv().VITE_APP_API_URL) - .with("engine", () => getConfig().apiUrl) - .otherwise(() => getConfig().apiUrl); + const globalEndpoint = features.multitenancy + ? cloudEnv().VITE_APP_API_URL + : getConfig().apiUrl; // Publishable (RIVET_PUBLIC_ENDPOINT) always uses global endpoint. // Secret (RIVET_ENDPOINT) uses regional endpoint if provided. @@ -116,7 +115,7 @@ export const useRivetDsn = ({ const auth = kind === "secret" ? `${namespace}:${adminToken}` - : __APP_TYPE__ === "engine" + : !features.multitenancy ? namespace : `${namespace}:${publishableToken}`; diff --git a/frontend/src/app/forgot-password.tsx b/frontend/src/app/forgot-password.tsx new file mode 100644 index 0000000000..43c6b51d0d --- /dev/null +++ b/frontend/src/app/forgot-password.tsx @@ -0,0 +1,132 @@ +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 { TurnstileWidget } from "@/components/ui/turnstile"; +import { authClient } from "@/lib/auth"; +import { cloudEnv } from "@/lib/env"; +import { features } from "@/lib/features"; + +export function ForgotPassword() { + const [sent, setSent] = useState(false); + const [turnstileToken, setTurnstileToken] = useState(null); + const turnstileSiteKey = cloudEnv().VITE_APP_TURNSTILE_SITE_KEY; + + const handleSubmit: SubmitHandler = async ({ email }, form) => { + if (features.captcha && !turnstileToken) { + form.setError("root", { + message: "Captcha verification is still loading, please try again", + }); + return; + } + + const result = await authClient.requestPasswordReset( + { email, redirectTo: `${window.location.origin}/reset-password` }, + features.captcha && turnstileToken + ? { headers: { "x-captcha-response": turnstileToken } } + : undefined, + ); + + if (result.error) { + form.setError("root", { + message: result.error.message ?? "Failed to send reset email", + }); + return; + } + + setTurnstileToken(null); + 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. + + +
+ + + + {features.captcha && turnstileSiteKey && ( + setTurnstileToken(null)} + onError={() => setTurnstileToken(null)} + onTimeout={() => setTurnstileToken(null)} + /> + )} + + +
+ Send reset link + +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/forms/create-organization-form.tsx b/frontend/src/app/forms/create-organization-form.tsx index 3bbb781d14..946b81df92 100644 --- a/frontend/src/app/forms/create-organization-form.tsx +++ b/frontend/src/app/forms/create-organization-form.tsx @@ -45,3 +45,13 @@ export const Name = ({ className }: { className?: string }) => { /> ); }; + +export const RootError = () => { + const { formState } = useFormContext(); + if (!formState.errors.root) return null; + return ( +

+ {formState.errors.root.message} +

+ ); +}; 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/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/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/app/layout.tsx b/frontend/src/app/layout.tsx index 5aa19cda7b..f6a8cd213c 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -27,7 +27,6 @@ import { useState, } from "react"; import type { ImperativePanelGroupHandle } from "react-resizable-panels"; -import { match } from "ts-pattern"; import { Button, type ButtonProps, @@ -48,6 +47,7 @@ import { import { useRootLayoutOptional } from "@/components/actors/root-layout-context"; import type { HeaderLinkProps } from "@/components/header/header-link"; import { authClient } from "@/lib/auth"; +import { features } from "@/lib/features"; import { ensureTrailingSlash } from "@/lib/utils"; import { TEST_IDS } from "@/utils/test-ids"; import { ActorBuildsList } from "./actor-builds-list"; @@ -178,122 +178,119 @@ const Sidebar = ({ >
- {match(__APP_TYPE__) - .with("engine", () => ( + {features.multitenancy + ? + : ( <> - )) - .with("cloud", () => ) - .otherwise(() => null)} + )}
- {match(__APP_TYPE__) - .with("cloud", () => { - return ( - <> -
- {matchRoute({ - to: "/orgs/$organization/projects/$project/ns/$namespace", - fuzzy: true, - }) ? ( - - -

- - Billing -

-
- - -
- -
- ) : matchRoute({ - to: "/orgs/$organization/projects/$project", - fuzzy: true, - }) ? ( - - -

- - Billing -

-
- - -
- -
- ) : null} - - - } - > - Support - - - - - - } + {features.multitenancy ? ( + <> +
+ {features.billing && matchRoute({ + to: "/orgs/$organization/projects/$project/ns/$namespace", + fuzzy: true, + }) ? ( + + +

+ + Billing +

+
+ + +
+ +
+ ) : features.billing && matchRoute({ + to: "/orgs/$organization/projects/$project", + fuzzy: true, + }) ? ( + + +

+ + Billing +

+
+ + +
+ +
+ ) : null} + {features.support ? ( + + + } + > + Support + + + ) : null} + {features.branding ? ( + + + } + > + - - Whats new? - - - - -
-
- -
- -
- - ); - }) - .otherwise(() => ( - <> -
+ Whats new? + + + + + ) : null} +
+
+ {features.auth ? ( +
+ +
+ ) : null} + + ) : ( +
+ {features.branding ? ( - - } + ) : null} + + } + > + ({ + ...old, + modal: "feedback", + })} > - ({ - ...old, - modal: "feedback", - })} - > - Feedback - - - - } - endIcon={ - - } + Feedback + + + + } + endIcon={ + + } + > + - - Documentation - - - - } - endIcon={ - - } + Documentation + + + + } + endIcon={ + + } + > + - - Discord - - - - } - endIcon={ - - } + Discord + + + + } + endIcon={ + + } + > + - - GitHub - - -
- - ))} + GitHub + + +
+ )}
diff --git a/frontend/src/app/login.tsx b/frontend/src/app/login.tsx index b211f153b1..560dd86f29 100644 --- a/frontend/src/app/login.tsx +++ b/frontend/src/app/login.tsx @@ -1,8 +1,22 @@ "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 { 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 +26,123 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +import { TurnstileWidget } from "@/components/ui/turnstile"; import { authClient, redirectToOrganization } from "@/lib/auth"; +import { cloudEnv } from "@/lib/env"; +import { features } from "@/lib/features"; export function Login() { const navigate = useNavigate(); 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 [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 ({ email, password }, form) => { + if (features.captcha && !turnstileToken) { + form.setError("root", { + message: + "Captcha verification is still loading, please try again", + }); + return; + } - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setError(null); - setIsLoading(true); + const result = await authClient.signIn.email( + { email, password }, + features.captcha && turnstileToken + ? { headers: { "x-captcha-response": turnstileToken } } + : undefined, + ); - 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; - } + if (result.data?.user.emailVerified === false) { + return navigate({ to: "/verify-email-pending", search: { email } }); + } - // Redirect to org page - try { - await redirectToOrganization( - from ? { from } : {}, - ); - } catch (e) { - // redirectToOrganization throws a redirect - throw e; - } + setTurnstileToken(null); - // 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); + const [error] = await attemptAsync( + async () => await redirectToOrganization(), + ); + + 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} - /> + Google + +
+

+ or +

+ + +
+ + Forgot password? + +
+
-
- - setPassword(e.target.value)} - disabled={isLoading} + {features.captcha && turnstileSiteKey && ( + setTurnstileToken(null)} + onError={() => setTurnstileToken(null)} + onTimeout={() => setTurnstileToken(null)} /> -
- {error ? ( -

{error}

- ) : null} + )}
- + Sign in
- + ); diff --git a/frontend/src/app/reset-password.tsx b/frontend/src/app/reset-password.tsx new file mode 100644 index 0000000000..8ed2094fbc --- /dev/null +++ b/frontend/src/app/reset-password.tsx @@ -0,0 +1,131 @@ +import { + isRedirect, + Link, + useNavigate, + useSearch, +} from "@tanstack/react-router"; +import { attemptAsync } from "es-toolkit"; +import { motion } from "framer-motion"; +import { toast } from "sonner"; +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; + } + + toast.success("Password updated. You can now sign in.", { + position: "top-center", + }); + + 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/app/sign-up.tsx b/frontend/src/app/sign-up.tsx index 8aa234b376..4d24644fc8 100644 --- a/frontend/src/app/sign-up.tsx +++ b/frontend/src/app/sign-up.tsx @@ -1,8 +1,17 @@ "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 { Link, useNavigate, useSearch } from "@tanstack/react-router"; import { motion } from "framer-motion"; -import { type FormEvent, useState } from "react"; +import { 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 +21,102 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +import { TurnstileWidget } from "@/components/ui/turnstile"; import { authClient } from "@/lib/auth"; +import { cloudEnv } from "@/lib/env"; +import { features } from "@/lib/features"; export function SignUp() { const navigate = useNavigate(); - - 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 = async (e: FormEvent) => { - e.preventDefault(); - setError(null); - setIsLoading(true); - - try { - const result = await authClient.signUp.email({ - email, - password, - name, + 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 handleSubmit: SubmitHandler = async ( + { name, email, password }, + form, + ) => { + if (features.captcha && !turnstileToken) { + form.setError("root", { + message: + "Captcha verification is still loading, please try again", }); + return; + } - if (result.error) { - setError(result.error.message ?? "Sign up failed"); - setIsLoading(false); - return; - } + const result = await authClient.signUp.email( + { email, password, name, callbackURL: `${window.location.origin}/?emailVerified=1` }, + features.captcha && turnstileToken + ? { headers: { "x-captcha-response": turnstileToken } } + : undefined, + ); - // 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 (result.error) { + form.setError("root", { + message: result.error.message ?? "Sign up failed", + }); + return; } + + setTurnstileToken(null); + navigate({ to: "/verify-email-pending", search: { email } }); }; 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} + + + + + {features.captcha && turnstileSiteKey && ( + setTurnstileToken(null)} + onError={() => setTurnstileToken(null)} + onTimeout={() => setTurnstileToken(null)} /> -
- {error ? ( -

{error}

- ) : null} + )}
- + Continue
-
+
); 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 421dff7cc2..ee3f5e7c84 100644 --- a/frontend/src/app/user-dropdown.tsx +++ b/frontend/src/app/user-dropdown.tsx @@ -54,31 +54,51 @@ export function UserDropdown({ children }: { children?: React.ReactNode }) { {isMatchingProjectRoute ? ( - { - return navigate({ - to: ".", - search: (old) => ({ ...old, modal: "billing" }), - }); - }} - > - Billing - + <> + { + return navigate({ + to: ".", + search: (old) => ({ + ...old, + modal: "billing", + }), + }); + }} + > + Billing + + + ) : null} - {params.organization ? ( - - - Switch Organization - - - - - - - + <> + + + Switch Organization + + + + + + + + { + return navigate({ + to: ".", + search: (old) => ({ + ...old, + modal: "org-members", + }), + }); + }} + > + Manage Members + + ) : null} { diff --git a/frontend/src/app/verify-email-pending.tsx b/frontend/src/app/verify-email-pending.tsx new file mode 100644 index 0000000000..e2b1aa215c --- /dev/null +++ b/frontend/src/app/verify-email-pending.tsx @@ -0,0 +1,132 @@ +import { useMutation } from "@tanstack/react-query"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { toast } from "sonner"; +import { useTimeout } from "usehooks-ts"; +import { RelativeTime } from "@/components/relative-time"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardDescription, + CardFooter, + CardHeader, + 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 searchEmail = useSearch({ strict: false, select: (s: { email?: string }) => s?.email }); + const email = session?.user.email ?? searchEmail; + const navigate = useNavigate(); + + const handleBackToSignIn = async () => { + await authClient.signOut(); + navigate({ to: "/login" }); + }; + + 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}/?emailVerified=1`, + 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) { + throw { ...result.error, retryAfter }; + } + return result.data; + }, + onSuccess: () => { + toast.success("Verification email sent", { + position: "top-center", + }); + }, + }); + + 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 handleResend = () => { + if (!email) return; + mutate(); + }; + + 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/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/actors-list.tsx b/frontend/src/components/actors/actors-list.tsx index 4accc206ca..baf7eb4312 100644 --- a/frontend/src/components/actors/actors-list.tsx +++ b/frontend/src/components/actors/actors-list.tsx @@ -32,6 +32,7 @@ import { WithTooltip, } from "@/components"; import { docsLinks } from "@/content/data"; +import { features } from "@/lib/features"; import { VisibilitySensor } from "../visibility-sensor"; import { useActorsFilters, useFiltersValue } from "./actor-filters-context"; import { useActorsLayout } from "./actors-layout-context"; @@ -77,7 +78,7 @@ function TopBar() { /> ) : null}
- {["engine", "cloud"].includes(__APP_TYPE__) ? ( + {features.datacenter ? ( ) : (
@@ -186,9 +187,6 @@ export function ListSkeleton() { } const useRunnerConfigs = () => { - if (__APP_TYPE__ === "inspector") { - return 1; - } const dataProvider = useDataProvider(); const { data: runnerNamesCount = 0 } = useInfiniteQuery({ ...dataProvider.runnerNamesQueryOptions(), diff --git a/frontend/src/components/actors/data-provider.tsx b/frontend/src/components/actors/data-provider.tsx index 2658d1753f..d1307b45d1 100644 --- a/frontend/src/components/actors/data-provider.tsx +++ b/frontend/src/components/actors/data-provider.tsx @@ -4,7 +4,6 @@ import { useMatchRoute, useRouteContext, } from "@tanstack/react-router"; -import { match } from "ts-pattern"; import type { createGlobalContext as createGlobalCloudContext, createNamespaceContext as createNamespaceCloudContext, @@ -15,7 +14,7 @@ import type { createGlobalContext as createGlobalEngineContext, createNamespaceContext as createNamespaceEngineContext, } from "@/app/data-providers/engine-data-provider"; -import type { createGlobalContext as createGlobalInspectorContext } from "@/app/data-providers/inspector-data-provider"; +import { features } from "@/lib/features"; type EngineDataProvider = ReturnType & ReturnType; @@ -25,111 +24,49 @@ type CloudDataProvider = ReturnType & ReturnType & ReturnType; -type InspectorDataProvider = ReturnType; - -type RootContext = - | { - __type: "engine"; - dataProvider: EngineDataProvider; - } - | { - __type: "cloud"; - dataProvider: CloudDataProvider; - } - | { - __type: "inspector"; - dataProvider: InspectorDataProvider; - }; - -export const useDataProvider = (): - | EngineDataProvider - | CloudDataProvider - | InspectorDataProvider => { - return match(__APP_TYPE__) - .with("cloud", () => { - // biome-ignore lint/correctness/useHookAtTopLevel: runs only once - return useRouteContext({ - from: "/_context/_cloud/orgs/$organization/projects/$project/ns/$namespace", - select: (ctx) => ctx.dataProvider, - }); - }) - .with("engine", () => { - // biome-ignore lint/correctness/useHookAtTopLevel: runs only once - return useRouteContext({ - from: "/_context/_engine/ns/$namespace", - }).dataProvider; - }) - .with("inspector", () => { - // we need to narrow down the context for inspector, because inspector does not have a unique route prefix - return match( - // biome-ignore lint/correctness/useHookAtTopLevel: runs only once - useRouteContext({ - from: "/_context", - }) as RootContext, - ) - .with({ __type: "inspector" }, (ctx) => ctx.dataProvider) - .otherwise(() => { - throw new Error("Not in an inspector-like context"); - }); - }) - .exhaustive() as - | EngineDataProvider - | CloudDataProvider - | InspectorDataProvider; +export const useDataProvider = (): EngineDataProvider | CloudDataProvider => { + if (features.multitenancy) { + // biome-ignore lint/correctness/useHookAtTopLevel: guarded by build constant + return useRouteContext({ + from: "/_context/orgs/$organization/projects/$project/ns/$namespace", + select: (ctx) => ctx.dataProvider, + }) as CloudDataProvider; + } + // biome-ignore lint/correctness/useHookAtTopLevel: guarded by build constant + return useRouteContext({ + from: "/_context/ns/$namespace", + }).dataProvider as EngineDataProvider; }; export const useDataProviderCheck = () => { const matchRoute = useMatchRoute(); - return matchRoute({ fuzzy: true, - to: match(__APP_TYPE__) - .with("cloud", () => { - return "/orgs/$organization/projects/$project/ns/$namespace" as const; - }) - .with("engine", () => { - return "/ns/$namespace" as const; - }) - .with("inspector", () => { - return "/" as const; - }) - .otherwise(() => { - throw new Error("Not in a valid context"); - }), + to: features.multitenancy + ? "/orgs/$organization/projects/$project/ns/$namespace" + : "/ns/$namespace", }); }; export const useEngineDataProvider = () => { return useRouteContext({ - from: "/_context/_engine", + from: "/_context", }).dataProvider; }; export const useEngineNamespaceDataProvider = () => { return useRouteContext({ - from: "/_context/_engine/ns/$namespace", + from: "/_context/ns/$namespace", }).dataProvider; }; -export const useInspectorDataProvider = () => { - const context = useRouteContext({ - from: "/_context", - }) as RootContext; - - return match(context) - .with({ __type: "inspector" }, (c) => c.dataProvider) - .otherwise(() => { - throw new Error("Not in an inspector-like context"); - }); -}; - type OnlyCloudRouteIds = Extract< RouteIds, - `/_context/_cloud/orgs/${string}` + `/_context/orgs/${string}` >; export const useCloudDataProvider = ({ - from = "/_context/_cloud/orgs/$organization", + from = "/_context/orgs/$organization", }: { from?: OnlyCloudRouteIds; } = {}) => { @@ -140,35 +77,22 @@ export const useCloudDataProvider = ({ export const useCloudProjectDataProvider = () => { return useRouteContext({ - from: "/_context/_cloud/orgs/$organization/projects/$project", + from: "/_context/orgs/$organization/projects/$project", }).dataProvider; }; export const useCloudNamespaceDataProvider = () => { return useRouteContext({ - from: "/_context/_cloud/orgs/$organization/projects/$project/ns/$namespace", + from: "/_context/orgs/$organization/projects/$project/ns/$namespace", }).dataProvider; }; export const useEngineCompatDataProvider = () => { - const routePath = match(__APP_TYPE__) - .with("cloud", () => { - return "/_context/_cloud/orgs/$organization/projects/$project/ns/$namespace" as const; - }) - .with("engine", () => { - return "/_context/_engine/ns/$namespace" as const; - }) - .with("inspector", () => { - return "/_context" as const; - }) - .otherwise(() => { - throw new Error("Not in an engine-like context"); - }); + const routePath = features.multitenancy + ? ("/_context/orgs/$organization/projects/$project/ns/$namespace" as const) + : ("/_context/ns/$namespace" as const); return useRouteContext({ from: routePath, - }).dataProvider as - | EngineDataProvider - | CloudDataProvider - | InspectorDataProvider; + }).dataProvider as EngineDataProvider | CloudDataProvider; }; 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/actors/guard-connectable-inspector.tsx b/frontend/src/components/actors/guard-connectable-inspector.tsx index 60ceb86f5b..459d44d40e 100644 --- a/frontend/src/components/actors/guard-connectable-inspector.tsx +++ b/frontend/src/components/actors/guard-connectable-inspector.tsx @@ -26,6 +26,7 @@ import { match, P } from "ts-pattern"; import { useLocalStorage } from "usehooks-ts"; import { HelpDropdown } from "@/app/help-dropdown"; import { isRivetApiError } from "@/lib/errors"; +import { features } from "@/lib/features"; import { DiscreteCopyButton } from "../copy-area"; import { getConfig, useConfig } from "../lib/config"; import { ls } from "../lib/utils"; @@ -521,10 +522,10 @@ function useActorRunner({ actorId }: { actorId: ActorId }) { } function useEngineToken() { - if (__APP_TYPE__ === "cloud") { + if (features.multitenancy) { const { data } = useQuery( useRouteContext({ - from: "/_context/_cloud/orgs/$organization/projects/$project/ns/$namespace", + from: "/_context/orgs/$organization/projects/$project/ns/$namespace", }).dataProvider.publishableTokenQueryOptions(), ); return data; diff --git a/frontend/src/components/actors/no-providers-alert.tsx b/frontend/src/components/actors/no-providers-alert.tsx index ad6549d081..d3426e0d7b 100644 --- a/frontend/src/components/actors/no-providers-alert.tsx +++ b/frontend/src/components/actors/no-providers-alert.tsx @@ -2,6 +2,7 @@ import { faBook, faExclamationTriangle, faPlus, Icon } from "@rivet-gg/icons"; import { Link } from "@tanstack/react-router"; import { ProviderDropdown } from "@/app/provider-dropdown"; import { docsLinks } from "@/content/data"; +import { features } from "@/lib/features"; import { Button } from "../ui/button"; import { H4 } from "../ui/typography"; @@ -28,7 +29,7 @@ export function NoProvidersAlert({
{variant === "default" ? ( <> - {__APP_TYPE__ === "cloud" ? ( + {features.multitenancy ? ( ) : null} - {__APP_TYPE__ === "engine" ? ( + {!features.multitenancy ? ( - {match(__APP_TYPE__) - .with("cloud", () => ( - - )) - .otherwise(() => ( - - ))} + {features.support ? ( + + ) : null}