mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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." },
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 /> },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user