feat: tiered costs feature + admin tier management + tag selection
Build & Push Docker Image / build (push) Successful in 6m52s
Build & Push Docker Image / build (push) Successful in 6m52s
- costs: nullable notes (fix create without notes), drop renewalDate (schema + API + UI), gate POST/PATCH/DELETE to admin + costs feature - feature middleware: admin-aware hasFeature + getEntitlements union; /auth/me and login return resolved entitlements - users: tier enum (free/premium/enterprise) in create/update/list, admin UI tier select + tier badge - tags: GET /tags/all, TagInput autocomplete in new/edit tool forms, feature suggestions on focus, query invalidation on create/update - openapi: nullable ToolUpdate urls, ToolUpdate tier fields, listAllTags - Dockerfile: push-force to drop renewal_date column
This commit is contained in:
@@ -32,13 +32,14 @@ export default function Admin() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editUser, setEditUser] = useState<{ id: number; username: string; role: string } | null>(null);
|
||||
const [editUser, setEditUser] = useState<{ id: number; username: string; role: string; tier: string } | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null);
|
||||
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [newRole, setNewRole] = useState<"admin" | "user">("user");
|
||||
const [newTier, setNewTier] = useState<"free" | "premium" | "enterprise">("free");
|
||||
|
||||
const { data: users, isLoading: loadingUsers } = useListUsers({
|
||||
query: { queryKey: getListUsersQueryKey(), enabled: isAdmin },
|
||||
@@ -68,7 +69,7 @@ export default function Admin() {
|
||||
const handleCreateUser = () => {
|
||||
if (!newUsername || !newPassword) return;
|
||||
createUser.mutate(
|
||||
{ data: { username: newUsername, password: newPassword, email: newEmail || undefined, role: newRole } },
|
||||
{ data: { username: newUsername, password: newPassword, email: newEmail || undefined, role: newRole, tier: newTier } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: "User created", description: `${newUsername} has been created.` });
|
||||
@@ -86,18 +87,18 @@ export default function Admin() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdateRole = (role: "admin" | "user") => {
|
||||
const handleUpdateUser = () => {
|
||||
if (!editUser) return;
|
||||
updateUser.mutate(
|
||||
{ id: editUser.id, data: { role } },
|
||||
{ id: editUser.id, data: { role: editUser.role as "admin" | "user", tier: editUser.tier as "free" | "premium" | "enterprise" } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: "Role updated" });
|
||||
toast({ title: "User updated" });
|
||||
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
||||
setEditUser(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: "Failed to update role", description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
toast({ title: "Failed to update user", description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -184,13 +185,16 @@ export default function Admin() {
|
||||
<Badge variant={u.role === "admin" ? "default" : "secondary"}>
|
||||
{u.role}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{u.tier ?? "free"}
|
||||
</Badge>
|
||||
{u.username !== user?.preferredUsername && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role })}
|
||||
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role, tier: u.tier ?? "free" })}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
@@ -295,6 +299,19 @@ export default function Admin() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Plan</Label>
|
||||
<Select value={newTier} onValueChange={(v) => setNewTier(v as "free" | "premium" | "enterprise")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="free">Free</SelectItem>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
<SelectItem value="enterprise">Enterprise</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
@@ -308,26 +325,45 @@ export default function Admin() {
|
||||
<Dialog open={!!editUser} onOpenChange={(open) => !open && setEditUser(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change Role — {editUser?.username}</DialogTitle>
|
||||
<DialogTitle>Edit User — {editUser?.username}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Select
|
||||
value={editUser?.role || "user"}
|
||||
onValueChange={(v) => setEditUser(editUser ? { ...editUser, role: v } : null)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<Select
|
||||
value={editUser?.role || "user"}
|
||||
onValueChange={(v) => setEditUser(editUser ? { ...editUser, role: v } : null)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Plan</Label>
|
||||
<Select
|
||||
value={editUser?.tier || "free"}
|
||||
onValueChange={(v) => setEditUser(editUser ? { ...editUser, tier: v } : null)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="free">Free</SelectItem>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
<SelectItem value="enterprise">Enterprise</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => handleUpdateRole(editUser?.role as "admin" | "user")}
|
||||
onClick={handleUpdateUser}
|
||||
disabled={updateUser.isPending}
|
||||
>
|
||||
Save
|
||||
|
||||
Reference in New Issue
Block a user