feat: admin dashboard
This commit is contained in:
+259
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Admin API client. Talks to the same Go BFF as the main app, reusing its
|
||||
* cookie session: auth uses the shared /auth/* endpoints, and every admin
|
||||
* action lives under /admin/* (gated server-side by RequireAdmin). All
|
||||
* requests send credentials so the httpOnly session cookie travels.
|
||||
*
|
||||
* For this to work cross-origin, the BFF must list this app's origin in CORS
|
||||
* via the ADMIN_URL env var (see .env.example).
|
||||
*/
|
||||
|
||||
const BFF = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
|
||||
|
||||
export class AdminError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AdminError";
|
||||
}
|
||||
}
|
||||
|
||||
type Envelope<T> = {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: { code: number; message: string };
|
||||
meta?: { total?: number; page?: number; limit?: number; total_pages?: number };
|
||||
};
|
||||
|
||||
async function call<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<{ data: T; meta?: Envelope<T>["meta"] }> {
|
||||
const res = await fetch(`${BFF}/api/v1${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||
...init,
|
||||
});
|
||||
let body: Envelope<T>;
|
||||
try {
|
||||
body = (await res.json()) as Envelope<T>;
|
||||
} catch {
|
||||
throw new AdminError(res.status, res.statusText || "Request failed");
|
||||
}
|
||||
if (!res.ok || body.success === false) {
|
||||
throw new AdminError(
|
||||
res.status,
|
||||
body.error?.message || res.statusText || "Request failed",
|
||||
);
|
||||
}
|
||||
return { data: body.data as T, meta: body.meta };
|
||||
}
|
||||
|
||||
export type Paged<T> = { items: T[]; total: number };
|
||||
|
||||
async function listed<T>(path: string): Promise<Paged<T>> {
|
||||
const { data, meta } = await call<T[]>(path);
|
||||
const items = data ?? [];
|
||||
return { items, total: meta?.total ?? items.length };
|
||||
}
|
||||
|
||||
function qs(params: Record<string, string | number | undefined>): string {
|
||||
const u = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== "") u.set(k, String(v));
|
||||
}
|
||||
const s = u.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
// ─── Types (mirror the BFF admin DTOs) ──────────────────────────────────────
|
||||
|
||||
export type Me = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
handle: string;
|
||||
name: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
export type AdminUser = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
handle: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
plan: string;
|
||||
email_verified_at: string | null;
|
||||
banned_at: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type AdminPost = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
caption: string;
|
||||
source_title: string;
|
||||
source_url: string;
|
||||
is_hidden: boolean;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
author_name: string;
|
||||
author_handle: string;
|
||||
likes_count: number;
|
||||
comments_count: number;
|
||||
};
|
||||
|
||||
export type AdminCollection = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
is_public: boolean;
|
||||
item_count: number;
|
||||
created_at: string;
|
||||
owner_name: string;
|
||||
owner_handle: string;
|
||||
};
|
||||
|
||||
export type AdminFeedback = {
|
||||
id: string;
|
||||
category: string;
|
||||
message: string;
|
||||
contact_email: string | null;
|
||||
created_at: string;
|
||||
resolved_at: string | null;
|
||||
replied_at: string | null;
|
||||
reply_message: string | null;
|
||||
author_handle: string | null;
|
||||
author_name: string | null;
|
||||
author_email: string | null;
|
||||
};
|
||||
|
||||
export type ReportType = "post" | "comment" | "user";
|
||||
|
||||
export type AdminReport = {
|
||||
id: string;
|
||||
type: ReportType;
|
||||
category: string;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
resolved_at: string | null;
|
||||
reporter_handle: string | null;
|
||||
reporter_name: string | null;
|
||||
target_id: string;
|
||||
target_text: string | null;
|
||||
target_author_handle: string | null;
|
||||
};
|
||||
|
||||
// Login can complete immediately (session) or require an emailed OTP (the
|
||||
// BFF's 30-day-stale gate). The admin login form handles both.
|
||||
export type LoginResult =
|
||||
| { kind: "session"; user: Me }
|
||||
| { kind: "otp_required" };
|
||||
|
||||
// ─── API surface ────────────────────────────────────────────────────────────
|
||||
|
||||
export const api = {
|
||||
// Auth (shared endpoints)
|
||||
async login(email: string, password: string): Promise<LoginResult> {
|
||||
const { data } = await call<LoginResult>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
return data;
|
||||
},
|
||||
async verifyLoginOtp(
|
||||
email: string,
|
||||
password: string,
|
||||
otp: string,
|
||||
): Promise<Me> {
|
||||
const { data } = await call<{ user: Me }>("/auth/verify-login-otp", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password, otp }),
|
||||
});
|
||||
return data.user;
|
||||
},
|
||||
async me(): Promise<Me | null> {
|
||||
try {
|
||||
const { data } = await call<Me>("/auth/me");
|
||||
return data;
|
||||
} catch (e) {
|
||||
if (e instanceof AdminError && e.status === 401) return null;
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
async logout(): Promise<void> {
|
||||
await call("/auth/logout", { method: "POST" });
|
||||
},
|
||||
async forgotPassword(email: string): Promise<void> {
|
||||
await call("/auth/forgot-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
},
|
||||
async resetPassword(token: string, password: string): Promise<void> {
|
||||
await call("/auth/reset-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
},
|
||||
|
||||
// Admin resources
|
||||
users(q: string, limit: number, offset: number) {
|
||||
return listed<AdminUser>(`/admin/users${qs({ q, limit, offset })}`);
|
||||
},
|
||||
notifyUser(id: string, title: string, body: string) {
|
||||
return call(`/admin/users/${id}/notify`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, body }),
|
||||
});
|
||||
},
|
||||
banUser(id: string) {
|
||||
return call(`/admin/users/${id}/ban`, { method: "POST" });
|
||||
},
|
||||
unbanUser(id: string) {
|
||||
return call(`/admin/users/${id}/ban`, { method: "DELETE" });
|
||||
},
|
||||
posts(q: string, limit: number, offset: number) {
|
||||
return listed<AdminPost>(`/admin/posts${qs({ q, limit, offset })}`);
|
||||
},
|
||||
hidePost(id: string) {
|
||||
return call(`/admin/posts/${id}/hide`, { method: "POST" });
|
||||
},
|
||||
unhidePost(id: string) {
|
||||
return call(`/admin/posts/${id}/hide`, { method: "DELETE" });
|
||||
},
|
||||
deletePost(id: string) {
|
||||
return call(`/admin/posts/${id}`, { method: "DELETE" });
|
||||
},
|
||||
restorePost(id: string) {
|
||||
return call(`/admin/posts/${id}/restore`, { method: "POST" });
|
||||
},
|
||||
deleteComment(id: string) {
|
||||
return call(`/admin/comments/${id}`, { method: "DELETE" });
|
||||
},
|
||||
restoreComment(id: string) {
|
||||
return call(`/admin/comments/${id}/restore`, { method: "POST" });
|
||||
},
|
||||
collections(q: string, limit: number, offset: number) {
|
||||
return listed<AdminCollection>(`/admin/collections${qs({ q, limit, offset })}`);
|
||||
},
|
||||
feedback(status: string, limit: number, offset: number) {
|
||||
return listed<AdminFeedback>(`/admin/feedback${qs({ status, limit, offset })}`);
|
||||
},
|
||||
replyFeedback(id: string, message: string) {
|
||||
return call(`/admin/feedback/${id}/reply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
},
|
||||
reports(type: ReportType, q: string, limit: number, offset: number) {
|
||||
return listed<AdminReport>(`/admin/reports${qs({ type, q, limit, offset })}`);
|
||||
},
|
||||
resolveReport(type: ReportType, id: string) {
|
||||
return call(`/admin/reports/${type}/${id}/resolve`, { method: "POST" });
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user