refactor: consolidate admin authorization into layout and introduce AdminShell component

This commit is contained in:
2026-06-30 22:56:25 +07:00 Unverified
parent 848f93641f
commit e2f480af62
12 changed files with 444 additions and 99 deletions
+78
View File
@@ -0,0 +1,78 @@
"use server";
import { readdirSync } from "fs";
import { join } from "path";
export async function refetchPlatform(
platform: string
): Promise<{ count: number | null }> {
// Validate platform against actual files to prevent path traversal
const dir = join(process.cwd(), "lib", "followers");
const validPlatforms = readdirSync(dir)
.filter((f) => f.endsWith(".ts") && f !== "index.ts")
.map((f) => f.replace(".ts", ""));
if (!validPlatforms.includes(platform)) {
throw new Error("Invalid platform");
}
let count: number | null = null;
let tiktokProfile = null;
try {
const mod = await import(`@/lib/followers/${platform}`);
const fns = Object.entries(mod).filter(
([, v]) => typeof v === "function"
) as [string, Function][];
// Prefer function with "Count" or "Member" in name
const countFn = fns.find(([name]) => /count|member/i.test(name));
const fn = countFn || fns[0];
if (fn) {
const result = await fn[1]();
if (typeof result === "number") {
count = result;
} else if (result === null) {
count = null;
} else if (
result &&
typeof result === "object" &&
"followerCount" in result
) {
count = result.followerCount;
tiktokProfile = result;
}
}
} catch (err) {
console.error(`Failed to fetch ${platform}:`, err);
throw new Error(`Failed to fetch ${platform}`);
}
// Update Redis cache
try {
const { getRedisClient } = await import("@/lib/redis");
const redis = await getRedisClient();
const cached = await redis.get("erika:follower_counts");
const counts: Record<string, number | null> = cached
? JSON.parse(cached)
: {};
counts[platform] = count;
await redis.set("erika:follower_counts", JSON.stringify(counts), {
ex: 3600,
});
if (tiktokProfile) {
await redis.set(
"erika:tiktok_profile",
JSON.stringify(tiktokProfile),
{ ex: 3600 }
);
}
} catch {
// Redis unavailable
}
return { count };
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { RefreshCw } from "lucide-react";
import { refetchPlatform } from "./actions";
import { toast } from "sonner";
interface Platform {
name: string;
cachedCount: number | null;
}
function formatCount(n: number | null): string {
if (n === null) return "No data";
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toString();
}
export function BackendClient({ platforms }: { platforms: Platform[] }) {
const [data, setData] = useState<Platform[]>(platforms);
const [loading, setLoading] = useState<string | null>(null);
const handleRefetch = async (platform: string) => {
setLoading(platform);
try {
const result = await refetchPlatform(platform);
setData((prev) =>
prev.map((p) =>
p.name === platform ? { ...p, cachedCount: result.count } : p
)
);
toast.success(`${platform} refetched successfully`);
} catch {
toast.error(`Failed to refetch ${platform}`);
} finally {
setLoading(null);
}
};
return (
<div className="grid gap-4 sm:grid-cols-2">
{data.map((p) => (
<Card key={p.name}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium capitalize">
{p.name}
</CardTitle>
<Badge
variant={p.cachedCount !== null ? "default" : "secondary"}
>
{formatCount(p.cachedCount)}
</Badge>
</CardHeader>
<CardContent>
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={loading !== null}
onClick={() => handleRefetch(p.name)}
>
<RefreshCw
className={`w-3 h-3 ${loading === p.name ? "animate-spin" : ""}`}
/>
Refetch
</Button>
</CardContent>
</Card>
))}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { AdminShell } from "@/components/admin-shell";
import { readdirSync } from "fs";
import { join } from "path";
import { BackendClient } from "./client";
export const dynamic = "force-dynamic";
export default async function BackendPage() {
// Auto-discover platform files from lib/followers/
const dir = join(process.cwd(), "lib", "followers");
const platforms = readdirSync(dir)
.filter((f) => f.endsWith(".ts") && f !== "index.ts")
.map((f) => f.replace(".ts", ""));
// Read current cached counts from Redis
let counts: Record<string, number | null> = {};
try {
const { getRedisClient } = await import("@/lib/redis");
const redis = await getRedisClient();
const cached = await redis.get("erika:follower_counts");
if (cached) {
counts = JSON.parse(cached);
}
} catch {
// Redis unavailable
}
const platformData = platforms.map((p) => ({
name: p,
cachedCount: counts[p] ?? null,
}));
return (
<AdminShell>
<div className="p-6 space-y-6 max-w-4xl mx-auto w-full">
<div>
<h1 className="text-3xl font-bold tracking-tight">Backend</h1>
<p className="text-muted-foreground mt-1">
Follower API connections and cache management
</p>
</div>
<BackendClient platforms={platformData} />
</div>
</AdminShell>
);
}
+1 -1
View File
@@ -14,7 +14,7 @@ import { Separator } from "@/components/ui/separator";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Checkbox } from "@/components/ui/checkbox";
import { toast } from "sonner";
import { updateForm } from "@/app/actions/form";
import { updateForm } from "@/app/admin/form/actions";
import {
createQuestion,
updateQuestion,
+1 -1
View File
@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { updateForm } from "@/app/actions/form";
import { updateForm } from "@/app/admin/form/actions";
import { Loader2, Save } from "lucide-react";
interface FormExtraClientProps {
-13
View File
@@ -1,6 +1,3 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AdminSidebar } from "./client";
import { Metadata } from "next";
@@ -19,16 +16,6 @@ export default async function AdminFormLayout(
}
) {
const params = await props.params;
const session = await getServerSession(authOptions);
const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const discordId = (session?.user as any)?.discordId;
if (!discordId || !adminIds.includes(discordId)) {
redirect("/form");
}
return (
<SidebarProvider>
@@ -51,5 +51,4 @@ export async function deleteForm(id: string) {
await db.delete(forms).where(eq(forms.id, id));
revalidatePath("/admin/form");
redirect("/admin/form");
}
+69 -82
View File
@@ -1,5 +1,4 @@
import { db } from "@/db";
import { forms } from "@/db/schema";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
@@ -11,103 +10,91 @@ import {
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Plus, Trash2 } from "lucide-react";
import { Plus } from "lucide-react";
import { redirect } from "next/navigation";
import { deleteForm } from "@/app/actions/form";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { DeleteFormButton } from "@/components/delete-buttons";
import { AdminShell } from "@/components/admin-shell";
export const dynamic = "force-dynamic";
export default async function AdminFormsPage() {
const session = await getServerSession(authOptions);
const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const discordId = (session?.user as any)?.discordId;
if (!discordId || !adminIds.includes(discordId)) {
redirect("/");
}
const formsList = await db.query.forms.findMany({
orderBy: (f, { desc }) => [desc(f.createdAt)],
});
return (
<div className="space-y-6 max-w-4xl mx-auto p-4 md:p-6 pb-12">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Forms</h1>
<p className="text-muted-foreground mt-1">
Manage your custom forms and view submissions.
</p>
<AdminShell>
<div className="space-y-6 p-4 md:p-6 pb-12 w-full">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Forms</h1>
<p className="text-muted-foreground mt-1">
Manage your custom forms and view submissions.
</p>
</div>
<form action={async () => {
"use server";
const { forms } = await import("@/db/schema");
const [newForm] = await db.insert(forms).values({
title: "New Form",
description: "",
}).returning();
redirect(`/admin/form/${newForm.id}`);
}}>
<Button type="submit">
<Plus className="mr-2 h-4 w-4" />
Create Form
</Button>
</form>
</div>
<form action={async () => {
"use server";
const [newForm] = await db.insert(forms).values({
title: "New Form",
description: "",
}).returning();
redirect(`/admin/form/${newForm.id}`);
}}>
<Button type="submit">
<Plus className="mr-2 h-4 w-4" />
Create Form
</Button>
</form>
</div>
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{formsList.length === 0 ? (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
No forms found. Create one to get started.
</TableCell>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
formsList.map((form) => (
<TableRow key={form.id}>
<TableCell className="font-medium">{form.title}</TableCell>
<TableCell>
{form.isOpen ? (
<Badge variant="default">Open</Badge>
) : (
<Badge variant="secondary">Closed</Badge>
)}
</TableCell>
<TableCell>
{form.createdAt ? new Date(form.createdAt).toLocaleDateString() : 'N/A'}
</TableCell>
<TableCell className="text-right space-x-2">
<Button variant="outline" asChild size="sm">
<Link href={`/admin/form/${form.id}`}>Edit</Link>
</Button>
<Button variant="outline" asChild size="sm">
<Link href={`/admin/form/${form.id}/result`}>Results</Link>
</Button>
<form className="inline-block" action={deleteForm.bind(null, form.id)}>
<Button variant="outline" size="sm" type="submit" className="text-destructive hover:text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</form>
</TableHeader>
<TableBody>
{formsList.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
No forms found. Create one to get started.
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
) : (
formsList.map((form) => (
<TableRow key={form.id}>
<TableCell className="font-medium">{form.title}</TableCell>
<TableCell>
{form.isOpen ? (
<Badge variant="default">Open</Badge>
) : (
<Badge variant="secondary">Closed</Badge>
)}
</TableCell>
<TableCell>
{form.createdAt ? new Date(form.createdAt).toLocaleDateString() : 'N/A'}
</TableCell>
<TableCell className="text-right space-x-2">
<Button variant="outline" asChild size="sm">
<Link href={`/admin/form/${form.id}`}>Edit</Link>
</Button>
<Button variant="outline" asChild size="sm">
<Link href={`/admin/form/${form.id}/result`}>Results</Link>
</Button>
<DeleteFormButton formId={form.id} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
</AdminShell>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const discordId = (session?.user as any)?.discordId;
if (!discordId || !adminIds.includes(discordId)) {
redirect("/");
}
return <>{children}</>;
}
+56
View File
@@ -0,0 +1,56 @@
import { AdminShell } from "@/components/admin-shell";
import Link from "next/link";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { ClipboardList, Server } from "lucide-react";
export default function AdminPage() {
return (
<AdminShell>
<div className="p-6 space-y-6 max-w-4xl mx-auto w-full">
<div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground mt-1">Admin control panel</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Link href="/admin/form">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<div className="flex items-center gap-3">
<ClipboardList className="w-5 h-5 text-primary" />
<div>
<CardTitle>Forms</CardTitle>
<CardDescription>
Manage custom forms and view submissions
</CardDescription>
</div>
</div>
</CardHeader>
</Card>
</Link>
<Link href="/admin/backend">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<div className="flex items-center gap-3">
<Server className="w-5 h-5 text-primary" />
<div>
<CardTitle>Backend</CardTitle>
<CardDescription>
Follower API settings and cache management
</CardDescription>
</div>
</div>
</CardHeader>
</Card>
</Link>
</div>
</div>
</AdminShell>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { usePathname } from "next/navigation";
import Link from "next/link";
import {
SidebarProvider,
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarHeader,
SidebarInset,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { ClipboardList, Server, LayoutDashboard } from "lucide-react";
export function AdminShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<SidebarProvider>
<div className="flex w-full min-h-screen bg-background text-foreground">
<Sidebar variant="sidebar" collapsible="icon">
<SidebarHeader className="border-b border-border py-4 px-4">
<span className="font-bold tracking-wider text-primary text-sm uppercase">
Admin
</span>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={pathname === "/admin"}
>
<Link href="/admin">
<LayoutDashboard className="w-4 h-4" />
<span>Dashboard</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={pathname === "/admin/form"}
>
<Link href="/admin/form">
<ClipboardList className="w-4 h-4" />
<span>Form</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={pathname.startsWith("/admin/backend")}
>
<Link href="/admin/backend">
<Server className="w-4 h-4" />
<span>Backend</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<SidebarInset>
<div className="md:hidden p-2">
<SidebarTrigger />
</div>
{children}
</SidebarInset>
</div>
</SidebarProvider>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Trash2, Loader2 } from "lucide-react";
import { deleteForm } from "@/app/actions/form";
import { deleteForm } from "@/app/admin/form/actions";
import { deleteSubmission, deleteAllSubmissions } from "@/app/actions/submissions";
import { toast } from "sonner";
import { useRouter } from "next/navigation";