c8f4b50ef3
All literal "team" tier-value references renamed to "family" across API routes, admin UI, OpenAPI schemas, and lib/tiers.ts. The DB enum value itself is renamed in place via ALTER TYPE ... RENAME VALUE (migration 0044) rather than drizzle-kit's auto-generated drop-and-recreate-the-enum migration, which would have failed against any existing row still holding 'team' — RENAME VALUE preserves existing data with no cast/backfill needed. Also adds STRIPE_PLAN.md — a full Stripe billing integration plan (Checkout+Portal, tier→Price mapping, admin billing dashboard, and a multi-user Family-group design since Family is meant to cover several accounts under one subscription, not one payer). Planning only, no Stripe code yet. v0.47.0
84 lines
2.7 KiB
TypeScript
84 lines
2.7 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
interface UserEditorProps {
|
|
userId: string;
|
|
currentRole: "user" | "moderator" | "admin";
|
|
currentTier: "free" | "pro" | "family";
|
|
}
|
|
|
|
export function UserEditor({ userId, currentRole, currentTier }: UserEditorProps) {
|
|
const [role, setRole] = useState<"user" | "moderator" | "admin">(currentRole);
|
|
const [tier, setTier] = useState<"free" | "pro" | "family">(currentTier);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
async function handleSave() {
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/users/${userId}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ role, tier }),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
throw new Error((data as { error?: string }).error ?? "Failed to save");
|
|
}
|
|
toast.success("User updated successfully");
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Failed to save");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="role-select">Role</Label>
|
|
<Select value={role} onValueChange={(v) => setRole(v as typeof role)}>
|
|
<SelectTrigger id="role-select">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="user">User</SelectItem>
|
|
<SelectItem value="moderator">Moderator</SelectItem>
|
|
<SelectItem value="admin">Admin</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tier-select">Tier</Label>
|
|
<Select value={tier} onValueChange={(v) => setTier(v as typeof tier)}>
|
|
<SelectTrigger id="tier-select">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="free">Free</SelectItem>
|
|
<SelectItem value="pro">Pro</SelectItem>
|
|
<SelectItem value="family">Family</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving ? "Saving…" : "Save Changes"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|