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:
@@ -23,13 +23,14 @@ export function FeatureInput({ value, onChange, placeholder, "data-testid": test
|
||||
|
||||
const known: string[] = allFeatures.data ?? [];
|
||||
|
||||
const suggestions = value.trim().length >= 1
|
||||
? known.filter(
|
||||
(f) =>
|
||||
f.toLowerCase().includes(value.toLowerCase()) &&
|
||||
f.toLowerCase() !== value.toLowerCase()
|
||||
).slice(0, 6)
|
||||
: [];
|
||||
const q = value.trim();
|
||||
const suggestions = known
|
||||
.filter(
|
||||
(f) =>
|
||||
q.length === 0 ||
|
||||
(f.toLowerCase().includes(q.toLowerCase()) && f.toLowerCase() !== q.toLowerCase()),
|
||||
)
|
||||
.slice(0, 6);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useListAllTags, getListAllTagsQueryKey } from "@workspace/api-client-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TagInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function TagInput({ value, onChange, placeholder, className, "data-testid": testId }: TagInputProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const allTags = useListAllTags({
|
||||
query: {
|
||||
queryKey: getListAllTagsQueryKey(),
|
||||
refetchOnMount: "always",
|
||||
staleTime: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const known: string[] = allTags.data ?? [];
|
||||
|
||||
const q = value.trim();
|
||||
const suggestions = known
|
||||
.filter(
|
||||
(t) =>
|
||||
q.length === 0 ||
|
||||
(t.toLowerCase().includes(q.toLowerCase()) && t.toLowerCase() !== q.toLowerCase()),
|
||||
)
|
||||
.slice(0, 6);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
data-testid={testId}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{open && suggestions.length > 0 && (
|
||||
<div className="absolute z-50 top-full mt-1 w-full rounded-md border bg-popover shadow-md text-sm overflow-hidden">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 hover:bg-muted transition-colors text-foreground",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(s);
|
||||
setOpen(false);
|
||||
}}
|
||||
data-testid={`suggestion-tag-${s}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -91,7 +91,6 @@ export default function ToolDetail() {
|
||||
const [costBillingPeriod, setCostBillingPeriod] = useState("monthly");
|
||||
const [costAmount, setCostAmount] = useState("");
|
||||
const [costCurrency, setCostCurrency] = useState("EUR");
|
||||
const [costRenewal, setCostRenewal] = useState("");
|
||||
const [costNotes, setCostNotes] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -150,7 +149,6 @@ export default function ToolDetail() {
|
||||
notes: costNotes || null,
|
||||
};
|
||||
if (costAmount) body.cost = costAmount;
|
||||
if (costRenewal) body.renewalDate = costRenewal;
|
||||
try {
|
||||
await customFetch(url, { method, body: JSON.stringify(body) });
|
||||
toast({ title: editCost ? "Cost updated" : "Cost added" });
|
||||
@@ -168,7 +166,6 @@ export default function ToolDetail() {
|
||||
setCostBillingPeriod("monthly");
|
||||
setCostAmount("");
|
||||
setCostCurrency("EUR");
|
||||
setCostRenewal("");
|
||||
setCostNotes("");
|
||||
}
|
||||
|
||||
@@ -178,7 +175,6 @@ export default function ToolDetail() {
|
||||
setCostBillingPeriod(c.billingPeriod ?? "monthly");
|
||||
setCostAmount(c.cost ?? "");
|
||||
setCostCurrency(c.currency ?? "EUR");
|
||||
setCostRenewal(c.renewalDate ?? "");
|
||||
setCostNotes(c.notes ?? "");
|
||||
setCostDialogOpen(true);
|
||||
}
|
||||
@@ -562,11 +558,6 @@ export default function ToolDetail() {
|
||||
<div className="text-lg font-bold">
|
||||
{c.cost != null ? `${c.cost} ${c.currency ?? ""}` : "Free"}
|
||||
</div>
|
||||
{c.renewalDate && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Renews: {new Date(c.renewalDate).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
{c.notes && <p className="text-xs text-muted-foreground mt-1 italic">{c.notes}</p>}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
@@ -641,10 +632,6 @@ export default function ToolDetail() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Renewal Date</label>
|
||||
<Input type="date" value={costRenewal} onChange={(e) => setCostRenewal(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Notes</label>
|
||||
<Textarea placeholder="Billing details, contract info..." value={costNotes} onChange={(e) => setCostNotes(e.target.value)} />
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getListToolsQueryKey,
|
||||
getListCategoriesQueryKey,
|
||||
getListAllFeaturesQueryKey,
|
||||
getListAllTagsQueryKey,
|
||||
getGetTopToolsQueryKey,
|
||||
getGetAnalyticsSummaryQueryKey,
|
||||
} from "@workspace/api-client-react";
|
||||
@@ -27,6 +28,7 @@ import { Pencil, Plus, X, ArrowLeft } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { CategoryCombobox } from "@/components/category-combobox";
|
||||
import { FeatureInput } from "@/components/feature-input";
|
||||
import { TagInput } from "@/components/tag-input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
const toolSchema = z.object({
|
||||
@@ -109,6 +111,7 @@ export default function ToolEdit() {
|
||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListAllTagsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
|
||||
setLocation(`/tools/${id}`);
|
||||
@@ -260,7 +263,7 @@ export default function ToolEdit() {
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Features</h3>
|
||||
<p className="text-sm text-muted-foreground">Key capabilities of this tool.</p>
|
||||
<p className="text-sm text-muted-foreground">Key capabilities of this tool. Existing features from other tools are selectable.</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Feature
|
||||
@@ -304,7 +307,7 @@ export default function ToolEdit() {
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Tags</h3>
|
||||
<p className="text-sm text-muted-foreground">Keywords for this tool.</p>
|
||||
<p className="text-sm text-muted-foreground">Keywords for this tool. Existing tags from other tools are selectable.</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Tag
|
||||
@@ -317,9 +320,14 @@ export default function ToolEdit() {
|
||||
control={form.control}
|
||||
name={`tags.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-y-0 relative w-[150px]">
|
||||
<FormItem className="flex items-center space-y-0 relative w-[200px]">
|
||||
<FormControl>
|
||||
<Input placeholder="Tag" className="pr-8 h-9 text-sm" {...field} />
|
||||
<TagInput
|
||||
placeholder="Tag"
|
||||
className="pr-8 h-9 text-sm"
|
||||
onChange={field.onChange}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useLocation } from "wouter";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { useCreateTool, getListToolsQueryKey, getListCategoriesQueryKey, getListAllFeaturesQueryKey, getGetTopToolsQueryKey, getGetAnalyticsSummaryQueryKey } from "@workspace/api-client-react";
|
||||
import { useCreateTool, getListToolsQueryKey, getListCategoriesQueryKey, getListAllFeaturesQueryKey, getListAllTagsQueryKey, getGetTopToolsQueryKey, getGetAnalyticsSummaryQueryKey } from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Layout } from "@/components/layout";
|
||||
@@ -16,6 +16,7 @@ import { Wrench, Plus, X, ArrowLeft, LogIn } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { CategoryCombobox } from "@/components/category-combobox";
|
||||
import { FeatureInput } from "@/components/feature-input";
|
||||
import { TagInput } from "@/components/tag-input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
const toolSchema = z.object({
|
||||
@@ -77,6 +78,7 @@ export default function ToolNew() {
|
||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListAllTagsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
|
||||
setLocation(`/tools/${newTool.id}`);
|
||||
@@ -230,7 +232,7 @@ export default function ToolNew() {
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Features</h3>
|
||||
<p className="text-sm text-muted-foreground">List key capabilities. Start typing to see suggestions from existing tools.</p>
|
||||
<p className="text-sm text-muted-foreground">List key capabilities. Existing features from other tools are selectable.</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -283,7 +285,7 @@ export default function ToolNew() {
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Tags</h3>
|
||||
<p className="text-sm text-muted-foreground">Keywords to help find this tool.</p>
|
||||
<p className="text-sm text-muted-foreground">Keywords to help find this tool. Existing tags from other tools are selectable.</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -303,12 +305,13 @@ export default function ToolNew() {
|
||||
control={form.control}
|
||||
name={`tags.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-y-0 relative w-[150px]">
|
||||
<FormItem className="flex items-center space-y-0 relative w-[200px]">
|
||||
<FormControl>
|
||||
<Input
|
||||
<TagInput
|
||||
placeholder="Tag"
|
||||
className="pr-8 h-9 text-sm"
|
||||
{...field}
|
||||
onChange={field.onChange}
|
||||
value={field.value ?? ""}
|
||||
data-testid={`input-tag-${index}`}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
Reference in New Issue
Block a user