diff --git a/AGENTS.md b/AGENTS.md index a9f27a9..50278f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,10 @@ - Paths (web): use alias `~/` for src imports (e.g., `import { x } from "~/utils/x"`). - Never use dynamic imports +## Rules + +- Prefer to use trpc alway unless asked otherwise + ## Testing Guidelines - No repo-wide test runner is configured yet. do not add any tests unless required diff --git a/apps/web/src/app/(dashboard)/admin/layout.tsx b/apps/web/src/app/(dashboard)/admin/layout.tsx new file mode 100644 index 0000000..7d53f7d --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/layout.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { SettingsNavButton } from "../dev-settings/settings-nav-button"; +import { isCloud } from "~/utils/common"; + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+

Admin

+
+ + SES Configurations + + {isCloud() ? ( + + Teams + + ) : null} + {isCloud() ? ( + + Waitlist + + ) : null} +
+
{children}
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/page.tsx b/apps/web/src/app/(dashboard)/admin/page.tsx index 1777a6d..d81188e 100644 --- a/apps/web/src/app/(dashboard)/admin/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/page.tsx @@ -2,19 +2,15 @@ import AddSesConfiguration from "./add-ses-configuration"; import SesConfigurations from "./ses-configurations"; -import { H1 } from "@usesend/ui"; -export default function ApiKeysPage() { +export default function AdminSesPage() { return ( -
-
-

Admin

+
+
+

SES Configurations

-
-

SES Configurations

- -
+
); } diff --git a/apps/web/src/app/(dashboard)/admin/teams/page.tsx b/apps/web/src/app/(dashboard)/admin/teams/page.tsx new file mode 100644 index 0000000..b3636b7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/teams/page.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { Button } from "@usesend/ui/src/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@usesend/ui/src/form"; +import { Input } from "@usesend/ui/src/input"; +import { Switch } from "@usesend/ui/src/switch"; +import Spinner from "@usesend/ui/src/spinner"; +import { toast } from "@usesend/ui/src/toaster"; +import { Badge } from "@usesend/ui/src/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@usesend/ui/src/select"; +import { formatDistanceToNow } from "date-fns"; + +import { api } from "~/trpc/react"; +import type { AppRouter } from "~/server/api/root"; +import type { inferRouterOutputs } from "@trpc/server"; +import { isCloud } from "~/utils/common"; + +const searchSchema = z.object({ + query: z + .string({ required_error: "Enter a team ID, name, domain, or member email" }) + .trim() + .min(1, "Enter a team ID, name, domain, or member email"), +}); + +type SearchInput = z.infer; + +type RouterOutputs = inferRouterOutputs; +type TeamAdmin = NonNullable; + +const updateSchema = z.object({ + apiRateLimit: z.coerce.number().int().min(1).max(10_000), + dailyEmailLimit: z.coerce.number().int().min(0).max(10_000_000), + isBlocked: z.boolean(), + plan: z.enum(["FREE", "BASIC"]), +}); + +type UpdateInput = z.infer; + +export default function AdminTeamsPage() { + const [team, setTeam] = useState(null); + const [hasSearched, setHasSearched] = useState(false); + + const searchForm = useForm({ + resolver: zodResolver(searchSchema), + defaultValues: { query: "" }, + }); + + const updateForm = useForm({ + resolver: zodResolver(updateSchema), + defaultValues: { + apiRateLimit: 1, + dailyEmailLimit: 0, + isBlocked: false, + plan: "FREE", + }, + }); + + useEffect(() => { + if (team) { + updateForm.reset({ + apiRateLimit: team.apiRateLimit, + dailyEmailLimit: team.dailyEmailLimit, + isBlocked: team.isBlocked, + plan: team.plan, + }); + } + }, [team, updateForm]); + + if (!isCloud()) { + return ( +
+ Team administration tools are available only in the cloud deployment. +
+ ); + } + + const findTeam = api.admin.findTeam.useMutation({ + onSuccess: (data) => { + setHasSearched(true); + if (!data) { + setTeam(null); + toast.info("No team found for that query"); + return; + } + setTeam(data); + }, + onError: (error) => { + toast.error(error.message ?? "Unable to search for team"); + }, + }); + + const updateTeam = api.admin.updateTeamSettings.useMutation({ + onSuccess: (updated) => { + setTeam(updated); + updateForm.reset({ + apiRateLimit: updated.apiRateLimit, + dailyEmailLimit: updated.dailyEmailLimit, + isBlocked: updated.isBlocked, + plan: updated.plan, + }); + toast.success("Team settings updated"); + }, + onError: (error) => { + toast.error(error.message ?? "Unable to update team settings"); + }, + }); + + const onSearchSubmit = (values: SearchInput) => { + setTeam(null); + setHasSearched(false); + findTeam.mutate(values); + }; + + const onUpdateSubmit = (values: UpdateInput) => { + if (!team) return; + updateTeam.mutate({ teamId: team.id, ...values }); + }; + + return ( +
+
+
+ + ( + + Team lookup + + + + + + )} + /> + + + +
+ + {findTeam.isPending ? null : hasSearched && !team ? ( +
+ No team matched that query. Try another search. +
+ ) : null} + + {team ? ( +
+
+
+

Team

+

{team.name}

+

+ ID #{team.id} • Created {formatDistanceToNow(new Date(team.createdAt), { addSuffix: true })} +

+
+
+ Plan: {team.plan} + + {team.isBlocked ? "Blocked" : "Active"} + +
+
+ +
+
+

Members

+
+ {team.teamUsers.length ? ( + team.teamUsers.map((member) => ( +
+
+

{member.user.name ?? member.user.email}

+

{member.user.email}

+
+ {member.role} +
+ )) + ) : ( +

No members found.

+ )} +
+
+
+

Domains

+
+ {team.domains.length ? ( + team.domains.map((domain) => ( +
+ {domain.name} + + {domain.status === "SUCCESS" + ? "Verified" + : domain.status.toLowerCase()} + +
+ )) + ) : ( +

No domains connected.

+ )} +
+
+
+ +
+

+ Billing contact: {team.billingEmail ?? "Not set"} +

+
+ +
+
+ + ( + + API rate limit + + + field.onChange(Number(event.target.value)) + } + disabled={updateTeam.isPending} + /> + + + + )} + /> + ( + + Daily email limit + + + field.onChange(Number(event.target.value)) + } + disabled={updateTeam.isPending} + /> + + + + )} + /> + ( + + Plan + + + + + + )} + /> + ( + + Blocked + +
+ + + {field.value ? "Team is blocked" : "Team is active"} + +
+
+ +
+ )} + /> +
+ +
+ + +
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/waitlist/page.tsx b/apps/web/src/app/(dashboard)/admin/waitlist/page.tsx new file mode 100644 index 0000000..9bc859c --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/waitlist/page.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useState } from "react"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { Button } from "@usesend/ui/src/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@usesend/ui/src/form"; +import { Input } from "@usesend/ui/src/input"; +import Spinner from "@usesend/ui/src/spinner"; +import { toast } from "@usesend/ui/src/toaster"; +import { Switch } from "@usesend/ui/src/switch"; +import { Badge } from "@usesend/ui/src/badge"; +import { formatDistanceToNow } from "date-fns"; + +import { api } from "~/trpc/react"; +import { isCloud } from "~/utils/common"; +import type { AppRouter } from "~/server/api/root"; +import type { inferRouterOutputs } from "@trpc/server"; + +const searchSchema = z.object({ + email: z + .string({ required_error: "Email is required" }) + .trim() + .email("Enter a valid email address"), +}); + +type SearchInput = z.infer; + +type RouterOutputs = inferRouterOutputs; +type WaitlistUser = NonNullable; + +export default function AdminWaitlistPage() { + const [userResult, setUserResult] = useState(null); + const [hasSearched, setHasSearched] = useState(false); + + const form = useForm({ + resolver: zodResolver(searchSchema), + defaultValues: { + email: "", + }, + }); + + const findUser = api.admin.findUserByEmail.useMutation({ + onSuccess: (data) => { + setHasSearched(true); + if (!data) { + setUserResult(null); + toast.info("No user found for that email"); + return; + } + + setUserResult(data); + }, + onError: (error) => { + toast.error(error.message ?? "Unable to search for user"); + }, + }); + + const updateWaitlist = api.admin.updateUserWaitlist.useMutation({ + onSuccess: (updated) => { + setUserResult(updated); + toast.success( + updated.isWaitlisted + ? "User marked as waitlisted" + : "User removed from waitlist", + ); + }, + onError: (error) => { + toast.error(error.message ?? "Unable to update waitlist flag"); + }, + }); + + const onSubmit = (values: SearchInput) => { + setHasSearched(false); + setUserResult(null); + findUser.mutate(values); + }; + + const handleToggle = (checked: boolean) => { + if (!userResult) return; + updateWaitlist.mutate({ userId: userResult.id, isWaitlisted: checked }); + }; + + if (!isCloud()) { + return ( +
+ Waitlist tooling is available only in the cloud deployment. +
+ ); + } + + return ( +
+
+
+ + ( + + User email + + + + + + )} + /> + + + +
+ + {findUser.isPending ? null : hasSearched && !userResult ? ( +
+ No user matched that email. Try another search. +
+ ) : null} + + {userResult ? ( +
+
+
+

Email

+

{userResult.email}

+
+ + {userResult.isWaitlisted ? "Waitlisted" : "Active"} + +
+ +
+
+

Name

+

{userResult.name ?? "—"}

+
+
+

Joined

+

+ {formatDistanceToNow(new Date(userResult.createdAt), { + addSuffix: true, + })} +

+
+
+ +
+
+

Waitlist access

+

+ Toggle to control whether the user remains on the waitlist. +

+
+
+ + {updateWaitlist.isPending ? ( + + ) : null} +
+
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/app/wait-list/page.tsx b/apps/web/src/app/wait-list/page.tsx index d913a48..891bc91 100644 --- a/apps/web/src/app/wait-list/page.tsx +++ b/apps/web/src/app/wait-list/page.tsx @@ -1,14 +1,34 @@ import { Rocket } from "lucide-react"; -export default async function Home() { +import { getServerAuthSession } from "~/server/auth"; +import { WaitListForm } from "./waitlist-form"; +import { redirect } from "next/navigation"; + +export default async function WaitListPage() { + const session = await getServerAuthSession(); + + if (!session?.user) { + redirect("/login"); + } + + const userEmail = session.user.email ?? ""; + return ( -
-
- -

You're on the Waitlist!

-

- Hang tight, we'll get to you as soon as possible. -

+
+
+
+ + + +
+

You're on the waitlist

+

+ Share a bit more context so we can prioritize your access. +

+
+
+ +
); diff --git a/apps/web/src/app/wait-list/schema.ts b/apps/web/src/app/wait-list/schema.ts new file mode 100644 index 0000000..a22b941 --- /dev/null +++ b/apps/web/src/app/wait-list/schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export const WAITLIST_EMAIL_TYPES = [ + "transactional", + "marketing", +] as const; + +export const waitlistSubmissionSchema = z.object({ + domain: z + .string({ required_error: "Domain is required" }) + .trim() + .min(1, "Domain is required") + .max(255, "Domain must be 255 characters or fewer"), + emailTypes: z + .array(z.enum(WAITLIST_EMAIL_TYPES)) + .min(1, "Select at least one email type"), + description: z + .string({ required_error: "Provide a short description" }) + .trim() + .min(10, "Please share a bit more detail") + .max(2000, "Description must be under 2000 characters"), +}); + +export type WaitlistSubmissionInput = z.infer; diff --git a/apps/web/src/app/wait-list/waitlist-form.tsx b/apps/web/src/app/wait-list/waitlist-form.tsx new file mode 100644 index 0000000..ba065d0 --- /dev/null +++ b/apps/web/src/app/wait-list/waitlist-form.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { Button } from "@usesend/ui/src/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@usesend/ui/src/form"; +import { Input } from "@usesend/ui/src/input"; +import { Textarea } from "@usesend/ui/src/textarea"; +import Spinner from "@usesend/ui/src/spinner"; +import { toast } from "@usesend/ui/src/toaster"; + +import { + WAITLIST_EMAIL_TYPES, + waitlistSubmissionSchema, + type WaitlistSubmissionInput, +} from "./schema"; +import { api } from "~/trpc/react"; +import { signOut } from "next-auth/react"; + +type WaitListFormProps = { + userEmail: string; +}; + +const EMAIL_TYPE_LABEL: Record<(typeof WAITLIST_EMAIL_TYPES)[number], string> = { + transactional: "Transactional", + marketing: "Marketing", +}; + +export function WaitListForm({ userEmail }: WaitListFormProps) { + const form = useForm({ + resolver: zodResolver(waitlistSubmissionSchema), + defaultValues: { + domain: "", + emailTypes: [], + description: "", + }, + }); + + const [isLoggingOut, setIsLoggingOut] = useState(false); + + const submitRequest = api.waitlist.submitRequest.useMutation({ + onSuccess: () => { + toast.success("Thanks! We'll reach out shortly."); + form.reset(); + }, + onError: (error) => { + toast.error(error.message ?? "Something went wrong"); + }, + }); + + const onSubmit = (values: WaitlistSubmissionInput) => { + submitRequest.mutate(values); + }; + + const handleLogout = () => { + setIsLoggingOut(true); + signOut({ callbackUrl: "/login" }).catch(() => { + setIsLoggingOut(false); + toast.error("Unable to log out. Please try again."); + }); + }; + + return ( +
+ + ( + + Primary domain + + + + + + )} + /> + +
+

Contact email

+

+ {userEmail || "Unknown"} +

+
+ + { + const selected = field.value ?? []; + const handleToggle = ( + option: (typeof WAITLIST_EMAIL_TYPES)[number] + ) => { + if (selected.includes(option)) { + field.onChange(selected.filter((value) => value !== option)); + } else { + field.onChange([...selected, option]); + } + }; + + return ( + + What emails do you plan to send? + +
+ {WAITLIST_EMAIL_TYPES.map((option) => { + const checked = selected.includes(option); + return ( + + ); + })} +
+
+ +
+ ); + }} + /> + + ( + + What kind of emails will you send? + +