feat: enhance user limits

This commit is contained in:
gosangam
2026-06-23 14:23:20 +05:30
parent 8b7ae1e7a2
commit 0406a7fe63
2 changed files with 161 additions and 1 deletions
+142 -1
View File
@@ -3,7 +3,12 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Mail } from "lucide-react"; import { Mail } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { api, AdminError, type AdminUser } from "@/lib/api"; import {
api,
AdminError,
type AdminUser,
type UserLimitsInput,
} from "@/lib/api";
import { GoogleIcon, FacebookIcon } from "@/components/ui/provider-icons"; import { GoogleIcon, FacebookIcon } from "@/components/ui/provider-icons";
import { usePagedList } from "@/lib/use-paged-list"; import { usePagedList } from "@/lib/use-paged-list";
import { ListShell, EmptyRow } from "@/components/list-shell"; import { ListShell, EmptyRow } from "@/components/list-shell";
@@ -25,6 +30,7 @@ export default function UsersPage() {
const list = usePagedList<AdminUser>(fetcher, [q]); const list = usePagedList<AdminUser>(fetcher, [q]);
const confirm = useConfirm(); const confirm = useConfirm();
const [notify, setNotify] = useState<AdminUser | null>(null); const [notify, setNotify] = useState<AdminUser | null>(null);
const [limitsUser, setLimitsUser] = useState<AdminUser | null>(null);
async function toggleBan(u: AdminUser) { async function toggleBan(u: AdminUser) {
const banning = !u.banned_at; const banning = !u.banned_at;
@@ -51,6 +57,7 @@ export default function UsersPage() {
function menuItems(u: AdminUser): MenuItem[] { function menuItems(u: AdminUser): MenuItem[] {
const items: MenuItem[] = [ const items: MenuItem[] = [
{ label: "Send notification", onClick: () => setNotify(u) }, { label: "Send notification", onClick: () => setNotify(u) },
{ label: "Edit limits", onClick: () => setLimitsUser(u) },
]; ];
// No ban option for fellow admins (avoid locking each other out). // No ban option for fellow admins (avoid locking each other out).
if (u.role !== "admin") { if (u.role !== "admin") {
@@ -127,6 +134,11 @@ export default function UsersPage() {
</Table> </Table>
<NotifyModal user={notify} onClose={() => setNotify(null)} /> <NotifyModal user={notify} onClose={() => setNotify(null)} />
<LimitsModal
user={limitsUser}
onClose={() => setLimitsUser(null)}
onSaved={list.refresh}
/>
</ListShell> </ListShell>
); );
} }
@@ -163,6 +175,135 @@ function AuthIcons({ user }: { user: AdminUser }) {
); );
} }
// An effective limit of -1 means "unlimited" (premium default) — shown as an
// empty field. Saving an empty field sends null, which resets that limit to
// the plan default on the server.
function limitToField(v: number | undefined): string {
return v === undefined || v < 0 ? "" : String(v);
}
function LimitsModal({
user,
onClose,
onSaved,
}: {
user: AdminUser | null;
onClose: () => void;
onSaved: () => void;
}) {
const [bookmark, setBookmark] = useState("");
const [pub, setPub] = useState("");
const [priv, setPriv] = useState("");
const [busy, setBusy] = useState(false);
// Re-seed the fields whenever a different user's modal opens.
const [seededFor, setSeededFor] = useState<string | null>(null);
if (user && seededFor !== user.id) {
setSeededFor(user.id);
setBookmark(limitToField(user.bookmark_limit));
setPub(limitToField(user.public_collection_limit));
setPriv(limitToField(user.private_collection_limit));
}
if (!user && seededFor !== null) setSeededFor(null);
// "" → null (reset to plan default); a non-negative integer → that cap.
function parseField(raw: string): number | null | undefined {
const t = raw.trim();
if (t === "") return null;
const n = Number(t);
if (!Number.isInteger(n) || n < 0) return undefined; // invalid
return n;
}
async function save() {
if (!user) return;
const b = parseField(bookmark);
const p = parseField(pub);
const v = parseField(priv);
if (b === undefined || p === undefined || v === undefined) {
toast.error("Limits must be whole numbers (0 or more).");
return;
}
const limits: UserLimitsInput = {
bookmark_limit: b,
public_collection_limit: p,
private_collection_limit: v,
};
setBusy(true);
try {
await api.setUserLimits(user.id, limits);
toast.success(`Limits updated for @${user.handle}`);
onSaved();
onClose();
} catch (err) {
toast.error(err instanceof AdminError ? err.message : "Could not update.");
} finally {
setBusy(false);
}
}
return (
<Modal
open={!!user}
onClose={onClose}
title={user ? `Edit limits — @${user.handle}` : "Edit limits"}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
How many of each this user can save. Leave a field blank to use their
plan default.
</p>
<LimitField
label="Bookmarks"
value={bookmark}
onChange={setBookmark}
/>
<LimitField
label="Public collections"
value={pub}
onChange={setPub}
/>
<LimitField
label="Private collections"
value={priv}
onChange={setPriv}
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={save} disabled={busy}>
{busy ? "Saving…" : "Save limits"}
</Button>
</div>
</div>
</Modal>
);
}
function LimitField({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) {
return (
<label className="block space-y-1.5">
<span className="text-sm font-medium">{label}</span>
<Input
type="number"
min={0}
inputMode="numeric"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Plan default"
/>
</label>
);
}
function NotifyModal({ function NotifyModal({
user, user,
onClose, onClose,
+19
View File
@@ -106,6 +106,19 @@ export type AdminUser = {
created_at: string; created_at: string;
has_password: boolean; has_password: boolean;
providers: string[]; providers: string[];
/** Effective save limits in force (plan default merged with any override);
* -1 means unlimited. */
bookmark_limit: number;
public_collection_limit: number;
private_collection_limit: number;
};
/** Per-user save-limit override payload. null = reset that limit to the plan
* default; a number caps it for this user. */
export type UserLimitsInput = {
bookmark_limit: number | null;
public_collection_limit: number | null;
private_collection_limit: number | null;
}; };
export type AdminPost = { export type AdminPost = {
@@ -246,6 +259,12 @@ export const api = {
unbanUser(id: string) { unbanUser(id: string) {
return call(`/admin/users/${id}/ban`, { method: "DELETE" }); return call(`/admin/users/${id}/ban`, { method: "DELETE" });
}, },
setUserLimits(id: string, limits: UserLimitsInput) {
return call(`/admin/users/${id}/limits`, {
method: "POST",
body: JSON.stringify(limits),
});
},
posts(q: string, limit: number, offset: number) { posts(q: string, limit: number, offset: number) {
return listed<AdminPost>(`/admin/posts${qs({ q, limit, offset })}`); return listed<AdminPost>(`/admin/posts${qs({ q, limit, offset })}`);
}, },