151 lines
4.7 KiB
TypeScript
151 lines
4.7 KiB
TypeScript
import { type NextRequest } from 'next/server';
|
|
import { requireAdminApi } from '@/lib/auth/admin-guard';
|
|
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
|
import { withAdminRetry } from '@/lib/admin/retry';
|
|
import { logAdminAction } from '@/lib/auth/audit';
|
|
import { isUuid } from '@/lib/admin/validators';
|
|
import { jsonNoStore } from '@/lib/admin/response';
|
|
|
|
export const runtime = 'nodejs';
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
type TunnelRow = {
|
|
user_id: string;
|
|
subdomain: string;
|
|
token: string;
|
|
is_active: boolean;
|
|
bytes_used: number;
|
|
quota_bytes: number;
|
|
last_seen_at: string | null;
|
|
created_at: string;
|
|
};
|
|
|
|
export async function GET(
|
|
_req: NextRequest,
|
|
{ params }: { params: { id: string } },
|
|
) {
|
|
const auth = await requireAdminApi();
|
|
if (!auth.ok) return auth.response;
|
|
|
|
const { id } = params;
|
|
if (!isUuid(id)) {
|
|
return jsonNoStore({ error: 'invalid user id' }, { status: 400 });
|
|
}
|
|
|
|
const admin = getSupabaseAdmin();
|
|
|
|
// Retry transient empty-body GoTrue responses so a burst-induced flake isn't
|
|
// misreported as a 404 for a user that actually exists. A genuine not-found
|
|
// (non-transient) still falls through to the clean 404 below.
|
|
const { data: userRes, error: userErr } = await withAdminRetry(() =>
|
|
admin.auth.admin.getUserById(id),
|
|
);
|
|
if (userErr || !userRes.user) {
|
|
return jsonNoStore({ error: 'user not found' }, { status: 404 });
|
|
}
|
|
const u = userRes.user;
|
|
|
|
const { data: tunnel } = await admin
|
|
.from('tunnels')
|
|
.select(
|
|
'user_id, subdomain, token, is_active, bytes_used, quota_bytes, last_seen_at, created_at',
|
|
)
|
|
.eq('user_id', id)
|
|
.maybeSingle<TunnelRow>();
|
|
|
|
const { data: audit } = await admin
|
|
.from('admin_audit_log')
|
|
.select('id, actor_email, action, target_type, target_id, details, created_at')
|
|
.eq('target_id', id)
|
|
.order('created_at', { ascending: false })
|
|
.limit(25);
|
|
|
|
return jsonNoStore({
|
|
user: {
|
|
id: u.id,
|
|
email: u.email ?? null,
|
|
role: (u.app_metadata?.role as string | undefined) ?? 'user',
|
|
banned_until:
|
|
(u as unknown as { banned_until?: string | null }).banned_until ?? null,
|
|
email_confirmed_at: u.email_confirmed_at ?? null,
|
|
created_at: u.created_at,
|
|
last_sign_in_at: u.last_sign_in_at ?? null,
|
|
},
|
|
tunnel: tunnel
|
|
? {
|
|
subdomain: tunnel.subdomain,
|
|
is_active: tunnel.is_active,
|
|
bytes_used: tunnel.bytes_used,
|
|
quota_bytes: tunnel.quota_bytes,
|
|
last_seen_at: tunnel.last_seen_at,
|
|
created_at: tunnel.created_at,
|
|
}
|
|
: null,
|
|
audit: audit ?? [],
|
|
});
|
|
}
|
|
|
|
export async function DELETE(
|
|
_req: NextRequest,
|
|
{ params }: { params: { id: string } },
|
|
) {
|
|
const auth = await requireAdminApi();
|
|
if (!auth.ok) return auth.response;
|
|
|
|
const { id } = params;
|
|
if (!isUuid(id)) {
|
|
return jsonNoStore({ error: 'invalid user id' }, { status: 400 });
|
|
}
|
|
if (id === auth.user.id) {
|
|
return jsonNoStore(
|
|
{ error: 'you cannot delete your own account' },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const admin = getSupabaseAdmin();
|
|
|
|
// Confirm the user exists up front. GoTrue replies with an empty body when
|
|
// deleting a non-existent user, which supabase-js surfaces as an opaque
|
|
// JSON-parse error (no status / "not found" text); a positive existence
|
|
// check lets us return a clean 404 instead of a misleading 500. Retry the
|
|
// lookup so a transient empty body under load isn't mistaken for not-found.
|
|
const { data: existing, error: lookupErr } = await withAdminRetry(() =>
|
|
admin.auth.admin.getUserById(id),
|
|
);
|
|
if (lookupErr || !existing.user) {
|
|
return jsonNoStore({ error: 'user not found' }, { status: 404 });
|
|
}
|
|
|
|
// Delete the AUTH USER first. Only if that succeeds do we remove the tunnel
|
|
// row, so a mid-failure never leaves an orphaned auth user with a dangling
|
|
// tunnel (or half-deletes the tunnel of an already-gone user).
|
|
const { error: delErr } = await admin.auth.admin.deleteUser(id);
|
|
if (delErr) {
|
|
console.error('admin user.delete: deleteUser failed', delErr);
|
|
return jsonNoStore({ error: 'internal error' }, { status: 500 });
|
|
}
|
|
|
|
// The auth user is gone, so no orphaned-user state is possible. Removing the
|
|
// tunnel row is best-effort cleanup: if it fails we log server-side but still
|
|
// report the (already-completed) user deletion as successful.
|
|
const { error: tunnelErr } = await admin
|
|
.from('tunnels')
|
|
.delete()
|
|
.eq('user_id', id);
|
|
if (tunnelErr) {
|
|
console.error(
|
|
`admin user.delete: tunnel cleanup failed for ${id}`,
|
|
tunnelErr,
|
|
);
|
|
}
|
|
|
|
await logAdminAction(auth.user, {
|
|
action: 'user.delete',
|
|
target_type: 'user',
|
|
target_id: id,
|
|
});
|
|
|
|
return jsonNoStore({ ok: true });
|
|
}
|