feat(sase): read-only impersonation from user detail page

Adım 3 — Süper Panel side of the readonly impersonation flow.

- admin-sdk/sase.ts: impersonateReadonly(userId, founderId, ttlMinutes,
  reason) — POSTs to /internal/admin/users/:id/impersonate-readonly.
  notWiredSdk stub returns same shape so type contract holds when env vars
  are absent.
- POST /api/sase/users/[id]/impersonate-readonly — auth-checked panel
  endpoint. Validates ttl ∈ {15,30,60} + reason ≥ 5 chars. Calls spoke SDK,
  writes audit (double-audit: AdminClient also logs at the wire), returns
  { redirectUrl, expiresAt, sessionIdPrefix }.
- _impersonate-button.tsx (client): shadcn Dialog. TTL pills (15/30/60) +
  reason textarea + submit. On success opens spoke redirectUrl in new
  window/_blank/noopener.
- Detail header gains [Impersonate (read-only)] button — gated on
  saseAdminWired() so it stays hidden until SASE_ADMIN_API_BASE +
  INTERNAL_API_TOKEN_SASE land in Coolify env.

Spoke side (sase.tr@79a2616 → … → next release): InternalAdminModule +
ImpersonationReadonlyGuard already merged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-18 00:51:38 +03:00
parent 0b31685e95
commit d8c2bdd22f
4 changed files with 244 additions and 4 deletions

View File

