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:
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user