add waitlist submission form (#238)
This commit is contained in:
@@ -29,6 +29,10 @@
|
|||||||
- Paths (web): use alias `~/` for src imports (e.g., `import { x } from "~/utils/x"`).
|
- Paths (web): use alias `~/` for src imports (e.g., `import { x } from "~/utils/x"`).
|
||||||
- Never use dynamic imports
|
- Never use dynamic imports
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Prefer to use trpc alway unless asked otherwise
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
|
|
||||||
- No repo-wide test runner is configured yet. do not add any tests unless required
|
- No repo-wide test runner is configured yet. do not add any tests unless required
|
||||||
|
32
apps/web/src/app/(dashboard)/admin/layout.tsx
Normal file
32
apps/web/src/app/(dashboard)/admin/layout.tsx
Normal file
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold">Admin</h1>
|
||||||
|
<div className="mt-4 flex gap-4">
|
||||||
|
<SettingsNavButton href="/admin">
|
||||||
|
SES Configurations
|
||||||
|
</SettingsNavButton>
|
||||||
|
{isCloud() ? (
|
||||||
|
<SettingsNavButton href="/admin/teams">
|
||||||
|
Teams
|
||||||
|
</SettingsNavButton>
|
||||||
|
) : null}
|
||||||
|
{isCloud() ? (
|
||||||
|
<SettingsNavButton href="/admin/waitlist">
|
||||||
|
Waitlist
|
||||||
|
</SettingsNavButton>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-8">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -2,19 +2,15 @@
|
|||||||
|
|
||||||
import AddSesConfiguration from "./add-ses-configuration";
|
import AddSesConfiguration from "./add-ses-configuration";
|
||||||
import SesConfigurations from "./ses-configurations";
|
import SesConfigurations from "./ses-configurations";
|
||||||
import { H1 } from "@usesend/ui";
|
|
||||||
|
|
||||||
export default function ApiKeysPage() {
|
export default function AdminSesPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex items-center justify-between">
|
||||||
<H1>Admin</H1>
|
<h2 className="text-xl font-semibold">SES Configurations</h2>
|
||||||
<AddSesConfiguration />
|
<AddSesConfiguration />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-10">
|
<SesConfigurations />
|
||||||
<p className="font-semibold mb-4">SES Configurations</p>
|
|
||||||
<SesConfigurations />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
366
apps/web/src/app/(dashboard)/admin/teams/page.tsx
Normal file
366
apps/web/src/app/(dashboard)/admin/teams/page.tsx
Normal file
@@ -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<typeof searchSchema>;
|
||||||
|
|
||||||
|
type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||||
|
type TeamAdmin = NonNullable<RouterOutputs["admin"]["findTeam"]>;
|
||||||
|
|
||||||
|
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<typeof updateSchema>;
|
||||||
|
|
||||||
|
export default function AdminTeamsPage() {
|
||||||
|
const [team, setTeam] = useState<TeamAdmin | null>(null);
|
||||||
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
|
||||||
|
const searchForm = useForm<SearchInput>({
|
||||||
|
resolver: zodResolver(searchSchema),
|
||||||
|
defaultValues: { query: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateForm = useForm<UpdateInput>({
|
||||||
|
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 (
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
|
||||||
|
Team administration tools are available only in the cloud deployment.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="rounded-lg border p-6 shadow-sm">
|
||||||
|
<Form {...searchForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={searchForm.handleSubmit(onSearchSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={searchForm.control}
|
||||||
|
name="query"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Team lookup</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Team ID, team name, domain, or member email"
|
||||||
|
autoComplete="off"
|
||||||
|
{...field}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={findTeam.isPending}>
|
||||||
|
{findTeam.isPending ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="mr-2 h-4 w-4" /> Searching...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Lookup team"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{findTeam.isPending ? null : hasSearched && !team ? (
|
||||||
|
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
|
||||||
|
No team matched that query. Try another search.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{team ? (
|
||||||
|
<div className="space-y-6 rounded-lg border p-6 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Team</p>
|
||||||
|
<p className="text-xl font-semibold">{team.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
ID #{team.id} • Created {formatDistanceToNow(new Date(team.createdAt), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">Plan: {team.plan}</Badge>
|
||||||
|
<Badge variant={team.isBlocked ? "destructive" : "outline"}>
|
||||||
|
{team.isBlocked ? "Blocked" : "Active"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">Members</h3>
|
||||||
|
<div className="space-y-2 rounded-lg border bg-muted/20 p-3">
|
||||||
|
{team.teamUsers.length ? (
|
||||||
|
team.teamUsers.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.user.id}
|
||||||
|
className="flex items-center justify-between rounded-md bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{member.user.name ?? member.user.email}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{member.user.email}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{member.role}</Badge>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">No members found.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">Domains</h3>
|
||||||
|
<div className="space-y-2 rounded-lg border bg-muted/20 p-3">
|
||||||
|
{team.domains.length ? (
|
||||||
|
team.domains.map((domain) => (
|
||||||
|
<div
|
||||||
|
key={domain.id}
|
||||||
|
className="flex items-center justify-between rounded-md bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span>{domain.name}</span>
|
||||||
|
<Badge variant={domain.status === "SUCCESS" ? "outline" : "secondary"}>
|
||||||
|
{domain.status === "SUCCESS"
|
||||||
|
? "Verified"
|
||||||
|
: domain.status.toLowerCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">No domains connected.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-muted/10 p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Billing contact: {team.billingEmail ?? "Not set"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-6">
|
||||||
|
<Form {...updateForm}>
|
||||||
|
<form onSubmit={updateForm.handleSubmit(onUpdateSubmit)} className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={updateForm.control}
|
||||||
|
name="apiRateLimit"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>API rate limit</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10000}
|
||||||
|
{...field}
|
||||||
|
value={Number.isNaN(field.value) ? 1 : field.value}
|
||||||
|
onChange={(event) =>
|
||||||
|
field.onChange(Number(event.target.value))
|
||||||
|
}
|
||||||
|
disabled={updateTeam.isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={updateForm.control}
|
||||||
|
name="dailyEmailLimit"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Daily email limit</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={10_000_000}
|
||||||
|
{...field}
|
||||||
|
value={Number.isNaN(field.value) ? 0 : field.value}
|
||||||
|
onChange={(event) =>
|
||||||
|
field.onChange(Number(event.target.value))
|
||||||
|
}
|
||||||
|
disabled={updateTeam.isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={updateForm.control}
|
||||||
|
name="plan"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Plan</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
disabled={updateTeam.isPending}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select plan" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="FREE">Free</SelectItem>
|
||||||
|
<SelectItem value="BASIC">Basic</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={updateForm.control}
|
||||||
|
name="isBlocked"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Blocked</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center gap-3 rounded-md border px-3 py-2">
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
disabled={updateTeam.isPending}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{field.value ? "Team is blocked" : "Team is active"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="lg:col-span-2 flex justify-end">
|
||||||
|
<Button type="submit" disabled={updateTeam.isPending}>
|
||||||
|
{updateTeam.isPending ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="mr-2 h-4 w-4" /> Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Update team"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
191
apps/web/src/app/(dashboard)/admin/waitlist/page.tsx
Normal file
191
apps/web/src/app/(dashboard)/admin/waitlist/page.tsx
Normal file
@@ -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<typeof searchSchema>;
|
||||||
|
|
||||||
|
type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||||
|
type WaitlistUser = NonNullable<RouterOutputs["admin"]["findUserByEmail"]>;
|
||||||
|
|
||||||
|
export default function AdminWaitlistPage() {
|
||||||
|
const [userResult, setUserResult] = useState<WaitlistUser | null>(null);
|
||||||
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<SearchInput>({
|
||||||
|
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 (
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
|
||||||
|
Waitlist tooling is available only in the cloud deployment.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="rounded-lg border p-6 shadow-sm">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>User email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="user@example.com"
|
||||||
|
autoComplete="off"
|
||||||
|
{...field}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={findUser.isPending}>
|
||||||
|
{findUser.isPending ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="mr-2 h-4 w-4" /> Searching...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Lookup user"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{findUser.isPending ? null : hasSearched && !userResult ? (
|
||||||
|
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
|
||||||
|
No user matched that email. Try another search.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{userResult ? (
|
||||||
|
<div className="space-y-4 rounded-lg border p-6 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Email</p>
|
||||||
|
<p className="text-base font-medium">{userResult.email}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={userResult.isWaitlisted ? "destructive" : "outline"}>
|
||||||
|
{userResult.isWaitlisted ? "Waitlisted" : "Active"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Name</p>
|
||||||
|
<p>{userResult.name ?? "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Joined</p>
|
||||||
|
<p>
|
||||||
|
{formatDistanceToNow(new Date(userResult.createdAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 border-t pt-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Waitlist access</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Toggle to control whether the user remains on the waitlist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={userResult.isWaitlisted}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
disabled={updateWaitlist.isPending}
|
||||||
|
/>
|
||||||
|
{updateWaitlist.isPending ? (
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,14 +1,34 @@
|
|||||||
import { Rocket } from "lucide-react";
|
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 (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen ">
|
<div className="flex min-h-screen items-center justify-center px-4 py-8">
|
||||||
<div className="p-8 shadow-lg rounded-lg flex flex-col gap-4">
|
<div className="flex w-full max-w-xl flex-col gap-6 rounded-lg border bg-card p-8 shadow-lg">
|
||||||
<Rocket />
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold">You're on the Waitlist!</h1>
|
<span className="rounded-full bg-primary/10 p-2 text-primary">
|
||||||
<p className=" text-secondary-muted">
|
<Rocket className="h-5 w-5" />
|
||||||
Hang tight, we'll get to you as soon as possible.
|
</span>
|
||||||
</p>
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">You're on the waitlist</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Share a bit more context so we can prioritize your access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WaitListForm userEmail={userEmail} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
24
apps/web/src/app/wait-list/schema.ts
Normal file
24
apps/web/src/app/wait-list/schema.ts
Normal file
@@ -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<typeof waitlistSubmissionSchema>;
|
201
apps/web/src/app/wait-list/waitlist-form.tsx
Normal file
201
apps/web/src/app/wait-list/waitlist-form.tsx
Normal file
@@ -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<WaitlistSubmissionInput>({
|
||||||
|
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 (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-6"
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="domain"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Primary domain</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="acme.com"
|
||||||
|
autoComplete="off"
|
||||||
|
{...field}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Contact email</p>
|
||||||
|
<p className="mt-1 rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||||
|
{userEmail || "Unknown"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="emailTypes"
|
||||||
|
render={({ field }) => {
|
||||||
|
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 (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>What emails do you plan to send?</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
{WAITLIST_EMAIL_TYPES.map((option) => {
|
||||||
|
const checked = selected.includes(option);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={option}
|
||||||
|
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-sm transition hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-muted-foreground/40"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => handleToggle(option)}
|
||||||
|
/>
|
||||||
|
<span>{EMAIL_TYPE_LABEL[option]}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>What kind of emails will you send?</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
rows={4}
|
||||||
|
placeholder="Share a quick summary so we can prioritize your access"
|
||||||
|
{...field}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
We'll come back usually within 4 hours.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={isLoggingOut}
|
||||||
|
>
|
||||||
|
{isLoggingOut ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="mr-2 h-4 w-4" /> Logging out...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Log out"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={submitRequest.isPending}>
|
||||||
|
{submitRequest.isPending ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="mr-2 h-4 w-4" /> Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Request Access"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
@@ -50,6 +50,7 @@ export const env = createEnv({
|
|||||||
.transform((str) => parseInt(str, 10)),
|
.transform((str) => parseInt(str, 10)),
|
||||||
FROM_EMAIL: z.string().optional(),
|
FROM_EMAIL: z.string().optional(),
|
||||||
ADMIN_EMAIL: z.string().optional(),
|
ADMIN_EMAIL: z.string().optional(),
|
||||||
|
FOUNDER_EMAIL: z.string().optional(),
|
||||||
DISCORD_WEBHOOK_URL: z.string().optional(),
|
DISCORD_WEBHOOK_URL: z.string().optional(),
|
||||||
REDIS_URL: z.string(),
|
REDIS_URL: z.string(),
|
||||||
S3_COMPATIBLE_ACCESS_KEY: z.string().optional(),
|
S3_COMPATIBLE_ACCESS_KEY: z.string().optional(),
|
||||||
@@ -103,6 +104,7 @@ export const env = createEnv({
|
|||||||
AUTH_EMAIL_RATE_LIMIT: process.env.AUTH_EMAIL_RATE_LIMIT,
|
AUTH_EMAIL_RATE_LIMIT: process.env.AUTH_EMAIL_RATE_LIMIT,
|
||||||
NEXT_PUBLIC_IS_CLOUD: process.env.NEXT_PUBLIC_IS_CLOUD,
|
NEXT_PUBLIC_IS_CLOUD: process.env.NEXT_PUBLIC_IS_CLOUD,
|
||||||
ADMIN_EMAIL: process.env.ADMIN_EMAIL,
|
ADMIN_EMAIL: process.env.ADMIN_EMAIL,
|
||||||
|
FOUNDER_EMAIL: process.env.FOUNDER_EMAIL,
|
||||||
DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL,
|
DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL,
|
||||||
REDIS_URL: process.env.REDIS_URL,
|
REDIS_URL: process.env.REDIS_URL,
|
||||||
FROM_EMAIL: process.env.FROM_EMAIL,
|
FROM_EMAIL: process.env.FROM_EMAIL,
|
||||||
|
@@ -5,8 +5,9 @@ import React from "react";
|
|||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { SessionProvider, useSession } from "next-auth/react";
|
import { SessionProvider, useSession } from "next-auth/react";
|
||||||
import LoginPage from "~/app/login/login-page";
|
import LoginPage from "~/app/login/login-page";
|
||||||
import { Rocket } from "lucide-react";
|
|
||||||
import { FullScreenLoading } from "~/components/FullScreenLoading";
|
import { FullScreenLoading } from "~/components/FullScreenLoading";
|
||||||
|
import { Rocket } from "lucide-react";
|
||||||
|
import { WaitListForm } from "~/app/wait-list/waitlist-form";
|
||||||
|
|
||||||
export type NextAuthProviderProps = {
|
export type NextAuthProviderProps = {
|
||||||
session?: Session | null | undefined;
|
session?: Session | null | undefined;
|
||||||
@@ -37,13 +38,21 @@ const AppAuthProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
|
|
||||||
if (session.user.isWaitlisted) {
|
if (session.user.isWaitlisted) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen ">
|
<div className="flex min-h-screen items-center justify-center px-4 py-8">
|
||||||
<div className="p-8 shadow-lg rounded-lg flex flex-col gap-4">
|
<div className="flex w-full max-w-xl flex-col gap-6 rounded-2xl border bg-card p-8 shadow-lg">
|
||||||
<Rocket />
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold">You're on the Waitlist!</h1>
|
<span className="rounded-full bg-primary/10 p-2 text-primary">
|
||||||
<p className=" text-secondary-muted">
|
<Rocket className="h-5 w-5" />
|
||||||
Hang tight, we'll get to you as soon as possible.
|
</span>
|
||||||
</p>
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">You're on the waitlist</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Share a bit more context so we can prioritize your access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WaitListForm userEmail={session.user.email ?? ""} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@@ -12,6 +12,7 @@ import { invitationRouter } from "./routers/invitiation";
|
|||||||
import { dashboardRouter } from "./routers/dashboard";
|
import { dashboardRouter } from "./routers/dashboard";
|
||||||
import { suppressionRouter } from "./routers/suppression";
|
import { suppressionRouter } from "./routers/suppression";
|
||||||
import { limitsRouter } from "./routers/limits";
|
import { limitsRouter } from "./routers/limits";
|
||||||
|
import { waitlistRouter } from "./routers/waitlist";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
@@ -32,6 +33,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
dashboard: dashboardRouter,
|
dashboard: dashboardRouter,
|
||||||
suppression: suppressionRouter,
|
suppression: suppressionRouter,
|
||||||
limits: limitsRouter,
|
limits: limitsRouter,
|
||||||
|
waitlist: waitlistRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
@@ -4,6 +4,46 @@ import { env } from "~/env";
|
|||||||
import { createTRPCRouter, adminProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, adminProcedure } from "~/server/api/trpc";
|
||||||
import { SesSettingsService } from "~/server/service/ses-settings-service";
|
import { SesSettingsService } from "~/server/service/ses-settings-service";
|
||||||
import { getAccount } from "~/server/aws/ses";
|
import { getAccount } from "~/server/aws/ses";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
|
||||||
|
const waitlistUserSelection = {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
isWaitlisted: true,
|
||||||
|
createdAt: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const teamAdminSelection = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
plan: true,
|
||||||
|
apiRateLimit: true,
|
||||||
|
dailyEmailLimit: true,
|
||||||
|
isBlocked: true,
|
||||||
|
billingEmail: true,
|
||||||
|
createdAt: true,
|
||||||
|
teamUsers: {
|
||||||
|
select: {
|
||||||
|
role: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
domains: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
isVerifying: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const adminRouter = createTRPCRouter({
|
export const adminRouter = createTRPCRouter({
|
||||||
getSesSettings: adminProcedure.query(async () => {
|
getSesSettings: adminProcedure.query(async () => {
|
||||||
@@ -66,4 +106,116 @@ export const adminRouter = createTRPCRouter({
|
|||||||
input.region ?? env.AWS_DEFAULT_REGION,
|
input.region ?? env.AWS_DEFAULT_REGION,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
findUserByEmail: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.transform((value) => value.toLowerCase()),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { email: input.email },
|
||||||
|
select: waitlistUserSelection,
|
||||||
|
});
|
||||||
|
|
||||||
|
return user ?? null;
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateUserWaitlist: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
userId: z.number(),
|
||||||
|
isWaitlisted: z.boolean(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const updatedUser = await db.user.update({
|
||||||
|
where: { id: input.userId },
|
||||||
|
data: { isWaitlisted: input.isWaitlisted },
|
||||||
|
select: waitlistUserSelection,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedUser;
|
||||||
|
}),
|
||||||
|
|
||||||
|
findTeam: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
query: z
|
||||||
|
.string({ required_error: "Search query is required" })
|
||||||
|
.trim()
|
||||||
|
.min(1, "Search query is required"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const query = input.query.trim();
|
||||||
|
|
||||||
|
let numericId: number | null = null;
|
||||||
|
if (/^\d+$/.test(query)) {
|
||||||
|
numericId = Number(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
let team = numericId
|
||||||
|
? await db.team.findUnique({
|
||||||
|
where: { id: numericId },
|
||||||
|
select: teamAdminSelection,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
team = await db.team.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ name: { equals: query, mode: "insensitive" } },
|
||||||
|
{ billingEmail: { equals: query, mode: "insensitive" } },
|
||||||
|
{
|
||||||
|
teamUsers: {
|
||||||
|
some: {
|
||||||
|
user: {
|
||||||
|
email: { equals: query, mode: "insensitive" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domains: {
|
||||||
|
some: {
|
||||||
|
name: { equals: query, mode: "insensitive" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: teamAdminSelection,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return team ?? null;
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateTeamSettings: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
teamId: z.number(),
|
||||||
|
apiRateLimit: z.number().int().min(1).max(10_000),
|
||||||
|
dailyEmailLimit: z.number().int().min(0).max(10_000_000),
|
||||||
|
isBlocked: z.boolean(),
|
||||||
|
plan: z.enum(["FREE", "BASIC"]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const { teamId, ...data } = input;
|
||||||
|
|
||||||
|
const updatedTeam = await db.team.update({
|
||||||
|
where: { id: teamId },
|
||||||
|
data,
|
||||||
|
select: teamAdminSelection,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedTeam;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
107
apps/web/src/server/api/routers/waitlist.ts
Normal file
107
apps/web/src/server/api/routers/waitlist.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { authedProcedure, createTRPCRouter } from "~/server/api/trpc";
|
||||||
|
import { logger } from "~/server/logger/log";
|
||||||
|
import { sendMail } from "~/server/mailer";
|
||||||
|
import { getRedis } from "~/server/redis";
|
||||||
|
import {
|
||||||
|
WAITLIST_EMAIL_TYPES,
|
||||||
|
waitlistSubmissionSchema,
|
||||||
|
} from "~/app/wait-list/schema";
|
||||||
|
|
||||||
|
const RATE_LIMIT_WINDOW_SECONDS = 60 * 60 * 6; // 6 hours
|
||||||
|
const RATE_LIMIT_MAX_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
const EMAIL_TYPE_LABEL: Record<(typeof WAITLIST_EMAIL_TYPES)[number], string> = {
|
||||||
|
transactional: "Transactional",
|
||||||
|
marketing: "Marketing",
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(input: string) {
|
||||||
|
return input
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const waitlistRouter = createTRPCRouter({
|
||||||
|
submitRequest: authedProcedure
|
||||||
|
.input(waitlistSubmissionSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
if (!ctx.session || !ctx.session.user) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = ctx.session;
|
||||||
|
|
||||||
|
const founderEmail = env.FOUNDER_EMAIL ?? env.ADMIN_EMAIL;
|
||||||
|
|
||||||
|
if (!founderEmail) {
|
||||||
|
logger.error("FOUNDER_EMAIL/ADMIN_EMAIL is not configured; skipping waitlist notification");
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Waitlist notifications are not configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const redis = getRedis();
|
||||||
|
const rateKey = `waitlist:requests:${user.id}`;
|
||||||
|
|
||||||
|
const currentCountRaw = await redis.get(rateKey);
|
||||||
|
const currentCount = currentCountRaw ? Number(currentCountRaw) : 0;
|
||||||
|
|
||||||
|
if (Number.isNaN(currentCount)) {
|
||||||
|
logger.warn({ currentCountRaw }, "Unexpected rate limit counter value");
|
||||||
|
} else if (currentCount >= RATE_LIMIT_MAX_ATTEMPTS) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "TOO_MANY_REQUESTS",
|
||||||
|
message: "You have reached the waitlist request limit. Please try later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipeline = redis.multi();
|
||||||
|
pipeline.incr(rateKey);
|
||||||
|
if (!currentCountRaw) {
|
||||||
|
pipeline.expire(rateKey, RATE_LIMIT_WINDOW_SECONDS);
|
||||||
|
}
|
||||||
|
await pipeline.exec();
|
||||||
|
|
||||||
|
const typesLabel = input.emailTypes
|
||||||
|
.map((type) => EMAIL_TYPE_LABEL[type])
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const escapedDescription = escapeHtml(input.description);
|
||||||
|
const escapedDomain = escapeHtml(input.domain);
|
||||||
|
const subject = `Waitlist request from ${user.email ?? "unknown user"}`;
|
||||||
|
|
||||||
|
const textBody = `A waitlisted user submitted a request:\n\nEmail: ${
|
||||||
|
user.email ?? "Unknown"
|
||||||
|
}\nDomain: ${input.domain}\nInterested emails: ${typesLabel}\n\nDescription:\n${input.description}`;
|
||||||
|
|
||||||
|
const htmlBody = `
|
||||||
|
<p>A waitlisted user submitted a request.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Email:</strong> ${escapeHtml(user.email ?? "Unknown")}</li>
|
||||||
|
<li><strong>Domain:</strong> ${escapedDomain}</li>
|
||||||
|
<li><strong>Interested emails:</strong> ${escapeHtml(typesLabel)}</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Description</strong></p>
|
||||||
|
<p style="white-space: pre-wrap;">${escapedDescription}</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendMail(founderEmail, subject, textBody, htmlBody, user.email ?? undefined);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
},
|
||||||
|
"Waitlist request submitted"
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
});
|
@@ -90,6 +90,24 @@ export const createTRPCRouter = t.router;
|
|||||||
*/
|
*/
|
||||||
export const publicProcedure = t.procedure;
|
export const publicProcedure = t.procedure;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated (session-required) procedure
|
||||||
|
*
|
||||||
|
* Ensures a session exists but does not enforce waitlist status. Useful for flows where waitlisted
|
||||||
|
* users should still have access (e.g., waitlist management).
|
||||||
|
*/
|
||||||
|
export const authedProcedure = t.procedure.use(({ ctx, next }) => {
|
||||||
|
if (!ctx.session || !ctx.session.user) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
session: { ...ctx.session, user: ctx.session.user },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Protected (authenticated) procedure
|
* Protected (authenticated) procedure
|
||||||
*
|
*
|
||||||
@@ -98,17 +116,12 @@ export const publicProcedure = t.procedure;
|
|||||||
*
|
*
|
||||||
* @see https://trpc.io/docs/procedures
|
* @see https://trpc.io/docs/procedures
|
||||||
*/
|
*/
|
||||||
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
|
export const protectedProcedure = authedProcedure.use(({ ctx, next }) => {
|
||||||
if (!ctx.session || !ctx.session.user || ctx.session.user.isWaitlisted) {
|
if (ctx.session.user.isWaitlisted) {
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return next({
|
return next();
|
||||||
ctx: {
|
|
||||||
// infers the `session` as non-nullable
|
|
||||||
session: { ...ctx.session, user: ctx.session.user },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const teamProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
export const teamProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
||||||
|
Reference in New Issue
Block a user