feat: initial commit
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { getDemoStore } from "@/lib/mockData";
|
||||
|
||||
interface AdminUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: "USER" | "ADMIN";
|
||||
createdAt: string;
|
||||
taskCount: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const INITIAL_ADMIN_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 router = useRouter();
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// 어드민 권한 판별
|
||||
const userRole = session?.user?.role;
|
||||
const userEmail = session?.user?.email;
|
||||
const isAdmin = userRole === "ADMIN" || userEmail?.endsWith("@checkflow.local") || userEmail?.startsWith("admin@");
|
||||
|
||||
// 인증 게이트: 비로그인 시 /login으로 리디렉션
|
||||
useEffect(() => {
|
||||
if (status === "unauthenticated") {
|
||||
router.replace("/login?callbackUrl=/admin");
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
// 로딩 중 스피너
|
||||
if (status === "loading" || status === "unauthenticated") {
|
||||
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)" }}>Checking administrator credentials...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 비인가 사용자(일반 유저) 차단 화면
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)", color: "var(--text-primary)", padding: 24 }}>
|
||||
<div style={{ maxWidth: 440, 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" }}>Access Restricted</h2>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.6, margin: "0 0 24px" }}>
|
||||
This console requires <strong>ADMIN</strong> privileges. Your account (<code>{session?.user?.email}</code>) does not have authorization to view multi-user system telemetry.
|
||||
</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 = (id: string) => {
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === id ? { ...u, role: u.role === "ADMIN" ? "USER" : "ADMIN" } : u))
|
||||
);
|
||||
};
|
||||
|
||||
const toggleStatus = (id: string) => {
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === id ? { ...u, active: !u.active } : u))
|
||||
);
|
||||
};
|
||||
|
||||
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 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 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div className="sidebar-logo">👑</div>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}>
|
||||
CheckFlow Admin Console
|
||||
</h1>
|
||||
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: 0 }}>
|
||||
Multi-user platform & self-hosted instance management
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Link href="/demo" className="btn btn-ghost btn-sm">
|
||||
← Back to App (Demo)
|
||||
</Link>
|
||||
<Link href="/" className="btn btn-primary btn-sm">
|
||||
🚀 Open Main 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 }}>{users.length}</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: 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: 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: 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 }}>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 700, margin: 0 }}>Registered Users & Privacy Isolation</h2>
|
||||
<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" }}>
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
const { name, email, password } = body;
|
||||
|
||||
if (!name || !email || !password) {
|
||||
return NextResponse.json({ error: "All fields required" }, { status: 400 });
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json({ error: "Password must be at least 8 characters" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "Email already in use" }, { status: 409 });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const user = await prisma.user.create({
|
||||
data: { name, email, passwordHash },
|
||||
});
|
||||
|
||||
// Create default list
|
||||
await prisma.list.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "My Tasks",
|
||||
color: "#4B7BF5",
|
||||
icon: "inbox",
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: user.id, email: user.email, name: user.name }, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error("[register]", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Server error. Make sure the database is running." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
// Helper: Basic Authentication
|
||||
async function authenticate(req: NextRequest) {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
if (!authHeader?.startsWith("Basic ")) return null;
|
||||
const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
|
||||
const sepIdx = decoded.indexOf(":");
|
||||
if (sepIdx === -1) return null;
|
||||
const email = decoded.substring(0, sepIdx);
|
||||
const password = decoded.substring(sepIdx + 1);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) return null;
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
return valid ? user : null;
|
||||
}
|
||||
|
||||
function taskToVTodo(task: {
|
||||
id: string;
|
||||
title: string;
|
||||
note: string | null;
|
||||
completed: boolean;
|
||||
completedAt: Date | null;
|
||||
dueDate: Date | null;
|
||||
priority: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
const now = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
|
||||
const priorityMap: Record<number, number> = { 0: 0, 1: 9, 2: 5, 3: 1 };
|
||||
const vtodo = [
|
||||
"BEGIN:VTODO",
|
||||
`UID:${task.id}@checkflow`,
|
||||
`DTSTAMP:${now}`,
|
||||
`CREATED:${task.createdAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`,
|
||||
`LAST-MODIFIED:${task.updatedAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`,
|
||||
`SUMMARY:${task.title.replace(/\n/g, "\\n")}`,
|
||||
`STATUS:${task.completed ? "COMPLETED" : "NEEDS-ACTION"}`,
|
||||
`PRIORITY:${priorityMap[task.priority] ?? 0}`,
|
||||
];
|
||||
if (task.note) vtodo.push(`DESCRIPTION:${task.note.replace(/\n/g, "\\n")}`);
|
||||
if (task.dueDate) vtodo.push(`DUE:${task.dueDate.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
|
||||
if (task.completedAt) vtodo.push(`COMPLETED:${task.completedAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
|
||||
vtodo.push("END:VTODO");
|
||||
return vtodo.join("\r\n");
|
||||
}
|
||||
|
||||
function parseVTodo(vcardBody: string): Partial<{
|
||||
uid: string;
|
||||
title: string;
|
||||
note: string;
|
||||
completed: boolean;
|
||||
dueDate: Date | null;
|
||||
priority: number;
|
||||
}> {
|
||||
const lines = vcardBody.split(/\r?\n/);
|
||||
const result: Partial<{
|
||||
uid: string;
|
||||
title: string;
|
||||
note: string;
|
||||
completed: boolean;
|
||||
dueDate: Date | null;
|
||||
priority: number;
|
||||
}> = {};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("UID:")) {
|
||||
result.uid = line.substring(4).replace(/@checkflow$/, "").trim();
|
||||
} else if (line.startsWith("SUMMARY:")) {
|
||||
result.title = line.substring(8).replace(/\\n/g, "\n").trim();
|
||||
} else if (line.startsWith("DESCRIPTION:")) {
|
||||
result.note = line.substring(12).replace(/\\n/g, "\n").trim();
|
||||
} else if (line.startsWith("STATUS:")) {
|
||||
result.completed = line.substring(7).trim().toUpperCase() === "COMPLETED";
|
||||
} else if (line.startsWith("DUE:")) {
|
||||
const val = line.substring(4).trim();
|
||||
try {
|
||||
// Parse basic ISO or iCal timestamp (YYYYMMDDTHHMMSSZ or YYYYMMDD)
|
||||
if (val.length === 8) {
|
||||
const y = parseInt(val.substring(0, 4), 10);
|
||||
const m = parseInt(val.substring(4, 6), 10) - 1;
|
||||
const d = parseInt(val.substring(6, 8), 10);
|
||||
result.dueDate = new Date(Date.UTC(y, m, d));
|
||||
} else {
|
||||
result.dueDate = new Date(val);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
} else if (line.startsWith("PRIORITY:")) {
|
||||
const p = parseInt(line.substring(9).trim(), 10);
|
||||
if (p === 1) result.priority = 3; // High
|
||||
else if (p === 5) result.priority = 2; // Medium
|
||||
else if (p === 9) result.priority = 1; // Low
|
||||
else result.priority = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// OPTIONS for CORS & CalDAV capability discovery
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
Allow: "OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, REPORT",
|
||||
DAV: "1, 2, 3, calendar-access, extended-mkcol",
|
||||
"MS-Author-Via": "DAV",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, REPORT",
|
||||
"Access-Control-Allow-Headers": "Authorization, Content-Type, Depth, Prefer, If-Match, If-None-Match",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// GET: Direct ICS / VTODO download
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await authenticate(req);
|
||||
if (!user) {
|
||||
return new NextResponse("Unauthorized", {
|
||||
status: 401,
|
||||
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
|
||||
});
|
||||
}
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { userId: user.id, parentId: null },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
});
|
||||
|
||||
const icsContent = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//CheckFlow//CheckFlow//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
...tasks.map(taskToVTodo),
|
||||
"END:VCALENDAR",
|
||||
].join("\r\n");
|
||||
|
||||
return new NextResponse(icsContent, {
|
||||
headers: {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"Content-Disposition": 'attachment; filename="checkflow-tasks.ics"',
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// PROPFIND: Standard CalDAV Discovery for DAVx5 / Apple Reminders
|
||||
export async function PROPFIND(req: NextRequest) {
|
||||
const user = await authenticate(req);
|
||||
if (!user) {
|
||||
return new NextResponse("Unauthorized", {
|
||||
status: 401,
|
||||
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
|
||||
});
|
||||
}
|
||||
|
||||
const host = req.headers.get("host") || "localhost:3000";
|
||||
const protocol = req.headers.get("x-forwarded-proto") || "http";
|
||||
const baseUrl = `${protocol}://${host}/api/dav`;
|
||||
const principalUrl = `${baseUrl}/principals/${encodeURIComponent(user.email)}`;
|
||||
const calendarHome = `${baseUrl}/calendars/${encodeURIComponent(user.email)}/`;
|
||||
const tasksUrl = `${calendarHome}tasks/`;
|
||||
|
||||
const xmlResponse = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
|
||||
<!-- Base Principal & Calendar Home -->
|
||||
<D:response>
|
||||
<D:href>${baseUrl}/</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:current-user-principal><D:href>${principalUrl}</D:href></D:current-user-principal>
|
||||
<D:resourcetype><D:collection/></D:resourcetype>
|
||||
<C:calendar-home-set><D:href>${calendarHome}</D:href></C:calendar-home-set>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
<!-- User Principal -->
|
||||
<D:response>
|
||||
<D:href>${principalUrl}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:displayname>${user.name || user.email}</D:displayname>
|
||||
<D:resourcetype><D:principal/></D:resourcetype>
|
||||
<C:calendar-home-set><D:href>${calendarHome}</D:href></C:calendar-home-set>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
<!-- Tasks Collection (VTODO) -->
|
||||
<D:response>
|
||||
<D:href>${tasksUrl}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:displayname>CheckFlow Tasks</D:displayname>
|
||||
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
|
||||
<C:supported-calendar-component-set>
|
||||
<C:comp name="VTODO"/>
|
||||
</C:supported-calendar-component-set>
|
||||
<CS:getctag>"${Date.now()}"</CS:getctag>
|
||||
<D:sync-token>data:sync:${Date.now()}</D:sync-token>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>`;
|
||||
|
||||
return new NextResponse(xmlResponse, {
|
||||
status: 207,
|
||||
headers: {
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
DAV: "1, 2, 3, calendar-access",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// REPORT: Query calendar items
|
||||
export async function REPORT(req: NextRequest) {
|
||||
const user = await authenticate(req);
|
||||
if (!user) {
|
||||
return new NextResponse("Unauthorized", {
|
||||
status: 401,
|
||||
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
|
||||
});
|
||||
}
|
||||
|
||||
const host = req.headers.get("host") || "localhost:3000";
|
||||
const protocol = req.headers.get("x-forwarded-proto") || "http";
|
||||
const tasksUrl = `${protocol}://${host}/api/dav/calendars/${encodeURIComponent(user.email)}/tasks/`;
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { userId: user.id, parentId: null },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
});
|
||||
|
||||
const responsesXml = tasks
|
||||
.map((task) => {
|
||||
const vcal = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//CheckFlow//CheckFlow//EN",
|
||||
taskToVTodo(task),
|
||||
"END:VCALENDAR",
|
||||
].join("\r\n");
|
||||
|
||||
return ` <D:response>
|
||||
<D:href>${tasksUrl}${task.id}.ics</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:getetag>"${new Date(task.updatedAt).getTime()}"</D:getetag>
|
||||
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav"><![CDATA[${vcal}]]></C:calendar-data>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const xmlResponse = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
${responsesXml}
|
||||
</D:multistatus>`;
|
||||
|
||||
return new NextResponse(xmlResponse, {
|
||||
status: 207,
|
||||
headers: {
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
DAV: "1, 2, 3, calendar-access",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// PUT: Create or update task via CalDAV sync
|
||||
export async function PUT(req: NextRequest) {
|
||||
const user = await authenticate(req);
|
||||
if (!user) {
|
||||
return new NextResponse("Unauthorized", {
|
||||
status: 401,
|
||||
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await req.text();
|
||||
const parsed = parseVTodo(body);
|
||||
|
||||
if (!parsed.title && !parsed.uid) {
|
||||
return new NextResponse("Bad Request: Missing task data", { status: 400 });
|
||||
}
|
||||
|
||||
// Find existing task by UID or create new one in user's default/first list
|
||||
let task = null;
|
||||
if (parsed.uid) {
|
||||
task = await prisma.task.findFirst({
|
||||
where: { id: parsed.uid, userId: user.id },
|
||||
});
|
||||
}
|
||||
|
||||
if (task) {
|
||||
// Update existing task
|
||||
task = await prisma.task.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
...(parsed.title !== undefined && { title: parsed.title }),
|
||||
...(parsed.note !== undefined && { note: parsed.note }),
|
||||
...(parsed.completed !== undefined && {
|
||||
completed: parsed.completed,
|
||||
completedAt: parsed.completed ? new Date() : null,
|
||||
}),
|
||||
...(parsed.dueDate !== undefined && { dueDate: parsed.dueDate }),
|
||||
...(parsed.priority !== undefined && { priority: parsed.priority }),
|
||||
},
|
||||
});
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
ETag: `"${new Date(task.updatedAt).getTime()}"`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Create new task in the user's primary list
|
||||
let list = await prisma.list.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
if (!list) {
|
||||
list = await prisma.list.create({
|
||||
data: {
|
||||
name: "Inbox",
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const newTask = await prisma.task.create({
|
||||
data: {
|
||||
...(parsed.uid && { id: parsed.uid }),
|
||||
title: parsed.title || "Untitled Task",
|
||||
note: parsed.note || null,
|
||||
completed: parsed.completed || false,
|
||||
completedAt: parsed.completed ? new Date() : null,
|
||||
dueDate: parsed.dueDate || null,
|
||||
priority: parsed.priority ?? 0,
|
||||
listId: list.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: 201,
|
||||
headers: {
|
||||
ETag: `"${new Date(newTask.updatedAt).getTime()}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Remove task via CalDAV sync
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const user = await authenticate(req);
|
||||
if (!user) {
|
||||
return new NextResponse("Unauthorized", {
|
||||
status: 401,
|
||||
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
|
||||
});
|
||||
}
|
||||
|
||||
// Extract ID from path URL
|
||||
const pathname = req.nextUrl.pathname;
|
||||
const match = pathname.match(/\/([a-zA-Z0-9_-]+)\.ics$/);
|
||||
const taskId = match ? match[1] : null;
|
||||
|
||||
if (!taskId) {
|
||||
return new NextResponse("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const task = await prisma.task.findFirst({
|
||||
where: { id: taskId, userId: user.id },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return new NextResponse("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.task.delete({
|
||||
where: { id: task.id },
|
||||
});
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function escapeCsvField(field: string | null | undefined): string {
|
||||
if (field === null || field === undefined) return '""';
|
||||
const str = String(field);
|
||||
// Sanitize formula injection
|
||||
const sanitized = /^[=+\-@\t\r]/.test(str.trim()) ? `'${str.trim()}` : str;
|
||||
return `"${sanitized.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function taskToVTodo(
|
||||
task: {
|
||||
id: string;
|
||||
title: string;
|
||||
note: string | null;
|
||||
completed: boolean;
|
||||
completedAt: Date | null;
|
||||
dueDate: Date | null;
|
||||
priority: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
list?: { name: string } | null;
|
||||
}
|
||||
) {
|
||||
const now = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
|
||||
const priorityMap: Record<number, number> = { 0: 0, 1: 9, 2: 5, 3: 1 };
|
||||
const vtodo = [
|
||||
"BEGIN:VTODO",
|
||||
`UID:${task.id}@checkflow`,
|
||||
`DTSTAMP:${now}`,
|
||||
`CREATED:${task.createdAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`,
|
||||
`LAST-MODIFIED:${task.updatedAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`,
|
||||
`SUMMARY:${task.title.replace(/\n/g, "\\n")}`,
|
||||
`STATUS:${task.completed ? "COMPLETED" : "NEEDS-ACTION"}`,
|
||||
`PRIORITY:${priorityMap[task.priority] ?? 0}`,
|
||||
];
|
||||
if (task.list?.name) {
|
||||
vtodo.push(`CATEGORIES:${task.list.name.replace(/\n/g, "\\n")}`);
|
||||
}
|
||||
if (task.note) {
|
||||
vtodo.push(`DESCRIPTION:${task.note.replace(/\n/g, "\\n")}`);
|
||||
}
|
||||
if (task.dueDate) {
|
||||
vtodo.push(`DUE:${task.dueDate.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
|
||||
}
|
||||
if (task.completedAt) {
|
||||
vtodo.push(`COMPLETED:${task.completedAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
|
||||
}
|
||||
vtodo.push("END:VTODO");
|
||||
return vtodo.join("\r\n");
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = req.nextUrl;
|
||||
const format = (searchParams.get("format") || "csv").toLowerCase();
|
||||
const listId = searchParams.get("listId");
|
||||
const includeCompleted = searchParams.get("includeCompleted") !== "false";
|
||||
|
||||
const whereClause: {
|
||||
userId: string;
|
||||
parentId: null;
|
||||
listId?: string;
|
||||
completed?: boolean;
|
||||
} = {
|
||||
userId: session.user.id,
|
||||
parentId: null,
|
||||
};
|
||||
|
||||
if (listId && listId !== "all") {
|
||||
whereClause.listId = listId;
|
||||
}
|
||||
|
||||
if (!includeCompleted) {
|
||||
whereClause.completed = false;
|
||||
}
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
list: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ listId: "asc" },
|
||||
{ sortOrder: "asc" },
|
||||
{ createdAt: "desc" },
|
||||
],
|
||||
});
|
||||
|
||||
const timestamp = new Date().toISOString().split("T")[0];
|
||||
|
||||
if (format === "ics") {
|
||||
const icsContent = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//CheckFlow//CheckFlow Tasks Export//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
...tasks.map(taskToVTodo),
|
||||
"END:VCALENDAR",
|
||||
].join("\r\n");
|
||||
|
||||
return new NextResponse(icsContent, {
|
||||
headers: {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="checkflow-export-${timestamp}.ics"`,
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Standard TickTick & RFC 4180 Compatible CSV
|
||||
const priorityLabels: Record<number, string> = { 0: "None", 1: "Low", 2: "Medium", 3: "High" };
|
||||
const headers = [
|
||||
"Folder Name",
|
||||
"List Name",
|
||||
"Title",
|
||||
"Tags",
|
||||
"Content",
|
||||
"Is Check list",
|
||||
"Start Date",
|
||||
"Due Date",
|
||||
"Reminder",
|
||||
"Repeat",
|
||||
"Priority",
|
||||
"Status",
|
||||
"Created Time",
|
||||
"Completed Time",
|
||||
"Order",
|
||||
"Timezone",
|
||||
];
|
||||
|
||||
const csvRows = [headers.map((h) => `"${h}"`).join(",")];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const t = tasks[i];
|
||||
const row = [
|
||||
escapeCsvField(""), // Folder Name
|
||||
escapeCsvField(t.list?.name || "Inbox"), // List Name
|
||||
escapeCsvField(t.title), // Title
|
||||
escapeCsvField(""), // Tags
|
||||
escapeCsvField(t.note || ""), // Content / Note
|
||||
escapeCsvField("0"), // Is Check list
|
||||
escapeCsvField(""), // Start Date
|
||||
escapeCsvField(t.dueDate ? t.dueDate.toISOString() : ""), // Due Date
|
||||
escapeCsvField(""), // Reminder
|
||||
escapeCsvField(""), // Repeat
|
||||
escapeCsvField(priorityLabels[t.priority] || "None"), // Priority
|
||||
escapeCsvField(t.completed ? "Completed" : "Normal"), // Status
|
||||
escapeCsvField(t.createdAt.toISOString()), // Created Time
|
||||
escapeCsvField(t.completedAt ? t.completedAt.toISOString() : ""), // Completed Time
|
||||
escapeCsvField(String(t.sortOrder ?? i)), // Order
|
||||
escapeCsvField(Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"), // Timezone
|
||||
];
|
||||
csvRows.push(row.join(","));
|
||||
}
|
||||
|
||||
const csvContent = "\uFEFF" + csvRows.join("\r\n"); // UTF-8 BOM for Excel compatibility
|
||||
|
||||
return new NextResponse(csvContent, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="checkflow-export-${timestamp}.csv"`,
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// TickTick CSV import
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const listId = formData.get("listId") as string | null;
|
||||
|
||||
if (!file || !listId) {
|
||||
return NextResponse.json({ error: "file and listId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 파일 크기 제한: 5MB
|
||||
const MAX_SIZE = 5 * 1024 * 1024;
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ error: "File too large. Maximum size is 5MB." }, { status: 413 });
|
||||
}
|
||||
|
||||
// 허용 확장자 검증
|
||||
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||
if (!["csv", "ics"].includes(ext ?? "")) {
|
||||
return NextResponse.json({ error: "Unsupported file format. Use CSV or ICS." }, { status: 400 });
|
||||
}
|
||||
|
||||
const list = await prisma.list.findFirst({ where: { id: listId, userId: session.user.id } });
|
||||
if (!list) return NextResponse.json({ error: "List not found" }, { status: 404 });
|
||||
|
||||
const text = await file.text();
|
||||
let imported = 0;
|
||||
|
||||
if (ext === "csv") {
|
||||
const lines = text.split("\n");
|
||||
const header = lines[0].split(",").map((h) => h.trim().replace(/"/g, ""));
|
||||
const titleIdx = header.findIndex((h) => h.toLowerCase().includes("title") || h.toLowerCase().includes("content"));
|
||||
const noteIdx = header.findIndex((h) => h.toLowerCase().includes("note") || h.toLowerCase().includes("description"));
|
||||
const dueIdx = header.findIndex((h) => h.toLowerCase().includes("due"));
|
||||
const priorityIdx = header.findIndex((h) => h.toLowerCase().includes("priority"));
|
||||
const completedIdx = header.findIndex((h) => h.toLowerCase().includes("status") || h.toLowerCase().includes("completed"));
|
||||
|
||||
const priorityMap: Record<string, number> = { high: 3, medium: 2, low: 1, none: 0, "3": 3, "2": 2, "1": 1, "0": 0 };
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
// Handle quoted CSV fields
|
||||
const fields: string[] = [];
|
||||
let inQuotes = false;
|
||||
let current = "";
|
||||
for (const ch of line + ",") {
|
||||
if (ch === '"') { inQuotes = !inQuotes; }
|
||||
else if (ch === "," && !inQuotes) { fields.push(current.trim()); current = ""; }
|
||||
else { current += ch; }
|
||||
}
|
||||
|
||||
// Formula Injection 방어 함수: =, +, -, @, \t, \r 등으로 시작할 경우 안전하게 이스케이프
|
||||
const sanitizeFormula = (val: string | null | undefined): string => {
|
||||
if (!val) return "";
|
||||
const trimmed = val.trim();
|
||||
if (/^[=+\-@\t\r]/.test(trimmed)) {
|
||||
return `'${trimmed}`;
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const rawTitle = titleIdx >= 0 ? fields[titleIdx]?.replace(/"/g, "") : "";
|
||||
const title = sanitizeFormula(rawTitle);
|
||||
if (!title) continue;
|
||||
|
||||
const rawNote = noteIdx >= 0 ? fields[noteIdx]?.replace(/"/g, "") : null;
|
||||
const note = rawNote ? sanitizeFormula(rawNote) : null;
|
||||
const dueRaw = dueIdx >= 0 ? fields[dueIdx] : null;
|
||||
const priorityRaw = priorityIdx >= 0 ? fields[priorityIdx]?.toLowerCase() : "0";
|
||||
const completedRaw = completedIdx >= 0 ? fields[completedIdx]?.toLowerCase() : "";
|
||||
|
||||
let dueDate: Date | null = null;
|
||||
if (dueRaw) {
|
||||
const d = new Date(dueRaw);
|
||||
if (!isNaN(d.getTime())) dueDate = d;
|
||||
}
|
||||
|
||||
const priority = priorityMap[priorityRaw || "0"] ?? 0;
|
||||
const completed = completedRaw === "completed" || completedRaw === "true" || completedRaw === "1";
|
||||
|
||||
await prisma.task.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
listId,
|
||||
title,
|
||||
note: note || null,
|
||||
dueDate,
|
||||
priority,
|
||||
completed,
|
||||
completedAt: completed ? new Date() : null,
|
||||
sortOrder: imported,
|
||||
},
|
||||
});
|
||||
imported++;
|
||||
}
|
||||
} else if (ext === "ics") {
|
||||
// Parse ICS / iCalendar VTODO
|
||||
const todos = text.split("BEGIN:VTODO").slice(1);
|
||||
for (const todo of todos) {
|
||||
const get = (key: string) => {
|
||||
const match = todo.match(new RegExp(`^${key}[^:]*:(.*)$`, "m"));
|
||||
return match ? match[1].trim().replace(/\\n/g, "\n") : null;
|
||||
};
|
||||
const title = get("SUMMARY");
|
||||
if (!title) continue;
|
||||
const note = get("DESCRIPTION");
|
||||
const dueDateRaw = get("DUE") || get("DTSTART");
|
||||
let dueDate: Date | null = null;
|
||||
if (dueDateRaw) {
|
||||
const d = new Date(dueDateRaw.replace(/(\d{4})(\d{2})(\d{2})/, "$1-$2-$3"));
|
||||
if (!isNaN(d.getTime())) dueDate = d;
|
||||
}
|
||||
const statusRaw = get("STATUS");
|
||||
const completed = statusRaw === "COMPLETED";
|
||||
const priorityRaw = get("PRIORITY");
|
||||
let priority = 0;
|
||||
if (priorityRaw) {
|
||||
const p = parseInt(priorityRaw);
|
||||
if (p >= 1 && p <= 3) priority = 4 - p; // ICS: 1=high, ours: 3=high
|
||||
else if (p >= 4 && p <= 6) priority = 2;
|
||||
else if (p >= 7) priority = 1;
|
||||
}
|
||||
|
||||
await prisma.task.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
listId,
|
||||
title,
|
||||
note: note || null,
|
||||
dueDate,
|
||||
priority,
|
||||
completed,
|
||||
completedAt: completed ? new Date() : null,
|
||||
sortOrder: imported,
|
||||
},
|
||||
});
|
||||
imported++;
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json({ error: "Unsupported file format. Use CSV or ICS." }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ imported });
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: Ctx) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
const { id } = await params;
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
|
||||
const list = await prisma.list.findFirst({ where: { id, userId: session.user.id } });
|
||||
if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const allowed = ["name", "color", "icon", "sortOrder"];
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const key of allowed) {
|
||||
if (key in body) data[key] = body[key];
|
||||
}
|
||||
|
||||
const updated = await prisma.list.update({ where: { id }, data });
|
||||
return NextResponse.json(updated);
|
||||
} catch (err) {
|
||||
console.error("[lists/id:PATCH]", err);
|
||||
return NextResponse.json({ error: "Failed to update list" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: NextRequest, { params }: Ctx) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
const { id } = await params;
|
||||
|
||||
const list = await prisma.list.findFirst({ where: { id, userId: session.user.id } });
|
||||
if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
await prisma.list.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("[lists/id:DELETE]", err);
|
||||
return NextResponse.json({ error: "Failed to delete list" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const lists = await prisma.list.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
_count: { select: { tasks: { where: { completed: false, parentId: null } } } },
|
||||
},
|
||||
});
|
||||
return NextResponse.json(lists);
|
||||
} catch (err) {
|
||||
console.error("[lists:GET]", err);
|
||||
return NextResponse.json({ error: "Failed to fetch lists" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
|
||||
const { name, color, icon } = body;
|
||||
if (!name?.trim()) return NextResponse.json({ error: "Name required" }, { status: 400 });
|
||||
|
||||
const count = await prisma.list.count({ where: { userId: session.user.id } });
|
||||
const list = await prisma.list.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
name: name.trim(),
|
||||
color: color || "#4B7BF5",
|
||||
icon: icon || "list",
|
||||
sortOrder: count,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(list, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error("[lists:POST]", err);
|
||||
return NextResponse.json({ error: "Failed to create list" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(_: NextRequest, { params }: Ctx) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
const { id } = await params;
|
||||
|
||||
const task = await prisma.task.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
include: { children: { orderBy: { sortOrder: "asc" } }, tags: { include: { tag: true } } },
|
||||
});
|
||||
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
return NextResponse.json(task);
|
||||
} catch (err) {
|
||||
console.error("[tasks/id:GET]", err);
|
||||
return NextResponse.json({ error: "Failed to fetch task" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: Ctx) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
const { id } = await params;
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
|
||||
const task = await prisma.task.findFirst({ where: { id, userId: session.user.id } });
|
||||
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
// Sanitize allowed fields
|
||||
const allowed = ["title", "note", "completed", "completedAt", "dueDate", "priority", "sortOrder", "listId", "parentId"];
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const key of allowed) {
|
||||
if (key in body) data[key] = body[key];
|
||||
}
|
||||
|
||||
// IDOR 방어: listId 변경 시 대상 목록이 사용자 소유인지 검증
|
||||
if ("listId" in data && typeof data.listId === "string") {
|
||||
const targetList = await prisma.list.findFirst({
|
||||
where: { id: data.listId, userId: session.user.id },
|
||||
});
|
||||
if (!targetList) {
|
||||
return NextResponse.json({ error: "Target list not found or forbidden" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// IDOR 방어: parentId 변경 시 대상 부모 태스크가 사용자 소유인지 검증
|
||||
if ("parentId" in data && data.parentId) {
|
||||
const targetParent = await prisma.task.findFirst({
|
||||
where: { id: data.parentId as string, userId: session.user.id },
|
||||
});
|
||||
if (!targetParent) {
|
||||
return NextResponse.json({ error: "Target parent task not found or forbidden" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-set completedAt
|
||||
if ("completed" in data) {
|
||||
data.completedAt = data.completed ? new Date() : null;
|
||||
}
|
||||
// Convert dueDate string to Date
|
||||
if ("dueDate" in data) {
|
||||
data.dueDate = data.dueDate ? new Date(data.dueDate as string) : null;
|
||||
}
|
||||
|
||||
const updated = await prisma.task.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: { children: { orderBy: { sortOrder: "asc" } }, tags: { include: { tag: true } } },
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch (err) {
|
||||
console.error("[tasks/id:PATCH]", err);
|
||||
return NextResponse.json({ error: "Failed to update task" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: NextRequest, { params }: Ctx) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
const { id } = await params;
|
||||
|
||||
const task = await prisma.task.findFirst({ where: { id, userId: session.user.id } });
|
||||
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
await prisma.task.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("[tasks/id:DELETE]", err);
|
||||
return NextResponse.json({ error: "Failed to delete task" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const listId = searchParams.get("listId");
|
||||
const showCompleted = searchParams.get("showCompleted") === "true";
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
userId: session.user.id,
|
||||
parentId: null,
|
||||
...(listId ? { listId } : {}),
|
||||
...(showCompleted ? {} : { completed: false }),
|
||||
};
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
include: {
|
||||
children: { orderBy: [{ sortOrder: "asc" }] },
|
||||
tags: { include: { tag: true } },
|
||||
},
|
||||
});
|
||||
return NextResponse.json(tasks);
|
||||
} catch (err) {
|
||||
console.error("[tasks:GET]", err);
|
||||
return NextResponse.json({ error: "Failed to fetch tasks" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
|
||||
const { title, listId, parentId, dueDate, priority, note } = body;
|
||||
if (!title || !listId) return NextResponse.json({ error: "title and listId required" }, { status: 400 });
|
||||
|
||||
const list = await prisma.list.findFirst({ where: { id: listId, userId: session.user.id } });
|
||||
if (!list) return NextResponse.json({ error: "List not found" }, { status: 404 });
|
||||
|
||||
// IDOR 방어: parentId가 지정된 경우, 부모 태스크가 현재 사용자의 소유인지 검증
|
||||
if (parentId) {
|
||||
const parentTask = await prisma.task.findFirst({
|
||||
where: { id: parentId, userId: session.user.id },
|
||||
});
|
||||
if (!parentTask) {
|
||||
return NextResponse.json({ error: "Parent task not found or forbidden" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const count = await prisma.task.count({ where: { listId, parentId: parentId || null } });
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
listId,
|
||||
parentId: parentId || null,
|
||||
title: title.trim(),
|
||||
note: note?.trim() || null,
|
||||
dueDate: dueDate ? new Date(dueDate) : null,
|
||||
priority: Number(priority) || 0,
|
||||
sortOrder: count,
|
||||
},
|
||||
include: { children: true, tags: { include: { tag: true } } },
|
||||
});
|
||||
return NextResponse.json(task, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error("[tasks:POST]", err);
|
||||
return NextResponse.json({ error: "Failed to create task" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
|
||||
export default function DemoPage() {
|
||||
const demoUser = {
|
||||
id: "demo-user",
|
||||
name: "Demo Explorer",
|
||||
email: "demo@checkflow.local",
|
||||
};
|
||||
|
||||
return <AppShell user={demoUser} isDemo={true} />;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+2029
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500", "600", "700", "800"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CheckFlow — Your Personal Todo",
|
||||
description: "A clean, fast, self-hosted todo app with hierarchical tasks and rich notes.",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "CheckFlow",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#4B7BF5",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="ko" className={inter.className} suppressHydrationWarning>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useTheme } from "@/app/providers";
|
||||
import { LanguageSelector } from "@/components/ui/LanguageSelector";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await signIn("credentials", { email, password, redirect: false });
|
||||
setLoading(false);
|
||||
if (res?.error) {
|
||||
setError("Invalid email or password. (Make sure DB is running)");
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
} catch {
|
||||
setError("Login failed. Check server status.");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
{/* Top bar: Lang & Theme switcher */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 20 }}>
|
||||
<div className="auth-logo" style={{ marginBottom: 0 }}>
|
||||
<div className="auth-logo-icon">✓</div>
|
||||
<span className="auth-logo-name">{t("appName")}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<LanguageSelector />
|
||||
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={toggleTheme}
|
||||
title={`${t("theme")}: ${theme}`}
|
||||
style={{ width: 28, height: 28 }}
|
||||
>
|
||||
{theme === "system" ? (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
) : theme === "light" ? (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="5" /><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="auth-title">{t("welcomeBack")}</h1>
|
||||
<p className="auth-subtitle">{t("signInSubtitle")}</p>
|
||||
|
||||
{/* Demo Mode Action Banner */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--accent-light)",
|
||||
border: "1px dashed var(--accent)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
padding: "12px 14px",
|
||||
marginBottom: 20,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--accent)" }}>✨ {t("demoBadge")}</span>
|
||||
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>No PostgreSQL needed</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: "var(--text-secondary)", lineHeight: 1.4 }}>
|
||||
Explore full 3-panel UI, sub-tasks, and markdown notes directly in browser storage.
|
||||
</p>
|
||||
<Link
|
||||
href="/demo"
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ marginTop: 4, width: "100%", textAlign: "center" }}
|
||||
id="try-demo-btn"
|
||||
>
|
||||
🚀 {t("tryDemoMode")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("email")}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
className="form-input"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("password")}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="form-error" style={{ marginBottom: "12px" }}>{error}</p>}
|
||||
<button id="login-btn" type="submit" className="btn btn-primary w-full" disabled={loading} style={{ height: "44px" }}>
|
||||
{loading ? t("signingIn") : t("signIn")}
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-footer">
|
||||
{t("dontHaveAccount")}{" "}
|
||||
<Link href="/register" className="auth-link">{t("createAccount")}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) redirect("/login");
|
||||
return <AppShell user={session.user} />;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
import { I18nProvider } from "@/lib/i18n";
|
||||
|
||||
export type ThemeMode = "system" | "light" | "dark";
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: ThemeMode;
|
||||
resolvedTheme: "light" | "dark";
|
||||
setTheme: (mode: ThemeMode) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
theme: "system",
|
||||
resolvedTheme: "light",
|
||||
setTheme: () => {},
|
||||
toggleTheme: () => {},
|
||||
});
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
|
||||
function ThemeManager({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<ThemeMode>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return (localStorage.getItem("checkflow_theme") as ThemeMode) || "system";
|
||||
}
|
||||
return "system";
|
||||
});
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = (localStorage.getItem("checkflow_theme") as ThemeMode) || "system";
|
||||
if (saved === "system") {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
return "light";
|
||||
});
|
||||
|
||||
const applyTheme = (mode: ThemeMode) => {
|
||||
let effective: "light" | "dark" = "light";
|
||||
if (mode === "system") {
|
||||
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
effective = isDark ? "dark" : "light";
|
||||
} else {
|
||||
effective = mode;
|
||||
}
|
||||
setResolvedTheme(effective);
|
||||
document.documentElement.setAttribute("data-theme", effective);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const saved = (localStorage.getItem("checkflow_theme") as ThemeMode) || "system";
|
||||
applyTheme(saved);
|
||||
|
||||
// Listen for system theme changes if set to system
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const listener = (e: MediaQueryListEvent) => {
|
||||
const current = (localStorage.getItem("checkflow_theme") as ThemeMode) || "system";
|
||||
if (current === "system") {
|
||||
const effective = e.matches ? "dark" : "light";
|
||||
setResolvedTheme(effective);
|
||||
document.documentElement.setAttribute("data-theme", effective);
|
||||
}
|
||||
};
|
||||
|
||||
media.addEventListener("change", listener);
|
||||
|
||||
// Register Service Worker
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("/sw.js").catch(console.error);
|
||||
}
|
||||
|
||||
return () => media.removeEventListener("change", listener);
|
||||
}, []);
|
||||
|
||||
const setTheme = (mode: ThemeMode) => {
|
||||
setThemeState(mode);
|
||||
localStorage.setItem("checkflow_theme", mode);
|
||||
applyTheme(mode);
|
||||
};
|
||||
|
||||
// Cycles through: System -> Light -> Dark -> System
|
||||
const toggleTheme = () => {
|
||||
const sequence: ThemeMode[] = ["system", "light", "dark"];
|
||||
const nextIndex = (sequence.indexOf(theme) + 1) % sequence.length;
|
||||
setTheme(sequence[nextIndex]);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<I18nProvider>
|
||||
<ThemeManager>{children}</ThemeManager>
|
||||
</I18nProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useTheme } from "@/app/providers";
|
||||
import { LanguageSelector } from "@/components/ui/LanguageSelector";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let msg = "Registration failed.";
|
||||
try {
|
||||
const data = await res.json();
|
||||
msg = data.error || msg;
|
||||
} catch {
|
||||
msg = "Database connection failed. Please ensure PostgreSQL is running or use Demo Mode.";
|
||||
}
|
||||
setError(msg);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await signIn("credentials", { email, password, redirect: false });
|
||||
router.push("/");
|
||||
} catch {
|
||||
setError("Network or server error. Check database status or try Demo Mode.");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
{/* Top bar: Lang & Theme switcher */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 20 }}>
|
||||
<div className="auth-logo" style={{ marginBottom: 0 }}>
|
||||
<div className="auth-logo-icon">✓</div>
|
||||
<span className="auth-logo-name">{t("appName")}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<LanguageSelector />
|
||||
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={toggleTheme}
|
||||
title={`${t("theme")}: ${theme}`}
|
||||
style={{ width: 28, height: 28 }}
|
||||
>
|
||||
{theme === "system" ? (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
) : theme === "light" ? (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="5" /><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="auth-title">{t("createAccount")}</h1>
|
||||
<p className="auth-subtitle">{t("createAccountSubtitle")}</p>
|
||||
|
||||
{/* Demo Mode Action Banner */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--accent-light)",
|
||||
border: "1px dashed var(--accent)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
padding: "12px 14px",
|
||||
marginBottom: 20,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--accent)" }}>✨ {t("demoBadge")}</span>
|
||||
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>No PostgreSQL needed</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: "var(--text-secondary)", lineHeight: 1.4 }}>
|
||||
Explore full 3-panel UI, sub-tasks, and markdown notes directly in browser storage.
|
||||
</p>
|
||||
<Link
|
||||
href="/demo"
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ marginTop: 4, width: "100%", textAlign: "center" }}
|
||||
id="try-demo-btn"
|
||||
>
|
||||
🚀 {t("tryDemoMode")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("displayName")}</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Your name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("email")}</label>
|
||||
<input
|
||||
id="reg-email"
|
||||
type="email"
|
||||
className="form-input"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("password")}</label>
|
||||
<input
|
||||
id="reg-password"
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder={t("min8Chars")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="form-error" style={{ marginBottom: "12px" }}>{error}</p>}
|
||||
<button id="register-btn" type="submit" className="btn btn-primary w-full" disabled={loading} style={{ height: "44px" }}>
|
||||
{loading ? t("creatingBtn") : t("createBtn")}
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-footer">
|
||||
{t("alreadyHaveAccount")}{" "}
|
||||
<Link href="/login" className="auth-link">{t("signIn")}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user