feat(sase): brand picker — Phase F

Pairs with sase.tr#31 (POST /internal/admin/subscriptions/:id/brands).

Admin SDK
- setSubscriptionBrands({ subscriptionId, brandIds, reason, founderId }).
- BrandSetResult type; SASE_ADMIN_ENDPOINTS list updated.

Repo
- listBrands() helper (RO).
- getUser() now returns currentPlanBrandCount + brandIds for the picker.

Route
- POST /api/sase/subscriptions/[subId]/brands — auth + spoke-wired,
  brandIds array required, reason ≥ 5 chars. Audit on both paths.

UI (BrandPicker on the Billing tab, below subscription actions)
- Full plan (brandCount=0): shows "all brands automatic" badge list,
  no picker.
- Active/trial with brandCount > 0: checkbox grid of all active brands.
  Clicking past the plan limit shows an inline error. Visual deltas:
  outline "kaldırılacak" badge on rows being removed, default "yeni"
  badge on rows being added.
- Cancelled/expired: read-only message.
- Save button enabled only when selection is exactly plan.brandCount,
  there's a dirty diff, and a reason ≥ 5 chars is entered. Saves +
  router.refresh().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-18 10:38:34 +03:00
parent 0eacc32286
commit 096ca9b4a3
5 changed files with 342 additions and 2 deletions

View File

