From 93bb56a458fbe2ecbee953672145e48f901eff25 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 28 May 2026 12:16:02 +0200 Subject: [PATCH] feat(limits): admin queue + customer request form + ToS clause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- admin/src/app/dashboard/LimitRequestsPage.tsx | 343 ++++++++++++++++++ admin/src/components/layout/Sidebar.tsx | 2 + .../src/lib/api/client/admin/limitRequests.ts | 46 +++ admin/src/lib/api/models/admin.ts | 34 ++ admin/src/main.tsx | 2 + site/src/pages/terms.astro | 61 ++-- web/src/app/app/settings/layout.tsx | 2 + web/src/app/app/settings/limits/page.tsx | 229 ++++++++++++ .../app/organizations/cancelLimitRequest.ts | 9 + .../app/organizations/listLimitRequests.ts | 12 + .../app/organizations/submitLimitRequest.ts | 15 + .../app/organizations/LimitIncreaseRequest.ts | 34 ++ web/src/main.tsx | 2 + 13 files changed, 769 insertions(+), 22 deletions(-) create mode 100644 admin/src/app/dashboard/LimitRequestsPage.tsx create mode 100644 admin/src/lib/api/client/admin/limitRequests.ts create mode 100644 web/src/app/app/settings/limits/page.tsx create mode 100644 web/src/lib/api/client/app/organizations/cancelLimitRequest.ts create mode 100644 web/src/lib/api/client/app/organizations/listLimitRequests.ts create mode 100644 web/src/lib/api/client/app/organizations/submitLimitRequest.ts create mode 100644 web/src/lib/api/models/app/organizations/LimitIncreaseRequest.ts diff --git a/admin/src/app/dashboard/LimitRequestsPage.tsx b/admin/src/app/dashboard/LimitRequestsPage.tsx new file mode 100644 index 00000000..ce6f8c8f --- /dev/null +++ b/admin/src/app/dashboard/LimitRequestsPage.tsx @@ -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 = { + 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 = { + 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("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 ( +
+ + + + + {isLoading && } + {error && ( +
+ Failed to load limit requests. +
+ )} + + {!isLoading && !error && rows.length === 0 && ( +
+ No {status === "all" ? "" : status} requests. +
+ )} + + {rows.length > 0 && ( +
+ + + + + + + + + + + + + + + + {rows.map((r) => ( + setReviewing({ req: r, mode: "approve" })} + onReject={() => setReviewing({ req: r, mode: "reject" })} + /> + ))} + +
WorkspaceRequesterFieldCurrentRequestedReasonStatusSubmittedAction
+
+ )} + + {reviewing && ( + !v && setReviewing(null)} + /> + )} +
+ ); +} + +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 ( + + + {req.organization && ( + + {req.organization.name} + + )} + + + {req.submitted_by_user?.email ?? req.submitted_by} + + {fieldLabel} + + {req.current_effective.toLocaleString()} + + + {req.requested.toLocaleString()} + + (+{delta.toLocaleString()}) + + + + {req.reason} + + + + {req.status} + + {req.review_notes && req.status !== "pending" && ( +
+ "{req.review_notes}" +
+ )} + + + {new Date(req.submitted_at).toLocaleDateString()} + + + + + + + ); +} + +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 ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +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 ( + + + + + {mode === "approve" ? "Approve request" : "Reject request"} + + + {mode === "approve" ? ( + <> + Approving raises {fieldLabel} for{" "} + + {req.organization?.name ?? req.organization_id} + {" "} + 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()}). + + )} + + +
+ + setNotes(e.target.value)} + autoFocus + /> +
+ + + + +
+
+ ); +} diff --git a/admin/src/components/layout/Sidebar.tsx b/admin/src/components/layout/Sidebar.tsx index 14084d59..ae816ef1 100644 --- a/admin/src/components/layout/Sidebar.tsx +++ b/admin/src/components/layout/Sidebar.tsx @@ -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 }, ], diff --git a/admin/src/lib/api/client/admin/limitRequests.ts b/admin/src/lib/api/client/admin/limitRequests.ts new file mode 100644 index 00000000..ba39966f --- /dev/null +++ b/admin/src/lib/api/client/admin/limitRequests.ts @@ -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 { + return Request({ + method: "POST", + url: `/admin/limit-requests/${id}/approve`, + authorization: true, + data: { notes }, + }); +} + +export function rejectLimitRequest( + id: string, + notes: string, +): Promise { + return Request({ + method: "POST", + url: `/admin/limit-requests/${id}/reject`, + authorization: true, + data: { notes }, + }); +} diff --git a/admin/src/lib/api/models/admin.ts b/admin/src/lib/api/models/admin.ts index e5607f66..c2bd41ae 100644 --- a/admin/src/lib/api/models/admin.ts +++ b/admin/src/lib/api/models/admin.ts @@ -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 { diff --git a/admin/src/main.tsx b/admin/src/main.tsx index 27bee8c8..c199ab72 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -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: }, { path: "campaigns", element: }, { path: "enterprise", element: }, + { path: "limit-requests", element: }, { path: "analytics", element: }, { path: "audit", element: }, { diff --git a/site/src/pages/terms.astro b/site/src/pages/terms.astro index 171b0bd5..6fceeb8a 100644 --- a/site/src/pages/terms.astro +++ b/site/src/pages/terms.astro @@ -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' }, ]; --- +
+

07. Usage limits and increase requests

+

+ 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. +

+

+ 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 at our sole discretion, 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. +

+

+ 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. +

+

+ 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 legal@warmbly.com before purchasing so we can assess whether the Service is the right fit. +

+
+
-

07. Service availability and changes

+

08. Service availability and changes

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.

@@ -113,7 +130,7 @@ const sections = [
-

08. Your content and data

+

09. Your content and data

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.

@@ -123,7 +140,7 @@ const sections = [
-

09. Intellectual property

+

10. Intellectual property

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.

@@ -133,14 +150,14 @@ const sections = [
-

10. Third-party services

+

11. Third-party services

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.

-

11. Disclaimers

+

12. Disclaimers

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.

@@ -150,7 +167,7 @@ const sections = [
-

12. Limitation of liability

+

13. Limitation of liability

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.

@@ -163,14 +180,14 @@ const sections = [
-

13. Indemnification

+

14. Indemnification

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.

-

14. Termination

+

15. Termination

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.

@@ -180,21 +197,21 @@ const sections = [
-

15. Governing law and jurisdiction

+

16. Governing law and jurisdiction

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.

-

16. Changes to these terms

+

17. Changes to these terms

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.

-

17. Contact

+

18. Contact

Questions or concerns about these Terms can be sent to hello@warmbly.com or by post to Mindroot Ltd, 71-75 Shelton Street, London, England, WC2H 9JQ.

diff --git a/web/src/app/app/settings/layout.tsx b/web/src/app/app/settings/layout.tsx index f5a59129..f7e2a2fa 100644 --- a/web/src/app/app/settings/layout.tsx +++ b/web/src/app/app/settings/layout.tsx @@ -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." }, ]; diff --git a/web/src/app/app/settings/limits/page.tsx b/web/src/app/app/settings/limits/page.tsx new file mode 100644 index 00000000..c5b9e27b --- /dev/null +++ b/web/src/app/app/settings/limits/page.tsx @@ -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 = { + 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("max_email_accounts"); + const [requested, setRequested] = useState(""); + const [reason, setReason] = useState(""); + + 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 ( + +
+
+
+ + +

+ {FIELD_OPTIONS.find((o) => o.value === field)?.hint} +

+
+
+ + 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" + /> +
+
+ +