feat: initial commit

This commit is contained in:
2026-08-21 15:22:17 +09:00
commit c65fff4dfd
94 changed files with 20244 additions and 0 deletions
+291
View File
@@ -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>
);
}
+5
View File
@@ -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 };
+49
View File
@@ -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 }
);
}
}
+397
View File
@@ -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 });
}
+176
View File
@@ -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",
},
});
}
+154
View File
@@ -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 });
}
+49
View File
@@ -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 });
}
}
+51
View File
@@ -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 });
}
}
+101
View File
@@ -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 });
}
}
+80
View File
@@ -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 });
}
}
+11
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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>
);
}
+144
View File
@@ -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>
);
}
+10
View File
@@ -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} />;
}
+108
View File
@@ -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>
);
}
+173
View File
@@ -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>
);
}