feat(ux): enhance n-depth tree DND reordering, full i18n localization, admin live stats & sidebar redesign
Build and Push Docker Image / build-and-push (push) Successful in 9m22s
Build and Push Docker Image / build-and-push (push) Successful in 9m22s
This commit is contained in:
+198
-115
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { getDemoStore } from "@/lib/mockData";
|
||||
@@ -15,7 +15,7 @@ interface AdminUser {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const INITIAL_ADMIN_USERS: AdminUser[] = [
|
||||
const INITIAL_DEMO_USERS: AdminUser[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Admin Master",
|
||||
@@ -49,27 +49,18 @@ export default function AdminPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>(INITIAL_ADMIN_USERS);
|
||||
const [search, setSearch] = useState("");
|
||||
const [totalTasks] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const store = getDemoStore();
|
||||
return store.tasks ? store.tasks.length : 67;
|
||||
}
|
||||
return 67;
|
||||
});
|
||||
const [totalLists] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const store = getDemoStore();
|
||||
return store.lists ? store.lists.length : 9;
|
||||
}
|
||||
return 9;
|
||||
});
|
||||
|
||||
// Is this running in Demo mode (no session or unauthenticated)
|
||||
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 =
|
||||
@@ -79,7 +70,49 @@ export default function AdminPage() {
|
||||
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)" }}>
|
||||
@@ -114,10 +147,27 @@ export default function AdminPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const toggleRole = (id: string) => {
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === id ? { ...u, role: u.role === "ADMIN" ? "USER" : "ADMIN" } : u))
|
||||
);
|
||||
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) => {
|
||||
@@ -126,9 +176,26 @@ export default function AdminPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const deleteUser = (id: string) => {
|
||||
if (confirm("Are you sure you want to delete this user and all their tasks?")) {
|
||||
setUsers((prev) => prev.filter((u) => u.id !== id));
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -149,10 +216,14 @@ export default function AdminPage() {
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}>
|
||||
CheckFlow Admin Console
|
||||
</h1>
|
||||
{isDemo && (
|
||||
{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" }}>
|
||||
@@ -162,9 +233,15 @@ export default function AdminPage() {
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Link href="/demo" className="btn btn-ghost btn-sm">
|
||||
← Back to Demo
|
||||
</Link>
|
||||
{isDemo ? (
|
||||
<Link href="/demo" className="btn btn-ghost btn-sm">
|
||||
← Back to Demo
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={fetchAdminData}>
|
||||
🔄 Refresh Data
|
||||
</button>
|
||||
)}
|
||||
<Link href="/" className="btn btn-primary btn-sm">
|
||||
🚀 Open Dashboard
|
||||
</Link>
|
||||
@@ -175,25 +252,25 @@ export default function AdminPage() {
|
||||
<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 }}>{users.length}</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 }}>{totalLists}</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 }}>{totalTasks}</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 }}>Active</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>
|
||||
@@ -218,85 +295,91 @@ export default function AdminPage() {
|
||||
|
||||
{/* Users Table */}
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<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.id)}
|
||||
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.id)}
|
||||
title="Delete user"
|
||||
style={{ fontSize: 11, padding: "3px 8px", color: "var(--danger)" }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
{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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user