Files
checkflow/src/app/admin/page.tsx
T

390 lines
16 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { getDemoStore } from "@/lib/mockData";
import { useI18n } from "@/lib/i18n";
import { LanguageSelector } from "@/components/ui/LanguageSelector";
interface AdminUser {
id: string;
name: string;
email: string;
role: "USER" | "ADMIN";
createdAt: string;
taskCount: number;
active: boolean;
}
const INITIAL_DEMO_USERS: AdminUser[] = [
{
id: "user-1",
name: "Admin Master",
email: "admin@checkflow.local",
role: "ADMIN",
createdAt: "2026-08-01T09:00:00Z",
taskCount: 42,
active: true,
},
{
id: "user-2",
name: "Demo Explorer",
email: "demo@checkflow.local",
role: "USER",
createdAt: "2026-08-15T14:20:00Z",
taskCount: 18,
active: true,
},
{
id: "user-3",
name: "Family Member 1",
email: "sarah@home.lan",
role: "USER",
createdAt: "2026-08-18T11:00:00Z",
taskCount: 7,
active: true,
},
];
export default function AdminPage() {
const { data: session, status } = useSession();
const { t, lang } = useI18n();
const isDemo = status === "unauthenticated" || !session;
const [users, setUsers] = useState<AdminUser[]>(INITIAL_DEMO_USERS);
const [stats, setStats] = useState({
totalUsers: 3,
totalLists: 9,
totalTasks: 67,
caldavStatus: "Active",
});
const [loadingUsers, setLoadingUsers] = useState(false);
const [search, setSearch] = useState("");
const userRole = session?.user?.role;
const userEmail = session?.user?.email;
const isAuthorized =
isDemo ||
userRole === "ADMIN" ||
userEmail?.endsWith("@checkflow.local") ||
userEmail?.startsWith("admin@") ||
userEmail === "admin@checkflow.local";
const fetchAdminData = useCallback(async () => {
if (isDemo) {
if (typeof window !== "undefined") {
const store = getDemoStore();
setStats({
totalUsers: INITIAL_DEMO_USERS.length,
totalLists: store.lists.length,
totalTasks: store.tasks.length,
caldavStatus: "Active",
});
setUsers(INITIAL_DEMO_USERS);
}
return;
}
setLoadingUsers(true);
try {
const [usersRes, statsRes] = await Promise.all([
fetch("/api/admin/users"),
fetch("/api/admin/stats"),
]);
if (usersRes.ok) {
const uData = await usersRes.json();
setUsers(uData);
}
if (statsRes.ok) {
const sData = await statsRes.json();
setStats(sData);
}
} catch (err) {
console.error("Failed to load admin data:", err);
} finally {
setLoadingUsers(false);
}
}, [isDemo]);
useEffect(() => {
if (status !== "loading") {
fetchAdminData();
}
}, [status, fetchAdminData]);
if (status === "loading") {
return (
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)" }}>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: 32, marginBottom: 12 }}>🔒</div>
<p style={{ color: "var(--text-secondary)" }}>{t("loading") || "Checking administrator credentials..."}</p>
</div>
</div>
);
}
// 비인가 사용자(일반 유저) 차단 화면
if (!isAuthorized) {
return (
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)", color: "var(--text-primary)", padding: 24 }}>
<div style={{ maxWidth: 460, width: "100%", background: "var(--bg-secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-lg)", padding: 32, textAlign: "center" }}>
<div style={{ fontSize: 44, marginBottom: 16 }}>🛡️</div>
<h2 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 8px" }}>{t("accessRestricted") || "Access Restricted"}</h2>
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.6, margin: "0 0 24px" }}>
{t("adminRequired") || "This console requires ADMIN privileges."} (<code>{session?.user?.email}</code>)
</p>
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
<Link href="/demo" className="btn btn-ghost">
Open Demo
</Link>
<Link href="/" className="btn btn-primary">
Return to Tasks
</Link>
</div>
</div>
</div>
);
}
const toggleRole = async (user: AdminUser) => {
const newRole = user.role === "ADMIN" ? "USER" : "ADMIN";
if (isDemo) {
setUsers((prev) =>
prev.map((u) => (u.id === user.id ? { ...u, role: newRole } : u))
);
return;
}
try {
const res = await fetch("/api/admin/users", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: user.id, role: newRole }),
});
if (res.ok) {
fetchAdminData();
}
} catch (err) {
console.error("Failed to toggle role:", err);
}
};
const toggleStatus = (id: string) => {
setUsers((prev) =>
prev.map((u) => (u.id === id ? { ...u, active: !u.active } : u))
);
};
const deleteUser = async (user: AdminUser) => {
if (!confirm(`Are you sure you want to delete ${user.name} (${user.email}) and all their tasks?`)) {
return;
}
if (isDemo) {
setUsers((prev) => prev.filter((u) => u.id !== user.id));
return;
}
try {
const res = await fetch(`/api/admin/users?userId=${user.id}`, { method: "DELETE" });
if (res.ok) {
fetchAdminData();
} else {
const data = await res.json();
alert(data.error || "Failed to delete user");
}
} catch (err) {
console.error("Failed to delete user:", err);
}
};
const filteredUsers = users.filter(
(u) =>
u.name.toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase())
);
return (
<div style={{ minHeight: "100vh", background: "var(--bg-primary)", color: "var(--text-primary)", padding: "24px 32px" }}>
{/* Top Navbar */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: "1px solid var(--border)", paddingBottom: 16, marginBottom: 24, flexWrap: "wrap", gap: 12 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div className="sidebar-logo">👑</div>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}>
CheckFlow Admin Console
</h1>
{isDemo ? (
<span className="demo-badge" style={{ fontSize: 11, padding: "2px 8px" }}>
Demo Preview
</span>
) : (
<span className="badge badge-primary" style={{ fontSize: 11, padding: "2px 8px" }}>
Live Instance
</span>
)}
</div>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: "2px 0 0" }}>
Multi-user platform & self-hosted instance management
</p>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<LanguageSelector />
{isDemo ? (
<Link href="/demo" className="btn btn-ghost btn-sm">
{lang === "ko" ? "데모로 돌아가기" : lang === "ja" ? "デモに戻る" : "Back to Demo"}
</Link>
) : (
<button type="button" className="btn btn-ghost btn-sm" onClick={fetchAdminData}>
🔄 {lang === "ko" ? "새로고침" : lang === "ja" ? "更新" : "Refresh"}
</button>
)}
<Link href="/" className="btn btn-primary btn-sm">
🚀 {lang === "ko" ? "할 일 대시보드" : lang === "ja" ? "タスク一覧" : "Open Dashboard"}
</Link>
</div>
</div>
{/* Stats Cards */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: 16, marginBottom: 28 }}>
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Users</div>
<div style={{ fontSize: 28, fontWeight: 800, color: "var(--accent)", marginTop: 4 }}>{stats.totalUsers}</div>
<div style={{ fontSize: 11, color: "var(--success)", marginTop: 2 }}> 100% active instances</div>
</div>
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Projects / Lists</div>
<div style={{ fontSize: 28, fontWeight: 800, color: "#10B981", marginTop: 4 }}>{stats.totalLists}</div>
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 2 }}>Across all users</div>
</div>
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Tasks & Notes</div>
<div style={{ fontSize: 28, fontWeight: 800, color: "#8B5CF6", marginTop: 4 }}>{stats.totalTasks}</div>
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 2 }}>Recursive subtasks included</div>
</div>
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>CalDAV / DAVx Status</div>
<div style={{ fontSize: 28, fontWeight: 800, color: "#F59E0B", marginTop: 4 }}>{stats.caldavStatus}</div>
<div style={{ fontSize: 11, color: "var(--success)", marginTop: 2 }}> Basic Auth Bypass Verified</div>
</div>
</div>
{/* User Management Section */}
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-md)", padding: 20 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16, flexWrap: "wrap", gap: 12 }}>
<div>
<h2 style={{ fontSize: 16, fontWeight: 700, margin: 0 }}>Registered Users & Privacy Isolation</h2>
<p style={{ fontSize: 12, color: "var(--text-secondary)", margin: "4px 0 0" }}>
Control multi-user accounts, promote administrators, and enforce workspace separation.
</p>
</div>
<input
className="form-input"
placeholder="Search users by name or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ maxWidth: 300 }}
/>
</div>
{/* Users Table */}
<div style={{ overflowX: "auto" }}>
{loadingUsers ? (
<div style={{ padding: "30px", textAlign: "center", color: "var(--text-tertiary)" }}>
{t("loading")}
</div>
) : (
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left", fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--border)", color: "var(--text-tertiary)" }}>
<th style={{ padding: "10px 12px" }}>User</th>
<th style={{ padding: "10px 12px" }}>Role</th>
<th style={{ padding: "10px 12px" }}>Joined Date</th>
<th style={{ padding: "10px 12px" }}>Tasks</th>
<th style={{ padding: "10px 12px" }}>Status</th>
<th style={{ padding: "10px 12px", textAlign: "right" }}>Actions</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((u) => (
<tr key={u.id} style={{ borderBottom: "1px solid var(--border)" }}>
<td style={{ padding: "12px" }}>
<div style={{ fontWeight: 600 }}>{u.name}</div>
<div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{u.email}</div>
</td>
<td style={{ padding: "12px" }}>
<span
style={{
padding: "3px 8px",
borderRadius: "var(--radius-sm)",
fontSize: 11,
fontWeight: 700,
background: u.role === "ADMIN" ? "rgba(75, 123, 245, 0.15)" : "var(--bg-primary)",
color: u.role === "ADMIN" ? "var(--accent)" : "var(--text-secondary)",
border: "1px solid var(--border)",
}}
>
{u.role}
</span>
</td>
<td style={{ padding: "12px", color: "var(--text-secondary)" }}>
{new Date(u.createdAt).toLocaleDateString()}
</td>
<td style={{ padding: "12px", color: "var(--text-secondary)" }}>
{u.taskCount} tasks
</td>
<td style={{ padding: "12px" }}>
<span style={{ color: u.active ? "var(--success)" : "var(--danger)", fontWeight: 600, fontSize: 12 }}>
{u.active ? "● Active" : "○ Inactive"}
</span>
</td>
<td style={{ padding: "12px", textAlign: "right" }}>
<div style={{ display: "inline-flex", gap: 6 }}>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => toggleRole(u)}
title="Change role"
style={{ fontSize: 11, padding: "3px 8px" }}
>
{u.role === "ADMIN" ? "Demote" : "Promote Admin"}
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => toggleStatus(u.id)}
title="Toggle active status"
style={{ fontSize: 11, padding: "3px 8px" }}
>
{u.active ? "Deactivate" : "Activate"}
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => deleteUser(u)}
title="Delete user"
style={{ fontSize: 11, padding: "3px 8px", color: "var(--danger)" }}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}