@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { createSaseAdmin, saseAdminWired } from "@/lib/admin-sdk/sase";
import { writeAudit } from "@/lib/audit";
export const dynamic = "force-dynamic";
export async function POST(
req: Request,
ctx: { params: Promise<{ subId: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
}
if (!saseAdminWired()) {
return NextResponse.json({ ok: false, error: "spoke_not_wired" }, { status: 503 });
}
const { subId } = await ctx.params;
const body = (await req.json().catch(() => ({}))) as {
brandIds?: string[];
reason?: string;
};
if (!Array.isArray(body.brandIds)) {
return NextResponse.json({ ok: false, error: "brandIds_required" }, { status: 400 });
}
const reason = (body.reason ?? "").trim();
if (reason.length < 5) {
return NextResponse.json({ ok: false, error: "reason_required" }, { status: 400 });
}
const endpoint = `/api/sase/subscriptions/${subId}/brands`;
try {
const sdk = createSaseAdmin();
const result = await sdk.setSubscriptionBrands({
subscriptionId: subId,
brandIds: body.brandIds,
reason,
founderId: session.user.id,
});
await writeAudit({
projectKey: "sase",
endpoint,
method: "POST",
requestPayload: {
subId,
brandCount: body.brandIds.length,
reasonLen: reason.length,
},
responseStatus: 200,
});
return NextResponse.json({ ok: true, result });
} catch (err) {
const status = (err as { status?: number } | undefined)?.status ?? 500;
const message = err instanceof Error ? err.message : "unknown";
await writeAudit({
projectKey: "sase",
endpoint,
method: "POST",
requestPayload: { subId, brandCount: body.brandIds.length, reasonLen: reason.length },
responseStatus: status,
});
return NextResponse.json({ ok: false, error: message }, { status });
}
}

View File

@@ -0,0 +1,222 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
type Brand = { id: string; name: string; slug: string };
export function BrandPicker({
subscriptionId,
subscriptionStatus,
planBrandCount,
currentBrandIds,
allBrands,
}: {
subscriptionId: string;
subscriptionStatus: string;
planBrandCount: number | null;
currentBrandIds: string[];
allBrands: Brand[];
}) {
const router = useRouter();
const [selected, setSelected] = useState<Set<string>>(
new Set(currentBrandIds),
);
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const canEdit =
(subscriptionStatus === "active" || subscriptionStatus === "trial") &&
planBrandCount !== null &&
planBrandCount > 0;
// Full plan (brandCount = 0) auto-grants all brands; no picker.
if (planBrandCount === 0) {
return (
<Card>
<CardHeader>
<CardDescription>Marka erişimi</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<p>Full plan tüm aktif markalara otomatik erişim ({allBrands.length}).</p>
<div className="flex flex-wrap gap-1">
{allBrands.map((b) => (
<Badge key={b.id} variant="secondary" className="font-mono">
{b.slug}
</Badge>
))}
</div>
</CardContent>
</Card>
);
}
if (!canEdit) {
return (
<Card>
<CardHeader>
<CardDescription>Marka erişimi</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{subscriptionStatus === "cancelled" || subscriptionStatus === "expired"
? `'${subscriptionStatus}' subscription — düzenlenemez.`
: "Subscription yok ya da plan tanımlı değil."}
</CardContent>
</Card>
);
}
function toggle(id: string) {
setError(null);
setInfo(null);
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
if (next.size >= (planBrandCount ?? 0)) {
setError(`Maksimum ${planBrandCount} marka seçebilirsin. Önce birini çıkar.`);
return prev;
}
next.add(id);
}
return next;
});
}
function submit() {
setError(null);
setInfo(null);
if (selected.size !== planBrandCount) {
setError(
`Tam olarak ${planBrandCount} marka seçilmeli (şu an ${selected.size}).`,
);
return;
}
if (reason.trim().length < 5) {
setError("Sebep en az 5 karakter olmalı.");
return;
}
startTransition(async () => {
const res = await fetch(`/api/sase/subscriptions/${subscriptionId}/brands`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
brandIds: Array.from(selected),
reason: reason.trim(),
}),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? `HTTP ${res.status}`);
return;
}
setInfo("Markalar güncellendi.");
setReason("");
router.refresh();
});
}
const dirty =
selected.size !== currentBrandIds.length ||
Array.from(selected).some((id) => !currentBrandIds.includes(id));
return (
<Card>
<CardHeader>
<CardDescription>
Marka erişimi · seçilen {selected.size}/{planBrandCount}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<ul className="grid grid-cols-2 gap-1 md:grid-cols-3">
{allBrands.map((b) => {
const isSelected = selected.has(b.id);
const isCurrent = currentBrandIds.includes(b.id);
return (
<li key={b.id}>
<label className="flex items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted/30">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggle(b.id)}
/>
<span className="flex-1">{b.name}</span>
<span className="font-mono text-xs text-muted-foreground">
{b.slug}
</span>
{isCurrent && !isSelected && (
<Badge variant="outline" className="text-xs">
kaldırılacak
</Badge>
)}
{!isCurrent && isSelected && (
<Badge variant="default" className="text-xs">
yeni
</Badge>
)}
</label>
</li>
);
})}
</ul>
{dirty && (
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Sebep (zorunlu, audit'e geçer)"
rows={2}
maxLength={500}
/>
)}
<div className="flex items-center justify-between">
<div className="text-xs">
{error && <span className="text-destructive">{error}</span>}
{info && <span className="text-emerald-600">{info}</span>}
</div>
<div className="flex gap-2">
{dirty && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelected(new Set(currentBrandIds));
setReason("");
setError(null);
}}
disabled={pending}
>
Sıfırla
</Button>
)}
<Button
size="sm"
onClick={submit}
disabled={
pending ||
!dirty ||
selected.size !== planBrandCount ||
reason.trim().length < 5
}
>
{pending ? "..." : "Kaydet"}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -23,7 +23,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getUser, listPlans } from "@/lib/sase/users";
import { getUser, listBrands, listPlans } from "@/lib/sase/users";
import {
getUserUsageStats,
getUserTimeline,
@@ -34,6 +34,7 @@ import { NotesTab } from "./_notes";
import { LifecycleButtons, LifecycleStatusBadge } from "./_lifecycle-buttons";
import { BillingActions } from "./_billing-actions";
import { RefundButton } from "./_refund-button";
import { BrandPicker } from "./_brand-picker";
import { saseAdminWired } from "@/lib/admin-sdk/sase";
export const dynamic = "force-dynamic";
@@ -47,11 +48,12 @@ export default async function SaseUserDetailPage({
const user = await getUser(id);
if (!user) notFound();
const [usage, timeline, audit, plans] = await Promise.all([
const [usage, timeline, audit, plans, brands] = await Promise.all([
getUserUsageStats(id),
getUserTimeline(id, 80),
getUserAuditTrail(id, 50),
listPlans(),
listBrands(),
]);
return (
@@ -188,6 +190,16 @@ export default async function SaseUserDetailPage({
</CardContent>
</Card>
{saseAdminWired() && user.subscriptionId && user.subscriptionStatus && (
<BrandPicker
subscriptionId={user.subscriptionId}
subscriptionStatus={user.subscriptionStatus}
planBrandCount={user.currentPlanBrandCount}
currentBrandIds={user.brandIds}
allBrands={brands}
/>
)}
<div className="rounded-md border">
<Table>
<TableHeader>