@@ -0,0 +1,86 @@
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";
const VALID_TTLS = new Set([15, 30, 60]);
export async function POST(
req: Request,
ctx: { params: Promise<{ id: 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 { id } = await ctx.params;
const body = (await req.json().catch(() => ({}))) as {
ttlMinutes?: number;
reason?: string;
};
const ttlMinutes = Number(body.ttlMinutes);
if (!VALID_TTLS.has(ttlMinutes)) {
return NextResponse.json(
{ ok: false, error: "invalid_ttl" },
{ 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/users/${id}/impersonate-readonly`;
try {
const sdk = createSaseAdmin();
const result = await sdk.impersonateReadonly({
userId: id,
founderId: session.user.id,
ttlMinutes,
reason,
});
await writeAudit({
projectKey: "sase",
endpoint,
method: "POST",
requestPayload: { userId: id, ttlMinutes, reasonLen: reason.length },
responseStatus: 200,
});
return NextResponse.json({
ok: true,
redirectUrl: result.redirectUrl,
expiresAt: result.expiresAt,
sessionIdPrefix: result.sessionIdPrefix,
});
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
await writeAudit({
projectKey: "sase",
endpoint,
method: "POST",
requestPayload: { userId: id, ttlMinutes, reasonLen: reason.length },
responseStatus: 500,
});
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
const TTL_OPTIONS = [15, 30, 60];
export function ImpersonateButton({ userId, userName }: { userId: string; userName: string }) {
const [open, setOpen] = useState(false);
const [ttl, setTtl] = useState<number>(15);
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
function onSubmit() {
setError(null);
if (reason.trim().length < 5) {
setError("Sebep en az 5 karakter olmalı.");
return;
}
startTransition(async () => {
try {
const res = await fetch(`/api/sase/users/${userId}/impersonate-readonly`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ttlMinutes: ttl, reason: reason.trim() }),
});
const data = (await res.json().catch(() => ({}))) as {
ok?: boolean;
redirectUrl?: string;
error?: string;
};
if (!res.ok || !data.redirectUrl) {
setError(data.error ?? `HTTP ${res.status}`);
return;
}
// Reset form, close modal, open consume URL in a new tab.
setOpen(false);
setReason("");
setTtl(15);
window.open(data.redirectUrl, "_blank", "noopener,noreferrer");
} catch (e) {
setError(e instanceof Error ? e.message : "network error");
}
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" size="sm">
Impersonate (read-only)
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Read-only impersonate</DialogTitle>
<DialogDescription>
{userName} olarak Sase.tr'yi yeni sekmede aç. Salt-okunur — hiçbir
mutasyon aksiyonu çalışmaz. Her aksiyon audit'lenir.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1">
<Label>Süre</Label>
<div className="flex gap-1">
{TTL_OPTIONS.map((t) => (
<Button
key={t}
type="button"
variant={ttl === t ? "default" : "outline"}
size="sm"
onClick={() => setTtl(t)}
>
{t} dk
</Button>
))}
</div>
</div>
<div className="space-y-1">
<Label htmlFor="impersonate-reason">Sebep (audit'e yazılır, zorunlu)</Label>
<Textarea
id="impersonate-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Örn: kullanıcı VIN sorgusunda 400 hatası alıyor, ne gördüğünü incelemek için"
rows={3}
maxLength={500}
/>
<p className="text-xs text-muted-foreground">
{reason.length}/500 · min 5 karakter
</p>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={pending}>
İptal
</Button>
<Button onClick={onSubmit} disabled={pending}>
{pending ? "..." : `Yeni sekmede aç (${ttl} dk)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -30,6 +30,8 @@ import {
getUserAuditTrail,
} from "@/lib/sase/user-detail";
import { EmailReveal } from "./_email-reveal";
import { ImpersonateButton } from "./_impersonate-button";
import { saseAdminWired } from "@/lib/admin-sdk/sase";
export const dynamic = "force-dynamic";
@@ -71,11 +73,16 @@ export default async function SaseUserDetailPage({
</CardDescription>
<p className="font-mono text-xs text-muted-foreground">{user.id}</p>
</div>
<div className="text-right text-xs text-muted-foreground">
<p>Kayıt: {user.createdAt.toISOString().slice(0, 10)}</p>
{user.lastActivityAt && (
<p>Son aktivite: {user.lastActivityAt.toISOString().slice(0, 16).replace("T", " ")}</p>
<div className="flex flex-col items-end gap-2">
{saseAdminWired() && (
<ImpersonateButton userId={user.id} userName={user.name} />
)}
<div className="text-right text-xs text-muted-foreground">
<p>Kayıt: {user.createdAt.toISOString().slice(0, 10)}</p>
{user.lastActivityAt && (
<p>Son aktivite: {user.lastActivityAt.toISOString().slice(0, 16).replace("T", " ")}</p>
)}
</div>
</div>
</div>
</CardHeader>

View File

@@ -23,6 +23,18 @@ export type SaseAdmin = {
* Resend verification email. Spoke endpoint: POST /internal/admin/users/:id/resend-verification
*/
resendVerification(userId: string): Promise<{ ok: true }>;
/**
* Open a time-boxed readonly impersonation of a Sase.tr user. Returns a
* one-shot consume URL the founder's browser opens in a new tab to receive
* the session cookie. Spoke enforces readonly via ImpersonationReadonlyGuard.
*/
impersonateReadonly(input: {
userId: string;
founderId: string;
ttlMinutes: number;
reason: string;
}): Promise<{ success: boolean; redirectUrl: string; expiresAt: string; sessionIdPrefix: string }>;
};
export function createSaseAdmin(): SaseAdmin {
@@ -39,6 +51,12 @@ export function createSaseAdmin(): SaseAdmin {
client.call("DELETE", `/internal/admin/subscriptions/${id}`),
resendVerification: (id) =>
client.call("POST", `/internal/admin/users/${id}/resend-verification`),
impersonateReadonly: (input) =>
client.call("POST", `/internal/admin/users/${input.userId}/impersonate-readonly`, {
ttlMinutes: input.ttlMinutes,
reason: input.reason,
founderId: input.founderId,
}),
};
}
@@ -52,6 +70,7 @@ function notWiredSdk(projectKey: string): SaseAdmin {
setSubscription: () => Promise.reject(reject()),
cancelSubscription: () => Promise.reject(reject()),
resendVerification: () => Promise.reject(reject()),
impersonateReadonly: () => Promise.reject(reject()),
};
}
@@ -63,4 +82,5 @@ export const SASE_ADMIN_ENDPOINTS = [
"PATCH /internal/admin/users/:id/subscription",
"DELETE /internal/admin/subscriptions/:id",
"POST /internal/admin/users/:id/resend-verification",
"POST /internal/admin/users/:id/impersonate-readonly",
];