feat(limits): admin queue + customer request form + ToS clause

Three surfaces close the loop on the limit-increase workflow:

  - admin/dashboard/LimitRequestsPage.tsx queues every pending request
    with full context (org → users → field → current vs requested →
    +delta) and one-click approve/reject. Both actions open a review
    dialog; approve notes are optional, reject notes are required and
    surface to the customer.
  - web/settings/limits/page.tsx is the customer-facing form. Resource
    selector, requested value, reason textarea, plus a list of every
    past request with its status (pending/approved/rejected/cancelled)
    and the reviewer's notes when present. Pending rows expose a
    cancel link. Footer links to the ToS limits clause.
  - site/terms.astro grows a new section 07 ("Usage limits and
    increase requests"). Explicit: "unlimited" means no plan-tier cap
    but a product-wide hard ceiling still applies, increases are at
    Warmbly's sole discretion, and previously granted increases can be
    revoked when reputation signals deteriorate. Bumps every existing
    section heading and id from 07 onward.

Admin sidebar grows a "Limit requests" entry under Accounts (Gauge
icon). Web settings layout grows a "Limits" section under owner-only
sections.
This commit is contained in:
Matt
2026-05-28 12:16:02 +02:00
parent e6753f7c24
commit 93bb56a458
13 changed files with 769 additions and 22 deletions
@@ -0,0 +1,343 @@
// Limit-increase request queue. Pending requests are the default
// view — approve writes the corresponding override on the org via the
// same SetLimitOverrides path the manual editor uses; reject stamps
// the row with required review notes.
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { CheckCircle2, XCircle } from "lucide-react";
import { PageHeader } from "@/components/layout/PageHeader";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
approveLimitRequest,
listLimitRequests,
rejectLimitRequest,
} from "@/lib/api/client/admin/limitRequests";
import type {
LimitIncreaseRequest,
LimitRequestStatus,
} from "@/lib/api/models/admin";
type StatusFilter = LimitRequestStatus | "all";
const STATUS_TONE: Record<LimitRequestStatus, string> = {
pending: "border-amber-300 text-amber-700 bg-amber-50",
approved: "border-emerald-300 text-emerald-700 bg-emerald-50",
rejected: "border-red-300 text-red-700 bg-red-50",
cancelled: "border-zinc-300 text-zinc-600 bg-zinc-50",
};
const FIELD_LABEL: Record<string, string> = {
max_email_accounts: "Mailboxes",
max_campaigns: "Campaigns (lifetime)",
max_active_campaigns: "Active campaigns",
max_team_members: "Team members",
max_contacts: "Contacts",
daily_campaign_limit: "Daily sends",
};
export default function LimitRequestsPage() {
const [status, setStatus] = useState<StatusFilter>("pending");
const [reviewing, setReviewing] = useState<{
req: LimitIncreaseRequest;
mode: "approve" | "reject";
} | null>(null);
const { data, isLoading, error } = useQuery({
queryKey: ["admin", "limit-requests", status],
queryFn: () => listLimitRequests(status),
refetchInterval: 30_000,
});
const rows = data?.data ?? [];
return (
<div>
<PageHeader
title="Limit-increase requests"
description="Customer-submitted requests for more capacity than their plan or product hard cap allows. Approving rewrites the per-org override; rejecting stamps the row with notes."
>
<StatusToggle value={status} onChange={setStatus} />
</PageHeader>
{isLoading && <Skeleton className="h-32 w-full" />}
{error && (
<div className="text-sm text-red-600 border border-red-200 bg-red-50 rounded-md p-3">
Failed to load limit requests.
</div>
)}
{!isLoading && !error && rows.length === 0 && (
<div className="text-sm text-muted-foreground border border-border rounded-md p-4 bg-card">
No {status === "all" ? "" : status} requests.
</div>
)}
{rows.length > 0 && (
<div className="border border-border rounded-lg overflow-hidden bg-card">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-muted-foreground text-xs uppercase">
<tr>
<th className="text-left px-3 py-2 font-medium">Workspace</th>
<th className="text-left px-3 py-2 font-medium">Requester</th>
<th className="text-left px-3 py-2 font-medium">Field</th>
<th className="text-right px-3 py-2 font-medium">Current</th>
<th className="text-right px-3 py-2 font-medium">Requested</th>
<th className="text-left px-3 py-2 font-medium">Reason</th>
<th className="text-left px-3 py-2 font-medium">Status</th>
<th className="text-left px-3 py-2 font-medium">Submitted</th>
<th className="text-right px-3 py-2 font-medium">Action</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<RequestRow
key={r.id}
req={r}
onApprove={() => setReviewing({ req: r, mode: "approve" })}
onReject={() => setReviewing({ req: r, mode: "reject" })}
/>
))}
</tbody>
</table>
</div>
)}
{reviewing && (
<ReviewDialog
req={reviewing.req}
mode={reviewing.mode}
open
onOpenChange={(v) => !v && setReviewing(null)}
/>
)}
</div>
);
}
function RequestRow({
req,
onApprove,
onReject,
}: {
req: LimitIncreaseRequest;
onApprove: () => void;
onReject: () => void;
}) {
const fieldLabel = FIELD_LABEL[req.field] ?? req.field;
const canReview = req.status === "pending";
const delta = req.requested - req.current_effective;
return (
<tr className="border-t border-border align-top">
<td className="px-3 py-2">
{req.organization && (
<Link
to={`/organizations/${req.organization_id}`}
className="text-[var(--admin-accent-strong)] hover:underline font-medium"
>
{req.organization.name}
</Link>
)}
</td>
<td className="px-3 py-2 text-xs">
{req.submitted_by_user?.email ?? req.submitted_by}
</td>
<td className="px-3 py-2 text-xs">{fieldLabel}</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">
{req.current_effective.toLocaleString()}
</td>
<td className="px-3 py-2 text-right tabular-nums font-medium">
{req.requested.toLocaleString()}
<span className="text-[10px] text-emerald-600 ml-1">
(+{delta.toLocaleString()})
</span>
</td>
<td className="px-3 py-2 text-xs max-w-md truncate" title={req.reason}>
{req.reason}
</td>
<td className="px-3 py-2">
<Badge variant="outline" className={`text-[10px] ${STATUS_TONE[req.status]}`}>
{req.status}
</Badge>
{req.review_notes && req.status !== "pending" && (
<div className="text-[10px] text-muted-foreground mt-1 max-w-xs truncate"
title={req.review_notes}>
"{req.review_notes}"
</div>
)}
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{new Date(req.submitted_at).toLocaleDateString()}
</td>
<td className="px-3 py-2 text-right space-x-1.5 whitespace-nowrap">
<Button
size="sm"
onClick={onApprove}
disabled={!canReview}
className="bg-emerald-600 hover:bg-emerald-700 text-white text-xs disabled:bg-zinc-200"
>
<CheckCircle2 className="size-3" /> Approve
</Button>
<Button
size="sm"
onClick={onReject}
disabled={!canReview}
className="bg-red-600 hover:bg-red-700 text-white text-xs disabled:bg-zinc-200"
>
<XCircle className="size-3" /> Reject
</Button>
</td>
</tr>
);
}
function StatusToggle({
value,
onChange,
}: {
value: StatusFilter;
onChange: (v: StatusFilter) => void;
}) {
const options: { value: StatusFilter; label: string }[] = [
{ value: "pending", label: "Pending" },
{ value: "approved", label: "Approved" },
{ value: "rejected", label: "Rejected" },
{ value: "cancelled", label: "Cancelled" },
{ value: "all", label: "All" },
];
return (
<div className="inline-flex rounded-md border border-border bg-card p-0.5 text-xs">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={`px-2 py-1 rounded ${
value === opt.value
? "bg-[var(--admin-accent)] text-white"
: "text-muted-foreground hover:text-foreground"
}`}
>
{opt.label}
</button>
))}
</div>
);
}
function ReviewDialog({
req,
mode,
open,
onOpenChange,
}: {
req: LimitIncreaseRequest;
mode: "approve" | "reject";
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const qc = useQueryClient();
const [notes, setNotes] = useState("");
const mutation = useMutation({
mutationFn: () =>
mode === "approve"
? approveLimitRequest(req.id, notes)
: rejectLimitRequest(req.id, notes),
onSuccess: () => {
toast.success(`Request ${mode === "approve" ? "approved" : "rejected"}`);
qc.invalidateQueries({ queryKey: ["admin", "limit-requests"] });
qc.invalidateQueries({ queryKey: ["admin", "organizations", req.organization_id] });
onOpenChange(false);
},
onError: (err: Error) => toast.error(err.message || "Action failed"),
});
const fieldLabel = FIELD_LABEL[req.field] ?? req.field;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{mode === "approve" ? "Approve request" : "Reject request"}
</DialogTitle>
<DialogDescription>
{mode === "approve" ? (
<>
Approving raises <strong>{fieldLabel}</strong> for{" "}
<span className="font-mono">
{req.organization?.name ?? req.organization_id}
</span>{" "}
from {req.current_effective.toLocaleString()} to{" "}
{req.requested.toLocaleString()}. This writes the
corresponding override on the org and is auditable.
</>
) : (
<>
Rejecting the request stamps it with your notes for the
customer to see. The org keeps its current effective
limit ({req.current_effective.toLocaleString()}).
</>
)}
</DialogDescription>
</DialogHeader>
<div>
<Label htmlFor="notes" className="text-xs font-medium">
Notes {mode === "reject" ? "(required)" : "(optional)"}
</Label>
<Input
id="notes"
placeholder={
mode === "approve"
? "Optional: business reason for the bump"
: "Required: tell the customer why"
}
value={notes}
onChange={(e) => setNotes(e.target.value)}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => {
if (mode === "reject" && notes.trim() === "") {
toast.error("Notes are required when rejecting");
return;
}
mutation.mutate();
}}
disabled={mutation.isPending}
className={
mode === "approve"
? "bg-emerald-600 hover:bg-emerald-700 text-white"
: "bg-red-600 hover:bg-red-700 text-white"
}
>
{mutation.isPending
? "Working…"
: mode === "approve"
? "Approve"
: "Reject"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+2
View File
@@ -15,6 +15,7 @@ import {
Database,
FileText,
Flame,
Gauge,
HardDrive,
Inbox,
LayoutDashboard,
@@ -66,6 +67,7 @@ const GROUPS: NavGroup[] = [
items: [
{ to: "/users", label: "Users", icon: Users },
{ to: "/organizations", label: "Organizations", icon: Building2 },
{ to: "/limit-requests", label: "Limit requests", icon: Gauge },
{ to: "/plans", label: "Plans & Billing", icon: CreditCard },
{ to: "/enterprise", label: "Enterprise", icon: Briefcase },
],
@@ -0,0 +1,46 @@
// /admin/limit-requests — review queue for customer-submitted limit
// increase requests.
import { Request } from "@/lib/api/client";
import type {
LimitIncreaseRequest,
LimitRequestStatus,
} from "@/lib/api/models/admin";
export function listLimitRequests(
status: LimitRequestStatus | "all" = "pending",
limit = 50,
): Promise<{ data: LimitIncreaseRequest[] }> {
const usp = new URLSearchParams();
if (status !== "all") usp.set("status", status);
usp.set("limit", String(limit));
return Request({
method: "GET",
url: `/admin/limit-requests?${usp.toString()}`,
authorization: true,
});
}
export function approveLimitRequest(
id: string,
notes: string,
): Promise<LimitIncreaseRequest> {
return Request({
method: "POST",
url: `/admin/limit-requests/${id}/approve`,
authorization: true,
data: { notes },
});
}
export function rejectLimitRequest(
id: string,
notes: string,
): Promise<LimitIncreaseRequest> {
return Request({
method: "POST",
url: `/admin/limit-requests/${id}/reject`,
authorization: true,
data: { notes },
});
}
+34
View File
@@ -323,6 +323,40 @@ export interface ProvisioningJobCreate {
};
}
// /admin/limit-requests — limit-increase request queue.
export type LimitRequestStatus =
| "pending"
| "approved"
| "rejected"
| "cancelled";
export interface LimitIncreaseRequest {
id: string;
organization_id: string;
field: string;
current_effective: number;
requested: number;
reason: string;
status: LimitRequestStatus;
submitted_by: string;
submitted_at: string;
reviewed_by?: string | null;
reviewed_at?: string | null;
review_notes: string;
organization?: {
id: string;
name: string;
slug?: string | null;
};
submitted_by_user?: {
id: string;
first_name: string;
last_name: string;
email: string;
};
}
// /admin/plans — plan catalog and custom-plan management.
export interface Plan {
+2
View File
@@ -41,6 +41,7 @@ import WarmupPage from "@/app/dashboard/WarmupPage";
import CampaignsPage from "@/app/dashboard/CampaignsPage";
import EnterprisePage from "@/app/dashboard/EnterprisePage";
import PlansPage from "@/app/dashboard/PlansPage";
import LimitRequestsPage from "@/app/dashboard/LimitRequestsPage";
import {
AnalyticsPage,
MailboxesPage,
@@ -93,6 +94,7 @@ const router = createBrowserRouter([
{ path: "warmup", element: <WarmupPage /> },
{ path: "campaigns", element: <CampaignsPage /> },
{ path: "enterprise", element: <EnterprisePage /> },
{ path: "limit-requests", element: <LimitRequestsPage /> },
{ path: "analytics", element: <AnalyticsPage /> },
{ path: "audit", element: <AuditPage /> },
{
+39 -22
View File
@@ -8,17 +8,18 @@ const sections = [
{ id: 'account', num: '04', title: 'Your account' },
{ id: 'use', num: '05', title: 'Acceptable use' },
{ id: 'plans', num: '06', title: 'Plans, billing, refunds' },
{ id: 'service', num: '07', title: 'Service availability' },
{ id: 'data', num: '08', title: 'Your content and data' },
{ id: 'ip', num: '09', title: 'Intellectual property' },
{ id: 'third-party', num: '10', title: 'Third-party services' },
{ id: 'warranties', num: '11', title: 'Disclaimers' },
{ id: 'liability', num: '12', title: 'Limitation of liability' },
{ id: 'indemnity', num: '13', title: 'Indemnification' },
{ id: 'termination', num: '14', title: 'Termination' },
{ id: 'law', num: '15', title: 'Governing law' },
{ id: 'changes', num: '16', title: 'Changes to these terms' },
{ id: 'contact', num: '17', title: 'Contact' },
{ id: 'limits', num: '07', title: 'Usage limits and increase requests' },
{ id: 'service', num: '08', title: 'Service availability' },
{ id: 'data', num: '09', title: 'Your content and data' },
{ id: 'ip', num: '10', title: 'Intellectual property' },
{ id: 'third-party', num: '11', title: 'Third-party services' },
{ id: 'warranties', num: '12', title: 'Disclaimers' },
{ id: 'liability', num: '13', title: 'Limitation of liability' },
{ id: 'indemnity', num: '14', title: 'Indemnification' },
{ id: 'termination', num: '15', title: 'Termination' },
{ id: 'law', num: '16', title: 'Governing law' },
{ id: 'changes', num: '17', title: 'Changes to these terms' },
{ id: 'contact', num: '18', title: 'Contact' },
];
---
<Legal
@@ -102,8 +103,24 @@ const sections = [
</p>
</section>
<section id="limits" class="scroll-mt-24">
<h2>07. Usage limits and increase requests</h2>
<p>
Every plan operates under usage limits — mailboxes, campaigns, team members, contacts, daily sending volume, and other capacity controls described in the workspace's billing area. Where marketing language describes a resource as "unlimited," that means there is no plan-tier cap, but a product-wide hard ceiling still applies. Hard ceilings exist to protect shared infrastructure, deliverability for other customers, and our compliance with mailbox-provider policies, and are necessary even on premium and enterprise tiers.
</p>
<p>
You may request an increase to any limit through the in-app limit-request form in your workspace settings. Each request is reviewed by our team. We may approve, partially approve, decline, or defer any request <strong>at our sole discretion</strong>, with or without further explanation, and we are under no obligation to grant any particular increase regardless of plan tier or contractual commitment. Reasons we may decline include, without limitation: insufficient sending reputation, prior abuse signals, deliverability risk to shared pools, fraud-prevention concerns, infrastructure capacity, and compliance considerations.
</p>
<p>
Approved increases take effect when the override is applied to your workspace. We may revoke, reduce, or condition any previously granted increase at any time, including but not limited to circumstances where a workspace's bounce rate, spam-complaint rate, warmup-pool behaviour, or reported abuse signals materially worsen. Such adjustments are operational responses to risk, not modifications to these terms.
</p>
<p>
Nothing in this section creates an entitlement to any particular volume or capacity beyond the limits in effect on your workspace at a given moment. If a hard ceiling materially limits your intended use, contact us at <a href="mailto:legal@warmbly.com">legal@warmbly.com</a> before purchasing so we can assess whether the Service is the right fit.
</p>
</section>
<section id="service" class="scroll-mt-24">
<h2>07. Service availability and changes</h2>
<h2>08. Service availability and changes</h2>
<p>
We work hard to keep Warmbly available and reliable, but the Service is provided without uptime guarantees beyond what is described in any separate service-level agreement we agree with you in writing.
</p>
@@ -113,7 +130,7 @@ const sections = [
</section>
<section id="data" class="scroll-mt-24">
<h2>08. Your content and data</h2>
<h2>09. Your content and data</h2>
<p>
You retain ownership of Customer content. You grant us a limited, non-exclusive licence to host, process, transmit, and display that content solely to operate the Service for you, to enforce these Terms, and to comply with law.
</p>
@@ -123,7 +140,7 @@ const sections = [
</section>
<section id="ip" class="scroll-mt-24">
<h2>09. Intellectual property</h2>
<h2>10. Intellectual property</h2>
<p>
Warmbly and Mindroot Ltd retain all rights in the Service, including the brand, the trademarks, the visual identity, the documentation, and the proprietary parts of the platform. Open-source components are licensed under their respective licences and are not subject to this clause.
</p>
@@ -133,14 +150,14 @@ const sections = [
</section>
<section id="third-party" class="scroll-mt-24">
<h2>10. Third-party services</h2>
<h2>11. Third-party services</h2>
<p>
Warmbly integrates with third-party services, including email providers, OAuth identity providers, payment processors such as Stripe, and infrastructure providers. Your use of those third-party services is governed by their own terms, and we are not responsible for their availability, content, or actions.
</p>
</section>
<section id="warranties" class="scroll-mt-24">
<h2>11. Disclaimers</h2>
<h2>12. Disclaimers</h2>
<p>
Except where prohibited by mandatory law, the Service is provided on an "as is" and "as available" basis without warranties of any kind, whether express or implied, including warranties of merchantability, fitness for a particular purpose, non-infringement, accuracy, or uninterrupted operation.
</p>
@@ -150,7 +167,7 @@ const sections = [
</section>
<section id="liability" class="scroll-mt-24">
<h2>12. Limitation of liability</h2>
<h2>13. Limitation of liability</h2>
<p>
To the maximum extent permitted by law, neither party will be liable to the other for any indirect, incidental, special, consequential, or punitive damages, or for any loss of profits, revenue, goodwill, or data, even if advised of the possibility of such damages.
</p>
@@ -163,14 +180,14 @@ const sections = [
</section>
<section id="indemnity" class="scroll-mt-24">
<h2>13. Indemnification</h2>
<h2>14. Indemnification</h2>
<p>
You agree to defend, indemnify, and hold harmless Mindroot Ltd, its officers, employees, and affiliates from and against any claims, damages, liabilities, and reasonable legal costs arising from your Customer content, your use of the Service in breach of these Terms, or your violation of any law or third-party right.
</p>
</section>
<section id="termination" class="scroll-mt-24">
<h2>14. Termination</h2>
<h2>15. Termination</h2>
<p>
You may cancel your account at any time from the billing area of your workspace. We may suspend or terminate your access if you breach these Terms, if your use of the Service creates a security or deliverability risk to other users, or if we are required to do so by law.
</p>
@@ -180,21 +197,21 @@ const sections = [
</section>
<section id="law" class="scroll-mt-24">
<h2>15. Governing law and jurisdiction</h2>
<h2>16. Governing law and jurisdiction</h2>
<p>
These Terms are governed by and construed in accordance with the laws of England and Wales. You and Mindroot Ltd agree to submit to the exclusive jurisdiction of the courts of London, United Kingdom, for any dispute arising out of or relating to these Terms or your use of the Service.
</p>
</section>
<section id="changes" class="scroll-mt-24">
<h2>16. Changes to these terms</h2>
<h2>17. Changes to these terms</h2>
<p>
We may update these Terms from time to time. If we make material changes, we will notify you by email or through the Service before the changes take effect. Your continued use of the Service after the effective date constitutes acceptance of the updated Terms.
</p>
</section>
<section id="contact" class="scroll-mt-24">
<h2>17. Contact</h2>
<h2>18. Contact</h2>
<p>
Questions or concerns about these Terms can be sent to <a href="mailto:hello@warmbly.com">hello@warmbly.com</a> or by post to Mindroot Ltd, 71-75 Shelton Street, London, England, WC2H 9JQ.
</p>
+2
View File
@@ -20,6 +20,7 @@ import {
BellIcon,
BriefcaseIcon,
CreditCardIcon,
GaugeIcon,
ShieldCheckIcon,
ShieldIcon,
UserIcon,
@@ -44,6 +45,7 @@ const SECTIONS: SectionDef[] = [
{ path: "roles", label: "Roles & access", icon: ShieldCheckIcon, description: "Who can do what.", ownerOnly: true },
{ path: "workspace", label: "Workspace", icon: BriefcaseIcon, description: "Org-wide settings.", ownerOnly: true },
{ path: "billing", label: "Billing", icon: CreditCardIcon, description: "Plan, payment, invoices.", ownerOnly: true },
{ path: "limits", label: "Limits", icon: GaugeIcon, description: "Request more capacity than your plan allows.", ownerOnly: true },
{ path: "danger", label: "Danger zone", icon: AlertOctagonIcon, description: "Irreversible actions." },
];
+229
View File
@@ -0,0 +1,229 @@
// Customer-facing limit-increase request flow. Lists past requests
// and offers a form to submit a new one. Approval surfaces as the
// effective limit going up on the org; rejection surfaces with the
// admin's notes attached to the row.
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { Section, SectionShell } from "../_components/SectionShell";
import getCurrentOrganization from "@/lib/api/client/app/organizations/getCurrentOrganization";
import listLimitRequests from "@/lib/api/client/app/organizations/listLimitRequests";
import submitLimitRequest from "@/lib/api/client/app/organizations/submitLimitRequest";
import cancelLimitRequest from "@/lib/api/client/app/organizations/cancelLimitRequest";
import type {
LimitField,
LimitRequestStatus,
} from "@/lib/api/models/app/organizations/LimitIncreaseRequest";
const FIELD_OPTIONS: { value: LimitField; label: string; hint: string }[] = [
{ value: "max_email_accounts", label: "Mailboxes", hint: "More connected sending mailboxes" },
{ value: "max_campaigns", label: "Campaigns (lifetime)", hint: "Higher cap on total campaigns created" },
{ value: "max_active_campaigns", label: "Active campaigns", hint: "More campaigns running at the same time" },
{ value: "max_team_members", label: "Team members", hint: "More seats on this workspace" },
{ value: "max_contacts", label: "Contacts", hint: "Store more recipient records" },
{ value: "daily_campaign_limit", label: "Daily sends", hint: "Send more campaign emails per day" },
];
const STATUS_TONE: Record<LimitRequestStatus, string> = {
pending: "bg-amber-50 text-amber-700 border-amber-200",
approved: "bg-emerald-50 text-emerald-700 border-emerald-200",
rejected: "bg-red-50 text-red-700 border-red-200",
cancelled: "bg-slate-50 text-slate-600 border-slate-200",
};
export default function LimitsSettingsPage() {
const qc = useQueryClient();
const orgQuery = useQuery({
queryKey: ["app", "organizations", "current"],
queryFn: getCurrentOrganization,
});
const orgId = orgQuery.data?.id;
const requestsQuery = useQuery({
queryKey: ["app", "organizations", orgId, "limit-requests"],
queryFn: () => listLimitRequests(orgId!),
enabled: !!orgId,
});
const [field, setField] = useState<LimitField>("max_email_accounts");
const [requested, setRequested] = useState<string>("");
const [reason, setReason] = useState<string>("");
const submit = useMutation({
mutationFn: () =>
submitLimitRequest(orgId!, {
field,
requested: Number(requested),
reason,
}),
onSuccess: () => {
toast.success("Request submitted — an admin will review shortly.");
qc.invalidateQueries({ queryKey: ["app", "organizations", orgId, "limit-requests"] });
setRequested("");
setReason("");
},
onError: (err: Error) => {
toast.error(err.message || "Could not submit — please try again.");
},
});
const cancel = useMutation({
mutationFn: (id: string) => cancelLimitRequest(id),
onSuccess: () => {
toast.success("Request cancelled");
qc.invalidateQueries({ queryKey: ["app", "organizations", orgId, "limit-requests"] });
},
onError: (err: Error) => {
toast.error(err.message || "Cancel failed");
},
});
const rows = requestsQuery.data?.data ?? [];
function onSubmit(e: React.FormEvent) {
e.preventDefault();
const n = Number(requested);
if (!Number.isInteger(n) || n <= 0) {
toast.error("Requested value must be a positive integer");
return;
}
if (reason.trim().length < 10) {
toast.error("Please include a reason (at least a sentence)");
return;
}
submit.mutate();
}
return (
<SectionShell
title="Limits"
description="Ask for more capacity than your plan or our product-level cap allows. Increases are reviewed and may be refused per our terms of service."
>
<Section
eyebrow="Request an increase"
description="Tell us what you need and why. We aim to respond within one business day."
>
<form onSubmit={onSubmit} className="space-y-3">
<div>
<label className="text-[12px] font-medium text-slate-700">Resource</label>
<select
value={field}
onChange={(e) => setField(e.target.value as LimitField)}
className="mt-1 block w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm"
>
{FIELD_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<p className="text-[11px] text-slate-500 mt-1">
{FIELD_OPTIONS.find((o) => o.value === field)?.hint}
</p>
</div>
<div>
<label className="text-[12px] font-medium text-slate-700">
Requested value
</label>
<input
type="number"
min={1}
value={requested}
onChange={(e) => setRequested(e.target.value)}
className="mt-1 block w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm tabular-nums"
placeholder="e.g. 50"
/>
</div>
<div>
<label className="text-[12px] font-medium text-slate-700">Reason</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
className="mt-1 block w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm"
placeholder="Why does this matter for your team? Volume, customer commitments, ramp plans, etc."
/>
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={submit.isPending || !orgId}
className="rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-50"
>
{submit.isPending ? "Submitting…" : "Submit request"}
</button>
<p className="text-[11px] text-slate-500">
Subject to review per our{" "}
<a
href="https://warmbly.com/terms"
target="_blank"
rel="noreferrer"
className="underline"
>
terms of service
</a>
.
</p>
</div>
</form>
</Section>
<Section eyebrow="Your requests" description="Pending, approved, and historical decisions.">
{requestsQuery.isLoading ? (
<p className="text-[12px] text-slate-500">Loading</p>
) : rows.length === 0 ? (
<p className="text-[12px] text-slate-500">No requests yet.</p>
) : (
<ul className="space-y-2">
{rows.map((r) => {
const fieldLabel =
FIELD_OPTIONS.find((o) => o.value === r.field)?.label ?? r.field;
return (
<li
key={r.id}
className="rounded-md border border-slate-200 p-3 bg-white"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium">
{fieldLabel}: {r.current_effective.toLocaleString()}
{" → "}
{r.requested.toLocaleString()}
</div>
<div className="text-[11px] text-slate-500 mt-1">
{new Date(r.submitted_at).toLocaleDateString()} · "{r.reason}"
</div>
{r.review_notes && r.status !== "pending" && (
<div className="text-[11px] text-slate-600 mt-1 italic">
Reviewer: "{r.review_notes}"
</div>
)}
</div>
<div className="flex flex-col items-end gap-1.5 shrink-0">
<span
className={`text-[10px] px-1.5 py-0.5 rounded border ${STATUS_TONE[r.status]}`}
>
{r.status}
</span>
{r.status === "pending" && (
<button
onClick={() => cancel.mutate(r.id)}
disabled={cancel.isPending}
className="text-[11px] text-slate-500 hover:text-slate-800 underline"
>
Cancel
</button>
)}
</div>
</div>
</li>
);
})}
</ul>
)}
</Section>
</SectionShell>
);
}
@@ -0,0 +1,9 @@
import Request from "../../Request";
export default async function cancelLimitRequest(id: string): Promise<void> {
await Request<void>({
method: "DELETE",
url: `/limit-requests/${id}`,
authorization: true,
});
}
@@ -0,0 +1,12 @@
import type LimitIncreaseRequest from "@/lib/api/models/app/organizations/LimitIncreaseRequest";
import Request from "../../Request";
export default async function listLimitRequests(
orgId: string,
): Promise<{ data: LimitIncreaseRequest[] }> {
return await Request<{ data: LimitIncreaseRequest[] }>({
method: "GET",
url: `/organization/${orgId}/limit-requests`,
authorization: true,
});
}
@@ -0,0 +1,15 @@
import type LimitIncreaseRequest from "@/lib/api/models/app/organizations/LimitIncreaseRequest";
import type { CreateLimitIncreaseRequest } from "@/lib/api/models/app/organizations/LimitIncreaseRequest";
import Request from "../../Request";
export default async function submitLimitRequest(
orgId: string,
body: CreateLimitIncreaseRequest,
): Promise<LimitIncreaseRequest> {
return await Request<LimitIncreaseRequest>({
method: "POST",
url: `/organization/${orgId}/limit-requests`,
data: body,
authorization: true,
});
}
@@ -0,0 +1,34 @@
export type LimitRequestStatus =
| "pending"
| "approved"
| "rejected"
| "cancelled";
export type LimitField =
| "max_campaigns"
| "max_active_campaigns"
| "max_team_members"
| "max_email_accounts"
| "max_contacts"
| "daily_campaign_limit";
export default interface LimitIncreaseRequest {
id: string;
organization_id: string;
field: LimitField;
current_effective: number;
requested: number;
reason: string;
status: LimitRequestStatus;
submitted_by: string;
submitted_at: string;
reviewed_by?: string | null;
reviewed_at?: string | null;
review_notes: string;
}
export interface CreateLimitIncreaseRequest {
field: LimitField;
requested: number;
reason: string;
}
+2
View File
@@ -34,6 +34,7 @@ import MembersSettingsPage from './app/app/settings/members/page';
import WorkspaceSettingsPage from './app/app/settings/workspace/page';
import DangerSettingsPage from './app/app/settings/danger/page';
import BillingSettingsPage from './app/app/settings/billing/page';
import LimitsSettingsPage from './app/app/settings/limits/page';
import RolesSettingsPage from './app/app/settings/roles/page';
import UniboxPage from './app/app/unibox/page';
@@ -261,6 +262,7 @@ const router = createBrowserRouter([
{ path: "members", element: <MembersSettingsPage /> },
{ path: "workspace", element: <WorkspaceSettingsPage /> },
{ path: "billing", element: <BillingSettingsPage /> },
{ path: "limits", element: <LimitsSettingsPage /> },
{ path: "roles", element: <RolesSettingsPage /> },
{ path: "danger", element: <DangerSettingsPage /> },
],