Compare commits

..

2 Commits

15 changed files with 2000 additions and 86 deletions

View File

@ -4,9 +4,21 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist
## Current Product Behavior ## Current Product Behavior
### Feb 20, 2026 updates
- Added task attachments with SQLite persistence.
- Added URL-based task detail pages (`/tasks/{taskId}`) so tasks can be opened/shared by link.
- Replaced flat notes/comments with threaded comments.
- Added unlimited nested replies (reply to comment, reply to reply, no depth limit).
- Updated UI wording from "notes" to "comments".
- Improved attachment opening/rendering by coercing MIME types (including `.md` as text) and using blob URLs for reliable in-browser viewing.
- Added lightweight collaborator identity tracking for task/comment authorship.
### Data model and status rules ### Data model and status rules
- Tasks use labels (`tags: string[]`) and can have multiple labels. - Tasks use labels (`tags: string[]`) and can have multiple labels.
- Tasks support attachments (`attachments: TaskAttachment[]`).
- Tasks now track `createdById`, `createdByName`, `updatedById`, and `updatedByName`.
- There is no active `backlog` status in workflow logic. - There is no active `backlog` status in workflow logic.
- A task is considered in Backlog when `sprintId` is empty. - A task is considered in Backlog when `sprintId` is empty.
- Current status values: - Current status values:
@ -21,18 +33,68 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist
- `done` - `done`
- New tasks default to `status: open`. - New tasks default to `status: open`.
### Authentication and sessions
- Added account-based authentication with:
- `POST /api/auth/register`
- `POST /api/auth/login`
- `POST /api/auth/logout`
- `GET /api/auth/session`
- Added a dedicated login/register screen at `/login`.
- Added account settings at `/settings` for authenticated users.
- Main board (`/`) and task detail pages (`/tasks/{taskId}`) require an authenticated session.
- Main board and task detail headers include quick access to Settings and Logout.
- API routes now enforce auth on task read/write/delete (`/api/tasks` returns `401` when unauthenticated).
- Added `PATCH /api/auth/account` to update name/email/password for the current user.
- Session cookie: `gantt_session` (HTTP-only, same-site lax, secure in production).
- Added `Remember me` in auth forms:
- Checked: persistent 30-day cookie/session.
- Unchecked: browser-session cookie (clears on browser close), with server-side short-session expiry.
- Task/comment authorship now uses authenticated user identity.
### Labels ### Labels
- Project selection UI for tasks was removed in favor of labels. - Project selection UI for tasks was removed in favor of labels.
- You can add/remove labels inline in: - You can add/remove labels inline in:
- New Task modal - New Task modal
- Task Detail modal - Task Detail page
- Label entry supports: - Label entry supports:
- Enter/comma to add - Enter/comma to add
- Existing-label suggestions - Existing-label suggestions
- Quick-add chips - Quick-add chips
- Case-insensitive de-duplication - Case-insensitive de-duplication
### Task detail pages
- Clicking a task from Kanban or Backlog opens a dedicated route:
- `/tasks/{taskId}`
- Task detail is no longer popup-only behavior.
- Each task now has a shareable deep link.
### Attachments
- Task detail page supports adding multiple attachments per task.
- Attachments are stored with each task in SQLite and survive refresh/restart.
- Attachment UI supports:
- Upload multiple files
- Open/view file
- Markdown (`.md`) preview rendering in browser tab
- Text/code preview for common file types including iOS project/code files (`.swift`, `.plist`, `.pbxproj`, `.storyboard`, `.xib`, `.xcconfig`, `.entitlements`)
- Syntax-highlighted source rendering (highlight.js) with copy-source action
- CSV table preview and sandboxed HTML preview with source view
- Download file
- Remove attachment
### Threaded comments
- Comments are now threaded, not flat.
- You can:
- Add top-level comments
- Reply to any comment
- Reply to replies recursively (unlimited depth)
- Delete any comment or reply from the thread
- Thread data is persisted in SQLite through the existing `/api/tasks` sync flow.
### Backlog drag and drop ### Backlog drag and drop
Backlog view supports moving tasks between: Backlog view supports moving tasks between:
@ -85,6 +147,7 @@ npm run dev
``` ```
Open `http://localhost:3000`. Open `http://localhost:3000`.
Task URLs follow `http://localhost:3000/tasks/{taskId}`.
## Scripts ## Scripts

View File

@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { getAuthenticatedUser, updateUserAccount } from "@/lib/server/auth";
export const runtime = "nodejs";
export async function PATCH(request: Request) {
try {
const sessionUser = await getAuthenticatedUser();
if (!sessionUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = (await request.json()) as {
name?: string;
email?: string;
currentPassword?: string;
newPassword?: string;
};
const nextName = typeof body.name === "string" ? body.name : undefined;
const nextEmail = typeof body.email === "string" ? body.email : undefined;
const currentPassword = typeof body.currentPassword === "string" ? body.currentPassword : undefined;
const newPassword = typeof body.newPassword === "string" ? body.newPassword : undefined;
const user = updateUserAccount({
userId: sessionUser.id,
name: nextName,
email: nextEmail,
currentPassword,
newPassword,
});
return NextResponse.json({ success: true, user });
} catch (error) {
const message = error instanceof Error ? error.message : "Account update failed";
if (message === "User not found") {
return NextResponse.json({ error: message }, { status: 404 });
}
if (message === "Current password is incorrect") {
return NextResponse.json({ error: message }, { status: 401 });
}
if (message.includes("exists")) {
return NextResponse.json({ error: message }, { status: 409 });
}
if (
message.includes("Current password is required")
|| message.includes("at least")
|| message.includes("Invalid email")
) {
return NextResponse.json({ error: message }, { status: 400 });
}
return NextResponse.json({ error: "Account update failed" }, { status: 500 });
}
}

View File

@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { authenticateUser, createUserSession, setSessionCookie } from "@/lib/server/auth";
export const runtime = "nodejs";
export async function POST(request: Request) {
try {
const body = (await request.json()) as {
email?: string;
password?: string;
rememberMe?: boolean;
};
const email = (body.email || "").trim();
const password = body.password || "";
const rememberMe = Boolean(body.rememberMe);
if (!email || !password) {
return NextResponse.json({ error: "Email and password are required" }, { status: 400 });
}
const user = authenticateUser({ email, password });
if (!user) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const session = createUserSession(user.id, rememberMe);
await setSessionCookie(session.token, rememberMe);
return NextResponse.json({
success: true,
user,
session: { expiresAt: session.expiresAt, rememberMe },
});
} catch {
return NextResponse.json({ error: "Login failed" }, { status: 500 });
}
}

View File

@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { clearSessionCookie, getSessionTokenFromCookies, revokeSession } from "@/lib/server/auth";
export const runtime = "nodejs";
export async function POST() {
try {
const token = await getSessionTokenFromCookies();
if (token) revokeSession(token);
await clearSessionCookie();
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: "Logout failed" }, { status: 500 });
}
}

View File

@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { createUserSession, registerUser, setSessionCookie } from "@/lib/server/auth";
export const runtime = "nodejs";
export async function POST(request: Request) {
try {
const body = (await request.json()) as {
name?: string;
email?: string;
password?: string;
rememberMe?: boolean;
};
const name = (body.name || "").trim();
const email = (body.email || "").trim();
const password = body.password || "";
const rememberMe = Boolean(body.rememberMe);
if (!name || !email || !password) {
return NextResponse.json({ error: "Name, email, and password are required" }, { status: 400 });
}
const user = registerUser({ name, email, password });
const session = createUserSession(user.id, rememberMe);
await setSessionCookie(session.token, rememberMe);
return NextResponse.json({
success: true,
user,
session: { expiresAt: session.expiresAt, rememberMe },
});
} catch (error) {
const message = error instanceof Error ? error.message : "Registration failed";
const status = message.includes("exists") ? 409 : 400;
return NextResponse.json({ error: message }, { status });
}
}

View File

@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { getAuthenticatedUser } from "@/lib/server/auth";
export const runtime = "nodejs";
export async function GET() {
try {
const user = await getAuthenticatedUser();
if (!user) {
return NextResponse.json({ authenticated: false }, { status: 401 });
}
return NextResponse.json({ authenticated: true, user });
} catch {
return NextResponse.json({ error: "Session check failed" }, { status: 500 });
}
}

View File

@ -1,11 +1,16 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getData, saveData, type DataStore, type Task } from "@/lib/server/taskDb"; import { getData, saveData, type DataStore, type Task } from "@/lib/server/taskDb";
import { getAuthenticatedUser } from "@/lib/server/auth";
export const runtime = "nodejs"; export const runtime = "nodejs";
// GET - fetch all tasks, projects, and sprints // GET - fetch all tasks, projects, and sprints
export async function GET() { export async function GET() {
try { try {
const user = await getAuthenticatedUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const data = getData(); const data = getData();
return NextResponse.json(data); return NextResponse.json(data);
} catch (error) { } catch (error) {
@ -17,6 +22,11 @@ export async function GET() {
// POST - create or update tasks, projects, or sprints // POST - create or update tasks, projects, or sprints
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const user = await getAuthenticatedUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json(); const body = await request.json();
const { task, tasks, projects, sprints } = body as { const { task, tasks, projects, sprints } = body as {
task?: Task; task?: Task;
@ -33,19 +43,34 @@ export async function POST(request: Request) {
if (task) { if (task) {
const existingIndex = data.tasks.findIndex((t) => t.id === task.id); const existingIndex = data.tasks.findIndex((t) => t.id === task.id);
if (existingIndex >= 0) { if (existingIndex >= 0) {
data.tasks[existingIndex] = { ...task, updatedAt: new Date().toISOString() }; data.tasks[existingIndex] = {
...task,
updatedAt: new Date().toISOString(),
updatedById: user.id,
updatedByName: user.name,
};
} else { } else {
data.tasks.push({ data.tasks.push({
...task, ...task,
id: task.id || Date.now().toString(), id: task.id || Date.now().toString(),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
createdById: task.createdById || user.id,
createdByName: task.createdByName || user.name,
updatedById: user.id,
updatedByName: user.name,
}); });
} }
} }
if (tasks && Array.isArray(tasks)) { if (tasks && Array.isArray(tasks)) {
data.tasks = tasks; data.tasks = tasks.map((entry) => ({
...entry,
createdById: entry.createdById || user.id,
createdByName: entry.createdByName || user.name,
updatedById: entry.updatedById || user.id,
updatedByName: entry.updatedByName || user.name,
}));
} }
const saved = saveData(data); const saved = saveData(data);
@ -59,6 +84,11 @@ export async function POST(request: Request) {
// DELETE - remove a task // DELETE - remove a task
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
try { try {
const user = await getAuthenticatedUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = (await request.json()) as { id: string }; const { id } = (await request.json()) as { id: string };
const data = getData(); const data = getData();
data.tasks = data.tasks.filter((t) => t.id !== id); data.tasks = data.tasks.filter((t) => t.id !== id);
@ -69,4 +99,3 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: "Failed to delete" }, { status: 500 }); return NextResponse.json({ error: "Failed to delete" }, { status: 500 });
} }
} }

148
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,148 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
export default function LoginPage() {
const router = useRouter()
const [mode, setMode] = useState<"login" | "register">("login")
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [rememberMe, setRememberMe] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let isMounted = true
const check = async () => {
try {
const res = await fetch("/api/auth/session", { cache: "no-store" })
if (res.ok && isMounted) {
router.replace("/")
}
} catch {
// ignore and stay on login
}
}
check()
return () => {
isMounted = false
}
}, [router])
const submit = async () => {
setError(null)
if (!email.trim() || !password) {
setError("Email and password are required")
return
}
if (mode === "register" && !name.trim()) {
setError("Name is required")
return
}
setIsSubmitting(true)
try {
const endpoint = mode === "login" ? "/api/auth/login" : "/api/auth/register"
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password, rememberMe }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || "Authentication failed")
return
}
router.replace("/")
} catch {
setError("Authentication failed")
} finally {
setIsSubmitting(false)
}
}
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center p-4">
<div className="w-full max-w-md border border-slate-800 rounded-xl bg-slate-900/70 p-6">
<h1 className="text-2xl font-semibold text-white mb-2">OpenClaw Task Hub</h1>
<p className="text-sm text-slate-400 mb-6">Sign in to continue.</p>
<div className="flex gap-2 mb-6 bg-slate-800 p-1 rounded-lg">
<button
type="button"
onClick={() => setMode("login")}
className={`flex-1 px-3 py-2 rounded text-sm ${mode === "login" ? "bg-slate-700 text-white" : "text-slate-400"}`}
>
Login
</button>
<button
type="button"
onClick={() => setMode("register")}
className={`flex-1 px-3 py-2 rounded text-sm ${mode === "register" ? "bg-slate-700 text-white" : "text-slate-400"}`}
>
Register
</button>
</div>
<div className="space-y-4">
{mode === "register" && (
<div>
<label className="block text-sm text-slate-300 mb-1">Name</label>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
placeholder="Your display name"
/>
</div>
)}
<div>
<label className="block text-sm text-slate-300 mb-1">Email</label>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm text-slate-300 mb-1">Password</label>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
placeholder="At least 8 characters"
/>
</div>
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
checked={rememberMe}
onChange={(event) => setRememberMe(event.target.checked)}
/>
Remember me
</label>
{error && <p className="text-sm text-red-400">{error}</p>}
<Button onClick={submit} className="w-full" disabled={isSubmitting}>
{isSubmitting ? "Please wait..." : mode === "login" ? "Login" : "Create Account"}
</Button>
</div>
</div>
</div>
)
}

View File

@ -22,7 +22,16 @@ import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment } from "@/stores/useTaskStore" import {
blobFromDataUrl,
coerceDataUrlMimeType,
inferAttachmentMimeType,
isMarkdownAttachment,
isTextPreviewAttachment,
markdownPreviewObjectUrl,
textPreviewObjectUrl,
} from "@/lib/attachments"
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, type CommentAuthor } from "@/stores/useTaskStore"
import { BacklogView } from "@/components/BacklogView" import { BacklogView } from "@/components/BacklogView"
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download } from "lucide-react" import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download } from "lucide-react"
@ -295,6 +304,7 @@ export default function Home() {
projects, projects,
tasks, tasks,
sprints, sprints,
currentUser,
selectedProjectId, selectedProjectId,
selectedTaskId, selectedTaskId,
addTask, addTask,
@ -303,6 +313,7 @@ export default function Home() {
selectTask, selectTask,
addComment, addComment,
deleteComment, deleteComment,
setCurrentUser,
syncFromServer, syncFromServer,
isLoading, isLoading,
} = useTaskStore() } = useTaskStore()
@ -323,6 +334,7 @@ export default function Home() {
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("") const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
const [activeKanbanTaskId, setActiveKanbanTaskId] = useState<string | null>(null) const [activeKanbanTaskId, setActiveKanbanTaskId] = useState<string | null>(null)
const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState<string | null>(null) const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState<string | null>(null)
const [authReady, setAuthReady] = useState(false)
const getTags = (taskLike: { tags?: unknown }) => { const getTags = (taskLike: { tags?: unknown }) => {
if (!Array.isArray(taskLike.tags)) return [] as string[] if (!Array.isArray(taskLike.tags)) return [] as string[]
@ -343,6 +355,27 @@ export default function Home() {
}) })
} }
const getCommentAuthor = (value: unknown): CommentAuthor => {
if (value === "assistant") {
return { id: "assistant", name: "Assistant", type: "assistant" }
}
if (value === "user") {
return { id: "legacy-user", name: "User", type: "human" }
}
if (!value || typeof value !== "object") {
return { id: "legacy-user", name: "User", type: "human" }
}
const candidate = value as Partial<CommentAuthor>
const type = candidate.type === "assistant" || candidate.id === "assistant" ? "assistant" : "human"
return {
id: typeof candidate.id === "string" && candidate.id ? candidate.id : type === "assistant" ? "assistant" : "legacy-user",
name: typeof candidate.name === "string" && candidate.name ? candidate.name : type === "assistant" ? "Assistant" : "User",
email: typeof candidate.email === "string" && candidate.email ? candidate.email : undefined,
type,
}
}
const formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B" if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"
const units = ["B", "KB", "MB", "GB"] const units = ["B", "KB", "MB", "GB"]
@ -371,13 +404,39 @@ export default function Home() {
const allLabels = useMemo(() => labelUsage.map(([label]) => label), [labelUsage]) const allLabels = useMemo(() => labelUsage.map(([label]) => label), [labelUsage])
// Sync from server on mount
useEffect(() => { useEffect(() => {
console.log('>>> PAGE: useEffect for syncFromServer running') let isMounted = true
syncFromServer().then(() => { const loadSession = async () => {
console.log('>>> PAGE: syncFromServer completed') try {
}) const res = await fetch("/api/auth/session", { cache: "no-store" })
}, [syncFromServer]) if (!res.ok) {
if (isMounted) router.replace("/login")
return
}
const data = await res.json()
if (!isMounted) return
setCurrentUser({
id: data.user.id,
name: data.user.name,
email: data.user.email,
})
setAuthReady(true)
} catch {
if (isMounted) router.replace("/login")
}
}
loadSession()
return () => {
isMounted = false
}
}, [router, setCurrentUser])
useEffect(() => {
if (!authReady) return
syncFromServer()
}, [authReady, syncFromServer])
useEffect(() => { useEffect(() => {
if (selectedTaskId) { if (selectedTaskId) {
@ -540,11 +599,20 @@ export default function Home() {
const handleAddComment = () => { const handleAddComment = () => {
if (newComment.trim() && selectedTaskId) { if (newComment.trim() && selectedTaskId) {
addComment(selectedTaskId, newComment.trim(), "user") addComment(selectedTaskId, newComment.trim())
setNewComment("") setNewComment("")
} }
} }
const handleLogout = async () => {
try {
await fetch("/api/auth/logout", { method: "POST" })
} finally {
setAuthReady(false)
router.replace("/login")
}
}
const handleAttachmentUpload = async (event: ChangeEvent<HTMLInputElement>) => { const handleAttachmentUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []) const files = Array.from(event.target.files || [])
if (files.length === 0) return if (files.length === 0) return
@ -552,14 +620,19 @@ export default function Home() {
try { try {
const uploadedAt = new Date().toISOString() const uploadedAt = new Date().toISOString()
const attachments = await Promise.all( const attachments = await Promise.all(
files.map(async (file) => ({ files.map(async (file) => {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, const type = inferAttachmentMimeType(file.name, file.type)
name: file.name, const rawDataUrl = await readFileAsDataUrl(file)
type: file.type || "application/octet-stream",
size: file.size, return {
dataUrl: await readFileAsDataUrl(file), id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
uploadedAt, name: file.name,
})) type,
size: file.size,
dataUrl: coerceDataUrlMimeType(rawDataUrl, type),
uploadedAt,
}
})
) )
setEditedTask((prev) => { setEditedTask((prev) => {
@ -576,6 +649,46 @@ export default function Home() {
} }
} }
const openAttachment = async (attachment: TaskAttachment) => {
try {
const mimeType = inferAttachmentMimeType(attachment.name, attachment.type)
const objectUrl = isMarkdownAttachment(attachment.name, mimeType)
? await markdownPreviewObjectUrl(attachment.name, attachment.dataUrl)
: isTextPreviewAttachment(attachment.name, mimeType)
? await textPreviewObjectUrl(attachment.name, attachment.dataUrl, mimeType)
: URL.createObjectURL(await blobFromDataUrl(attachment.dataUrl, mimeType))
window.open(objectUrl, "_blank", "noopener,noreferrer")
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
} catch (error) {
console.error("Failed to open attachment:", error)
}
}
const downloadAttachment = async (attachment: TaskAttachment) => {
try {
const mimeType = inferAttachmentMimeType(attachment.name, attachment.type)
const blob = await blobFromDataUrl(attachment.dataUrl, mimeType)
const objectUrl = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = objectUrl
link.download = attachment.name
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
} catch (error) {
console.error("Failed to download attachment:", error)
}
}
if (!authReady) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
<p className="text-sm text-slate-400">Checking session...</p>
</div>
)
}
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-100"> <div className="min-h-screen bg-slate-950 text-slate-100">
{/* Header */} {/* Header */}
@ -603,6 +716,23 @@ export default function Home() {
<span className="hidden md:inline text-sm text-slate-400"> <span className="hidden md:inline text-sm text-slate-400">
{tasks.length} tasks · {allLabels.length} labels {tasks.length} tasks · {allLabels.length} labels
</span> </span>
<span className="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300">
{currentUser.name}
</span>
<button
type="button"
onClick={() => router.push("/settings")}
className="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:border-slate-500"
>
Settings
</button>
<button
type="button"
onClick={handleLogout}
className="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:border-slate-500"
>
Logout
</button>
</div> </div>
</div> </div>
</div> </div>
@ -1109,27 +1239,26 @@ export default function Home() {
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-800 bg-slate-800/50" className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-800 bg-slate-800/50"
> >
<div className="min-w-0"> <div className="min-w-0">
<a <button
href={attachment.dataUrl} type="button"
target="_blank" onClick={() => openAttachment(attachment)}
rel="noreferrer"
className="text-sm text-blue-300 hover:text-blue-200 truncate block" className="text-sm text-blue-300 hover:text-blue-200 truncate block"
> >
{attachment.name} {attachment.name}
</a> </button>
<p className="text-xs text-slate-500 mt-1"> <p className="text-xs text-slate-500 mt-1">
{formatBytes(attachment.size)} · {new Date(attachment.uploadedAt).toLocaleString()} {formatBytes(attachment.size)} · {new Date(attachment.uploadedAt).toLocaleString()}
</p> </p>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<a <button
href={attachment.dataUrl} type="button"
download={attachment.name} onClick={() => downloadAttachment(attachment)}
className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700" className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700"
title="Download attachment" title="Download attachment"
> >
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
</a> </button>
<button <button
type="button" type="button"
onClick={() => onClick={() =>
@ -1163,26 +1292,30 @@ export default function Home() {
{!editedTask.comments || editedTask.comments.length === 0 ? ( {!editedTask.comments || editedTask.comments.length === 0 ? (
<p className="text-slate-500 text-sm">No comments yet. Add the first one.</p> <p className="text-slate-500 text-sm">No comments yet. Add the first one.</p>
) : ( ) : (
editedTask.comments.map((comment) => ( editedTask.comments.map((comment) => {
const author = getCommentAuthor(comment.author)
const isAssistant = author.type === "assistant"
const displayName = author.id === currentUser.id ? "You" : author.name
return (
<div <div
key={comment.id} key={comment.id}
className={`flex gap-3 p-3 rounded-lg ${ className={`flex gap-3 p-3 rounded-lg ${
comment.author === "assistant" ? "bg-blue-900/20" : "bg-slate-800/50" isAssistant ? "bg-blue-900/20" : "bg-slate-800/50"
}`} }`}
> >
<div <div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${ className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${
comment.author === "assistant" isAssistant
? "bg-blue-600 text-white" ? "bg-blue-600 text-white"
: "bg-slate-700 text-slate-300" : "bg-slate-700 text-slate-300"
}`} }`}
> >
{comment.author === "assistant" ? "AI" : "You"} {isAssistant ? "AI" : displayName.slice(0, 2).toUpperCase()}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-slate-300"> <span className="text-sm font-medium text-slate-300">
{comment.author === "assistant" ? "Assistant" : "You"} {isAssistant ? "Assistant" : displayName}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-slate-500"> <span className="text-xs text-slate-500">
@ -1199,7 +1332,8 @@ export default function Home() {
<p className="text-slate-300 text-sm whitespace-pre-wrap">{comment.text}</p> <p className="text-slate-300 text-sm whitespace-pre-wrap">{comment.text}</p>
</div> </div>
</div> </div>
)) )
})
)} )}
</div> </div>

240
src/app/settings/page.tsx Normal file
View File

@ -0,0 +1,240 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useTaskStore } from "@/stores/useTaskStore"
export default function SettingsPage() {
const router = useRouter()
const { setCurrentUser } = useTaskStore()
const [authReady, setAuthReady] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isLoggingOut, setIsLoggingOut] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [currentPassword, setCurrentPassword] = useState("")
const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
useEffect(() => {
let isMounted = true
const loadSession = async () => {
try {
const res = await fetch("/api/auth/session", { cache: "no-store" })
if (!res.ok) {
if (isMounted) router.replace("/login")
return
}
const data = await res.json()
if (!isMounted) return
setName(data.user.name || "")
setEmail(data.user.email || "")
setCurrentUser({
id: data.user.id,
name: data.user.name,
email: data.user.email,
})
setAuthReady(true)
} catch {
if (isMounted) router.replace("/login")
}
}
loadSession()
return () => {
isMounted = false
}
}, [router, setCurrentUser])
const handleSave = async () => {
setError(null)
setSuccess(null)
const trimmedName = name.trim()
const trimmedEmail = email.trim()
if (!trimmedName || !trimmedEmail) {
setError("Name and email are required")
return
}
if (newPassword || confirmPassword || currentPassword) {
if (!currentPassword) {
setError("Current password is required to change password")
return
}
if (!newPassword) {
setError("New password is required")
return
}
if (newPassword !== confirmPassword) {
setError("New password and confirmation do not match")
return
}
}
setIsSaving(true)
try {
const payload: Record<string, string> = {
name: trimmedName,
email: trimmedEmail,
}
if (currentPassword) payload.currentPassword = currentPassword
if (newPassword) payload.newPassword = newPassword
const res = await fetch("/api/auth/account", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || "Failed to update account")
return
}
setCurrentUser({
id: data.user.id,
name: data.user.name,
email: data.user.email,
})
setName(data.user.name)
setEmail(data.user.email)
setCurrentPassword("")
setNewPassword("")
setConfirmPassword("")
setSuccess("Account updated")
} catch {
setError("Failed to update account")
} finally {
setIsSaving(false)
}
}
const handleLogout = async () => {
setIsLoggingOut(true)
try {
await fetch("/api/auth/logout", { method: "POST" })
router.replace("/login")
} finally {
setIsLoggingOut(false)
}
}
if (!authReady) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
<p className="text-sm text-slate-400">Checking session...</p>
</div>
)
}
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
<div className="max-w-2xl mx-auto p-4 md:p-6 space-y-6">
<div className="flex items-center justify-between">
<Button variant="outline" className="border-slate-700 text-slate-200 hover:bg-slate-800" onClick={() => router.push("/")}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Board
</Button>
<Button
variant="outline"
className="border-slate-700 text-slate-200 hover:bg-slate-800"
onClick={handleLogout}
disabled={isLoggingOut}
>
{isLoggingOut ? "Logging out..." : "Logout"}
</Button>
</div>
<div className="border border-slate-800 rounded-xl bg-slate-900/60 p-5 space-y-5">
<div>
<h1 className="text-xl font-semibold text-white">Account Settings</h1>
<p className="text-sm text-slate-400 mt-1">Update your profile and password.</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-slate-300 mb-1">Name</label>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
placeholder="Your display name"
/>
</div>
<div>
<label className="block text-sm text-slate-300 mb-1">Email</label>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
placeholder="you@example.com"
/>
</div>
</div>
<div className="border-t border-slate-800 pt-4 space-y-4">
<div>
<h2 className="text-sm font-medium text-slate-200">Change Password</h2>
<p className="text-xs text-slate-500 mt-1">Leave blank if you do not want to change it.</p>
</div>
<div>
<label className="block text-sm text-slate-300 mb-1">Current Password</label>
<input
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
placeholder="Current password"
/>
</div>
<div>
<label className="block text-sm text-slate-300 mb-1">New Password</label>
<input
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
placeholder="At least 8 characters"
/>
</div>
<div>
<label className="block text-sm text-slate-300 mb-1">Confirm New Password</label>
<input
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
placeholder="Confirm new password"
/>
</div>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
{success && <p className="text-sm text-emerald-400">{success}</p>}
<div className="pt-2">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@ -7,14 +7,25 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import {
blobFromDataUrl,
coerceDataUrlMimeType,
inferAttachmentMimeType,
isMarkdownAttachment,
isTextPreviewAttachment,
markdownPreviewObjectUrl,
textPreviewObjectUrl,
} from "@/lib/attachments"
import { import {
useTaskStore, useTaskStore,
type Comment as TaskComment, type Comment as TaskComment,
type CommentAuthor,
type Priority, type Priority,
type Task, type Task,
type TaskAttachment, type TaskAttachment,
type TaskStatus, type TaskStatus,
type TaskType, type TaskType,
type UserProfile,
} from "@/stores/useTaskStore" } from "@/stores/useTaskStore"
const typeColors: Record<TaskType, string> = { const typeColors: Record<TaskType, string> = {
@ -64,24 +75,53 @@ const getAttachments = (taskLike: { attachments?: unknown }) => {
const getComments = (value: unknown): TaskComment[] => { const getComments = (value: unknown): TaskComment[] => {
if (!Array.isArray(value)) return [] if (!Array.isArray(value)) return []
return value const normalized: TaskComment[] = []
.map((entry) => { for (const entry of value) {
if (!entry || typeof entry !== "object") return null if (!entry || typeof entry !== "object") continue
const comment = entry as Partial<TaskComment> const comment = entry as Partial<TaskComment>
if (typeof comment.id !== "string" || typeof comment.text !== "string") return null if (typeof comment.id !== "string" || typeof comment.text !== "string") continue
return { normalized.push({
id: comment.id, id: comment.id,
text: comment.text, text: comment.text,
createdAt: typeof comment.createdAt === "string" ? comment.createdAt : new Date().toISOString(), createdAt: typeof comment.createdAt === "string" ? comment.createdAt : new Date().toISOString(),
author: comment.author === "assistant" ? "assistant" : "user", author: getCommentAuthor(comment.author),
replies: getComments(comment.replies), replies: getComments(comment.replies),
}
}) })
.filter((comment): comment is TaskComment => comment !== null) }
return normalized
} }
const buildComment = (text: string, author: "user" | "assistant" = "user"): TaskComment => ({ const getCommentAuthor = (value: unknown): CommentAuthor => {
if (value === "assistant") {
return { id: "assistant", name: "Assistant", type: "assistant" }
}
if (value === "user") {
return { id: "legacy-user", name: "User", type: "human" }
}
if (!value || typeof value !== "object") {
return { id: "legacy-user", name: "User", type: "human" }
}
const candidate = value as Partial<CommentAuthor>
const type = candidate.type === "assistant" || candidate.id === "assistant" ? "assistant" : "human"
return {
id: typeof candidate.id === "string" && candidate.id ? candidate.id : type === "assistant" ? "assistant" : "legacy-user",
name: typeof candidate.name === "string" && candidate.name ? candidate.name : type === "assistant" ? "Assistant" : "User",
email: typeof candidate.email === "string" && candidate.email ? candidate.email : undefined,
type,
}
}
const profileToAuthor = (profile: UserProfile): CommentAuthor => ({
id: profile.id,
name: profile.name,
email: profile.email,
type: "human",
})
const buildComment = (text: string, author: CommentAuthor): TaskComment => ({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
text, text,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@ -152,8 +192,10 @@ export default function TaskDetailPage() {
const { const {
tasks, tasks,
sprints, sprints,
currentUser,
updateTask, updateTask,
deleteTask, deleteTask,
setCurrentUser,
syncFromServer, syncFromServer,
isLoading, isLoading,
} = useTaskStore() } = useTaskStore()
@ -165,10 +207,40 @@ export default function TaskDetailPage() {
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({}) const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({})
const [openReplyEditors, setOpenReplyEditors] = useState<Record<string, boolean>>({}) const [openReplyEditors, setOpenReplyEditors] = useState<Record<string, boolean>>({})
const [authReady, setAuthReady] = useState(false)
useEffect(() => { useEffect(() => {
let isMounted = true
const loadSession = async () => {
try {
const res = await fetch("/api/auth/session", { cache: "no-store" })
if (!res.ok) {
if (isMounted) router.replace("/login")
return
}
const data = await res.json()
if (!isMounted) return
setCurrentUser({
id: data.user.id,
name: data.user.name,
email: data.user.email,
})
setAuthReady(true)
} catch {
if (isMounted) router.replace("/login")
}
}
loadSession()
return () => {
isMounted = false
}
}, [router, setCurrentUser])
useEffect(() => {
if (!authReady) return
syncFromServer() syncFromServer()
}, [syncFromServer]) }, [authReady, syncFromServer])
useEffect(() => { useEffect(() => {
if (selectedTask) { if (selectedTask) {
@ -203,14 +275,19 @@ export default function TaskDetailPage() {
try { try {
const uploadedAt = new Date().toISOString() const uploadedAt = new Date().toISOString()
const attachments = await Promise.all( const attachments = await Promise.all(
files.map(async (file) => ({ files.map(async (file) => {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, const type = inferAttachmentMimeType(file.name, file.type)
name: file.name, const rawDataUrl = await readFileAsDataUrl(file)
type: file.type || "application/octet-stream",
size: file.size, return {
dataUrl: await readFileAsDataUrl(file), id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
uploadedAt, name: file.name,
})) type,
size: file.size,
dataUrl: coerceDataUrlMimeType(rawDataUrl, type),
uploadedAt,
}
})
) )
setEditedTask((prev) => { setEditedTask((prev) => {
@ -230,9 +307,10 @@ export default function TaskDetailPage() {
const handleAddComment = () => { const handleAddComment = () => {
if (!editedTask || !newComment.trim()) return if (!editedTask || !newComment.trim()) return
const actor = profileToAuthor(currentUser)
setEditedTask({ setEditedTask({
...editedTask, ...editedTask,
comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), "user")], comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), actor)],
}) })
setNewComment("") setNewComment("")
} }
@ -242,9 +320,10 @@ export default function TaskDetailPage() {
const text = replyDrafts[parentId]?.trim() const text = replyDrafts[parentId]?.trim()
if (!text) return if (!text) return
const actor = profileToAuthor(currentUser)
setEditedTask({ setEditedTask({
...editedTask, ...editedTask,
comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, "user")), comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, actor)),
}) })
setReplyDrafts((prev) => ({ ...prev, [parentId]: "" })) setReplyDrafts((prev) => ({ ...prev, [parentId]: "" }))
@ -270,6 +349,38 @@ export default function TaskDetailPage() {
setIsSaving(false) setIsSaving(false)
} }
const openAttachment = async (attachment: TaskAttachment) => {
try {
const mimeType = inferAttachmentMimeType(attachment.name, attachment.type)
const objectUrl = isMarkdownAttachment(attachment.name, mimeType)
? await markdownPreviewObjectUrl(attachment.name, attachment.dataUrl)
: isTextPreviewAttachment(attachment.name, mimeType)
? await textPreviewObjectUrl(attachment.name, attachment.dataUrl, mimeType)
: URL.createObjectURL(await blobFromDataUrl(attachment.dataUrl, mimeType))
window.open(objectUrl, "_blank", "noopener,noreferrer")
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
} catch (error) {
console.error("Failed to open attachment:", error)
}
}
const downloadAttachment = async (attachment: TaskAttachment) => {
try {
const mimeType = inferAttachmentMimeType(attachment.name, attachment.type)
const blob = await blobFromDataUrl(attachment.dataUrl, mimeType)
const objectUrl = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = objectUrl
link.download = attachment.name
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
} catch (error) {
console.error("Failed to download attachment:", error)
}
}
const handleDelete = () => { const handleDelete = () => {
if (!editedTask) return if (!editedTask) return
if (!window.confirm("Delete this task?")) return if (!window.confirm("Delete this task?")) return
@ -277,21 +388,33 @@ export default function TaskDetailPage() {
router.push("/") router.push("/")
} }
const handleLogout = async () => {
try {
await fetch("/api/auth/logout", { method: "POST" })
} finally {
setAuthReady(false)
router.replace("/login")
}
}
const renderThread = (comments: TaskComment[], depth = 0): JSX.Element[] => const renderThread = (comments: TaskComment[], depth = 0): JSX.Element[] =>
comments.map((comment) => { comments.map((comment) => {
const replies = getComments(comment.replies) const replies = getComments(comment.replies)
const isReplying = !!openReplyEditors[comment.id] const isReplying = !!openReplyEditors[comment.id]
const replyDraft = replyDrafts[comment.id] || "" const replyDraft = replyDrafts[comment.id] || ""
const author = getCommentAuthor(comment.author)
const isAssistant = author.type === "assistant"
const displayName = isAssistant ? "Assistant" : author.id === currentUser.id ? "You" : author.name
return ( return (
<div key={comment.id} className="space-y-2" style={{ marginLeft: depth * 20 }}> <div key={comment.id} className="space-y-2" style={{ marginLeft: depth * 20 }}>
<div className={`p-3 rounded-lg border ${comment.author === "assistant" ? "bg-blue-900/20 border-blue-900/40" : "bg-slate-800/60 border-slate-800"}`}> <div className={`p-3 rounded-lg border ${isAssistant ? "bg-blue-900/20 border-blue-900/40" : "bg-slate-800/60 border-slate-800"}`}>
<div className="flex items-center justify-between gap-2 mb-1"> <div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-medium ${comment.author === "assistant" ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-200"}`}> <span className={`w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-medium ${isAssistant ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-200"}`}>
{comment.author === "assistant" ? "AI" : "You"} {isAssistant ? "AI" : displayName.slice(0, 2).toUpperCase()}
</span> </span>
<span className="text-sm text-slate-300 font-medium">{comment.author === "assistant" ? "Assistant" : "You"}</span> <span className="text-sm text-slate-300 font-medium">{displayName}</span>
<span className="text-xs text-slate-500">{new Date(comment.createdAt).toLocaleString()}</span> <span className="text-xs text-slate-500">{new Date(comment.createdAt).toLocaleString()}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -350,6 +473,14 @@ export default function TaskDetailPage() {
) )
} }
if (!authReady) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 p-6 flex items-center justify-center">
<p className="text-slate-400">Checking session...</p>
</div>
)
}
if (!selectedTask && !isLoading) { if (!selectedTask && !isLoading) {
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-100 p-6"> <div className="min-h-screen bg-slate-950 text-slate-100 p-6">
@ -376,7 +507,23 @@ export default function TaskDetailPage() {
<ArrowLeft className="w-4 h-4 mr-2" /> <ArrowLeft className="w-4 h-4 mr-2" />
Back to Board Back to Board
</Button> </Button>
<span className="text-xs text-slate-500">Task URL: /tasks/{editedTask.id}</span> <div className="flex items-center gap-2">
<span className="text-xs text-slate-500">Task URL: /tasks/{editedTask.id}</span>
<button
type="button"
onClick={() => router.push("/settings")}
className="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:border-slate-500"
>
Settings
</button>
<button
type="button"
onClick={handleLogout}
className="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:border-slate-500"
>
Logout {currentUser.name}
</button>
</div>
</div> </div>
<div className="border border-slate-800 rounded-xl bg-slate-900/60 p-4 md:p-6"> <div className="border border-slate-800 rounded-xl bg-slate-900/60 p-4 md:p-6">
@ -395,6 +542,9 @@ export default function TaskDetailPage() {
onChange={(event) => setEditedTask({ ...editedTask, title: event.target.value })} onChange={(event) => setEditedTask({ ...editedTask, title: event.target.value })}
className="w-full text-xl font-semibold bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white" className="w-full text-xl font-semibold bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white"
/> />
<p className="text-xs text-slate-500 mt-2">
Created by {editedTask.createdByName || "Unknown"}{editedTask.updatedByName ? ` · Last updated by ${editedTask.updatedByName}` : ""}
</p>
<div className="space-y-6 py-4"> <div className="space-y-6 py-4">
<div> <div>
@ -554,27 +704,26 @@ export default function TaskDetailPage() {
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-800 bg-slate-800/50" className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-800 bg-slate-800/50"
> >
<div className="min-w-0"> <div className="min-w-0">
<a <button
href={attachment.dataUrl} type="button"
target="_blank" onClick={() => openAttachment(attachment)}
rel="noreferrer"
className="text-sm text-blue-300 hover:text-blue-200 truncate block" className="text-sm text-blue-300 hover:text-blue-200 truncate block"
> >
{attachment.name} {attachment.name}
</a> </button>
<p className="text-xs text-slate-500 mt-1"> <p className="text-xs text-slate-500 mt-1">
{formatBytes(attachment.size)} · {new Date(attachment.uploadedAt).toLocaleString()} {formatBytes(attachment.size)} · {new Date(attachment.uploadedAt).toLocaleString()}
</p> </p>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<a <button
href={attachment.dataUrl} type="button"
download={attachment.name} onClick={() => downloadAttachment(attachment)}
className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700" className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700"
title="Download attachment" title="Download attachment"
> >
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
</a> </button>
<button <button
type="button" type="button"
onClick={() => onClick={() =>

504
src/lib/attachments.ts Normal file
View File

@ -0,0 +1,504 @@
export function inferAttachmentMimeType(fileName: string, declaredType?: string): string {
const safeDeclaredType = typeof declaredType === "string" ? declaredType.trim() : ""
if (safeDeclaredType && safeDeclaredType !== "application/octet-stream") {
return safeDeclaredType
}
const extension = fileName.toLowerCase().split(".").pop() || ""
const extensionToMime: Record<string, string> = {
md: "text/plain",
markdown: "text/plain",
txt: "text/plain",
log: "text/plain",
json: "application/json",
csv: "text/csv",
yml: "text/plain",
yaml: "text/plain",
html: "text/html",
htm: "text/html",
xml: "application/xml",
js: "text/javascript",
mjs: "text/javascript",
cjs: "text/javascript",
ts: "text/plain",
tsx: "text/plain",
jsx: "text/plain",
swift: "text/plain",
m: "text/plain",
mm: "text/plain",
pbxproj: "text/plain",
xcconfig: "text/plain",
strings: "text/plain",
plist: "application/xml",
stringsdict: "application/xml",
entitlements: "application/xml",
storyboard: "application/xml",
xib: "application/xml",
metal: "text/plain",
css: "text/css",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
pdf: "application/pdf",
}
return extensionToMime[extension] || "application/octet-stream"
}
export function isMarkdownAttachment(fileName: string, mimeType?: string): boolean {
const extension = fileName.toLowerCase().split(".").pop() || ""
if (extension === "md" || extension === "markdown") return true
return (mimeType || "").toLowerCase() === "text/markdown"
}
export function isTextPreviewAttachment(fileName: string, mimeType?: string): boolean {
const extension = fileName.toLowerCase().split(".").pop() || ""
const textLikeExtensions = new Set([
"txt",
"log",
"json",
"csv",
"xml",
"yml",
"yaml",
"ini",
"cfg",
"conf",
"env",
"toml",
"properties",
"sql",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"py",
"rb",
"php",
"go",
"rs",
"java",
"kt",
"swift",
"pbxproj",
"plist",
"strings",
"stringsdict",
"xcconfig",
"entitlements",
"storyboard",
"xib",
"m",
"mm",
"metal",
"c",
"h",
"cpp",
"hpp",
"cs",
"js",
"mjs",
"cjs",
"ts",
"tsx",
"jsx",
"css",
"scss",
"sass",
"less",
"vue",
"svelte",
"html",
"htm",
])
const safeMimeType = (mimeType || "").toLowerCase()
if (textLikeExtensions.has(extension)) return true
if (safeMimeType.startsWith("text/")) return true
if (safeMimeType === "application/json" || safeMimeType === "application/xml") return true
return false
}
export function coerceDataUrlMimeType(dataUrl: string, mimeType: string): string {
if (!dataUrl.startsWith("data:")) return dataUrl
const commaIndex = dataUrl.indexOf(",")
if (commaIndex === -1) return dataUrl
const meta = dataUrl.slice(0, commaIndex)
const payload = dataUrl.slice(commaIndex + 1)
const hasBase64 = meta.includes(";base64")
const encodingSuffix = hasBase64 ? ";base64" : ""
return `data:${mimeType}${encodingSuffix},${payload}`
}
export async function blobFromDataUrl(dataUrl: string, mimeTypeOverride?: string): Promise<Blob> {
const response = await fetch(dataUrl)
const blob = await response.blob()
if (!mimeTypeOverride || blob.type === mimeTypeOverride) return blob
const buffer = await blob.arrayBuffer()
return new Blob([buffer], { type: mimeTypeOverride })
}
export async function textFromDataUrl(dataUrl: string): Promise<string> {
const response = await fetch(dataUrl)
return response.text()
}
function safeScriptString(value: string): string {
return JSON.stringify(value).replace(/<\/script/gi, "<\\/script")
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
function getExtension(fileName: string): string {
return fileName.toLowerCase().split(".").pop() || ""
}
function isJsonAttachment(fileName: string, mimeType?: string): boolean {
const extension = getExtension(fileName)
return extension === "json" || (mimeType || "").toLowerCase() === "application/json"
}
function isCsvAttachment(fileName: string, mimeType?: string): boolean {
const extension = getExtension(fileName)
return extension === "csv" || (mimeType || "").toLowerCase() === "text/csv"
}
function isHtmlAttachment(fileName: string, mimeType?: string): boolean {
const extension = getExtension(fileName)
const safeMimeType = (mimeType || "").toLowerCase()
return extension === "html" || extension === "htm" || safeMimeType === "text/html"
}
function inferCodeLanguage(fileName: string, mimeType?: string): string {
const extension = getExtension(fileName)
const safeMimeType = (mimeType || "").toLowerCase()
const extensionToLanguage: Record<string, string> = {
swift: "swift",
m: "objectivec",
mm: "objectivec",
h: "objectivec",
pbxproj: "ini",
xcconfig: "ini",
strings: "ini",
stringsdict: "xml",
plist: "xml",
entitlements: "xml",
storyboard: "xml",
xib: "xml",
metal: "cpp",
json: "json",
csv: "plaintext",
yml: "yaml",
yaml: "yaml",
xml: "xml",
html: "xml",
htm: "xml",
js: "javascript",
mjs: "javascript",
cjs: "javascript",
ts: "typescript",
tsx: "typescript",
jsx: "javascript",
css: "css",
scss: "scss",
sass: "scss",
less: "less",
sh: "bash",
bash: "bash",
zsh: "bash",
fish: "bash",
ps1: "powershell",
py: "python",
rb: "ruby",
php: "php",
go: "go",
rs: "rust",
java: "java",
kt: "kotlin",
c: "c",
cpp: "cpp",
hpp: "cpp",
cs: "csharp",
sql: "sql",
vue: "xml",
svelte: "xml",
md: "markdown",
markdown: "markdown",
txt: "plaintext",
log: "plaintext",
ini: "ini",
cfg: "ini",
conf: "ini",
env: "ini",
toml: "ini",
properties: "ini",
}
const fromExtension = extensionToLanguage[extension]
if (fromExtension) return fromExtension
if (safeMimeType === "application/json") return "json"
if (safeMimeType === "application/xml" || safeMimeType === "text/xml") return "xml"
if (safeMimeType === "text/html") return "xml"
if (safeMimeType === "text/css") return "css"
if (safeMimeType === "text/javascript") return "javascript"
return "plaintext"
}
function parseCsvLine(line: string): string[] {
const cells: string[] = []
let current = ""
let index = 0
let inQuotes = false
while (index < line.length) {
const char = line[index]
if (char === '"') {
const next = line[index + 1]
if (inQuotes && next === '"') {
current += '"'
index += 2
continue
}
inQuotes = !inQuotes
index += 1
continue
}
if (char === "," && !inQuotes) {
cells.push(current)
current = ""
index += 1
continue
}
current += char
index += 1
}
cells.push(current)
return cells
}
function csvTableHtml(content: string): string {
const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(0, 200)
if (lines.length === 0) return '<p class="muted">No CSV rows to preview.</p>'
const rows = lines.map(parseCsvLine)
const maxColumns = rows.reduce((max, row) => Math.max(max, row.length), 0)
const normalizedRows = rows.map((row) => [...row, ...Array.from({ length: maxColumns - row.length }, () => "")])
const header = normalizedRows[0]
const body = normalizedRows.slice(1)
const headerCells = header.map((cell) => `<th>${escapeHtml(cell || "Column")}</th>`).join("")
const bodyRows = body
.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}</tr>`)
.join("")
return `<table><thead><tr>${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`
}
function prettyJsonOrRaw(content: string): string {
try {
return JSON.stringify(JSON.parse(content), null, 2)
} catch {
return content
}
}
export function createTextPreviewHtml(fileName: string, content: string, mimeType?: string): string {
const safeTitle = escapeHtml(fileName)
const safeMimeType = (mimeType || "").toLowerCase()
const isHtml = isHtmlAttachment(fileName, safeMimeType)
const isJson = isJsonAttachment(fileName, safeMimeType)
const isCsv = isCsvAttachment(fileName, safeMimeType)
const language = inferCodeLanguage(fileName, safeMimeType)
const displayedText = isJson ? prettyJsonOrRaw(content) : content
const sourceLiteral = safeScriptString(displayedText)
const htmlLiteral = safeScriptString(content)
const renderedCsv = isCsv ? csvTableHtml(content) : ""
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${safeTitle}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/styles/github-dark.min.css" />
<style>
body { margin: 0; background: #0f172a; color: #e2e8f0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }
.container { max-width: 1100px; margin: 0 auto; padding: 24px; }
.header { color: #94a3b8; font-size: 13px; margin-bottom: 14px; }
.panel { border: 1px solid #1e293b; background: #020617; border-radius: 10px; padding: 14px; margin-bottom: 14px; overflow: auto; }
.panel-toolbar { display: flex; align-items: center; justify-content: space-between; margin: 6px 0 8px; }
.copy-btn { border: 1px solid #334155; background: #111827; color: #e2e8f0; border-radius: 7px; font-size: 12px; padding: 4px 8px; cursor: pointer; }
.copy-btn:hover { border-color: #475569; }
.source-meta { color: #64748b; font-size: 12px; }
.code { margin: 0; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace; font-size: 13px; line-height: 1.55; }
.hljs { background: #020617 !important; border-radius: 8px; padding: 0 !important; }
.muted { color: #94a3b8; font-size: 13px; }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { border: 1px solid #334155; padding: 6px 8px; text-align: left; vertical-align: top; }
th { background: #111827; color: #f8fafc; position: sticky; top: 0; }
.iframe-wrap { border: 1px solid #1e293b; border-radius: 10px; overflow: hidden; background: #ffffff; }
iframe { display: block; width: 100%; min-height: 360px; border: 0; background: #fff; }
details { border: 1px solid #1e293b; border-radius: 10px; background: #020617; padding: 10px 12px; }
summary { cursor: pointer; color: #cbd5e1; font-size: 13px; }
</style>
</head>
<body>
<main class="container">
<div class="header">Attachment Preview: ${safeTitle}${safeMimeType ? ` · ${escapeHtml(safeMimeType)}` : ""}</div>
${isHtml ? `<div class="iframe-wrap"><iframe sandbox="" id="html-frame"></iframe></div>` : ""}
${isCsv ? `<div class="panel">${renderedCsv}</div>` : ""}
${isHtml ? '<details><summary>View source</summary>' : ""}
<div class="panel-toolbar">
<button id="copy-source" type="button" class="copy-btn">Copy source</button>
<span id="source-meta" class="source-meta"></span>
</div>
<div class="panel">
<pre class="code"><code id="source-code" class="language-${language}"></code></pre>
</div>
${isHtml ? '</details>' : ""}
</main>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/lib/common.min.js"></script>
<script>
const sourceText = ${sourceLiteral};
const rawHtml = ${htmlLiteral};
const sourceCodeElement = document.getElementById("source-code");
if (sourceCodeElement) sourceCodeElement.textContent = sourceText;
const sourceMetaElement = document.getElementById("source-meta");
if (sourceMetaElement) {
const lineCount = sourceText.length === 0 ? 0 : sourceText.split(/\\r?\\n/).length;
sourceMetaElement.textContent = "${escapeHtml(language)} · " + lineCount + " lines";
}
if (window.hljs && sourceCodeElement) {
try {
window.hljs.highlightElement(sourceCodeElement);
} catch (_error) {
// no-op fallback to plain text
}
}
const copyButton = document.getElementById("copy-source");
if (copyButton && navigator.clipboard && navigator.clipboard.writeText) {
copyButton.addEventListener("click", async () => {
const originalLabel = copyButton.textContent || "Copy source";
try {
await navigator.clipboard.writeText(sourceText);
copyButton.textContent = "Copied";
setTimeout(() => { copyButton.textContent = originalLabel; }, 1200);
} catch (_error) {
copyButton.textContent = "Copy failed";
setTimeout(() => { copyButton.textContent = originalLabel; }, 1500);
}
});
}
const htmlFrame = document.getElementById("html-frame");
if (htmlFrame) htmlFrame.srcdoc = rawHtml;
</script>
</body>
</html>`
}
export function createMarkdownPreviewHtml(fileName: string, markdown: string): string {
const safeTitle = fileName.replace(/[<>&"]/g, (char) => {
if (char === "<") return "&lt;"
if (char === ">") return "&gt;"
if (char === "&") return "&amp;"
return "&quot;"
})
const markdownLiteral = safeScriptString(markdown)
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${safeTitle}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/styles/github-dark.min.css" />
<style>
body { margin: 0; background: #0f172a; color: #e2e8f0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }
.container { max-width: 900px; margin: 0 auto; padding: 24px; }
.header { margin-bottom: 16px; color: #94a3b8; font-size: 14px; }
.markdown { line-height: 1.7; color: #e2e8f0; }
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { color: #f8fafc; margin-top: 1.3em; margin-bottom: 0.4em; }
.markdown pre { background: #020617; border: 1px solid #1e293b; border-radius: 8px; padding: 12px; overflow: auto; }
.markdown code { background: #020617; border: 1px solid #1e293b; border-radius: 6px; padding: 0.1em 0.35em; }
.markdown pre code { background: transparent; border: 0; padding: 0; }
.markdown a { color: #60a5fa; }
.markdown blockquote { border-left: 3px solid #334155; margin: 0.8em 0; padding-left: 12px; color: #cbd5e1; }
.markdown table { border-collapse: collapse; width: 100%; }
.markdown th, .markdown td { border: 1px solid #334155; padding: 6px 8px; }
.markdown hr { border: 0; border-top: 1px solid #334155; margin: 1.5em 0; }
.fallback { white-space: pre-wrap; background: #020617; border: 1px solid #1e293b; border-radius: 8px; padding: 12px; color: #e2e8f0; }
</style>
</head>
<body>
<main class="container">
<div class="header">Markdown Preview: ${safeTitle}</div>
<article id="root" class="markdown"></article>
</main>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/lib/common.min.js"></script>
<script>
const rawMarkdown = ${markdownLiteral};
const markdown = rawMarkdown.replace(/</g, "&lt;").replace(/>/g, "&gt;");
const root = document.getElementById("root");
const fallback = () => {
const pre = document.createElement("pre");
pre.className = "fallback";
pre.textContent = rawMarkdown;
root.innerHTML = "";
root.appendChild(pre);
};
try {
if (window.marked && typeof window.marked.parse === "function") {
root.innerHTML = window.marked.parse(markdown);
if (window.hljs) {
root.querySelectorAll("pre code").forEach((block) => {
try {
window.hljs.highlightElement(block);
} catch (_error) {
// no-op fallback to plain rendered code block
}
});
}
} else {
fallback();
}
} catch (_error) {
fallback();
}
</script>
</body>
</html>`;
}
export async function markdownPreviewObjectUrl(fileName: string, dataUrl: string): Promise<string> {
const markdown = await textFromDataUrl(dataUrl)
const html = createMarkdownPreviewHtml(fileName, markdown)
return URL.createObjectURL(new Blob([html], { type: "text/html" }))
}
export async function textPreviewObjectUrl(fileName: string, dataUrl: string, mimeType?: string): Promise<string> {
const content = await textFromDataUrl(dataUrl)
const html = createTextPreviewHtml(fileName, content, mimeType)
return URL.createObjectURL(new Blob([html], { type: "text/html" }))
}

295
src/lib/server/auth.ts Normal file
View File

@ -0,0 +1,295 @@
import Database from "better-sqlite3";
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "crypto";
import { mkdirSync } from "fs";
import { join } from "path";
import { cookies } from "next/headers";
const DATA_DIR = join(process.cwd(), "data");
const DB_FILE = join(DATA_DIR, "tasks.db");
const SESSION_COOKIE_NAME = "gantt_session";
const SESSION_HOURS_SHORT = 12;
const SESSION_DAYS_REMEMBER = 30;
type SqliteDb = InstanceType<typeof Database>;
let db: SqliteDb | null = null;
export interface AuthUser {
id: string;
name: string;
email: string;
createdAt: string;
}
interface UserRow extends AuthUser {
passwordHash: string;
}
function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
function getDb(): SqliteDb {
if (db) return db;
mkdirSync(DATA_DIR, { recursive: true });
const database = new Database(DB_FILE);
database.pragma("journal_mode = WAL");
database.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
passwordHash TEXT NOT NULL,
createdAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
tokenHash TEXT NOT NULL UNIQUE,
createdAt TEXT NOT NULL,
expiresAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(tokenHash);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(userId);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expiresAt);
`);
db = database;
return database;
}
function hashPassword(password: string, salt?: string): string {
const safeSalt = salt || randomBytes(16).toString("hex");
const derived = scryptSync(password, safeSalt, 64).toString("hex");
return `scrypt$${safeSalt}$${derived}`;
}
function verifyPassword(password: string, stored: string): boolean {
const parts = stored.split("$");
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
const [, salt, digest] = parts;
const candidate = hashPassword(password, salt);
const candidateDigest = candidate.split("$")[2];
const a = Buffer.from(digest, "hex");
const b = Buffer.from(candidateDigest, "hex");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
function hashSessionToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
function deleteExpiredSessions(database: SqliteDb) {
const now = new Date().toISOString();
database.prepare("DELETE FROM sessions WHERE expiresAt <= ?").run(now);
}
export function registerUser(params: {
name: string;
email: string;
password: string;
}): AuthUser {
const database = getDb();
deleteExpiredSessions(database);
const name = params.name.trim();
const email = normalizeEmail(params.email);
const password = params.password;
if (name.length < 2) throw new Error("Name must be at least 2 characters");
if (!email.includes("@")) throw new Error("Invalid email");
if (password.length < 8) throw new Error("Password must be at least 8 characters");
const existing = database
.prepare("SELECT id FROM users WHERE email = ? LIMIT 1")
.get(email) as { id: string } | undefined;
if (existing) {
throw new Error("Email already exists");
}
const user: AuthUser = {
id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name,
email,
createdAt: new Date().toISOString(),
};
database
.prepare("INSERT INTO users (id, name, email, passwordHash, createdAt) VALUES (?, ?, ?, ?, ?)")
.run(user.id, user.name, user.email, hashPassword(password), user.createdAt);
return user;
}
export function authenticateUser(params: {
email: string;
password: string;
}): AuthUser | null {
const database = getDb();
deleteExpiredSessions(database);
const email = normalizeEmail(params.email);
const row = database
.prepare("SELECT id, name, email, passwordHash, createdAt FROM users WHERE email = ? LIMIT 1")
.get(email) as UserRow | undefined;
if (!row) return null;
if (!verifyPassword(params.password, row.passwordHash)) return null;
return {
id: row.id,
name: row.name,
email: row.email,
createdAt: row.createdAt,
};
}
export function updateUserAccount(params: {
userId: string;
name?: string;
email?: string;
currentPassword?: string;
newPassword?: string;
}): AuthUser {
const database = getDb();
deleteExpiredSessions(database);
const row = database
.prepare("SELECT id, name, email, passwordHash, createdAt FROM users WHERE id = ? LIMIT 1")
.get(params.userId) as UserRow | undefined;
if (!row) throw new Error("User not found");
const requestedName = typeof params.name === "string" ? params.name.trim() : row.name;
const requestedEmail = typeof params.email === "string" ? normalizeEmail(params.email) : row.email;
const currentPassword = params.currentPassword || "";
const newPassword = params.newPassword || "";
if (requestedName.length < 2) throw new Error("Name must be at least 2 characters");
if (!requestedEmail.includes("@")) throw new Error("Invalid email");
if (newPassword && newPassword.length < 8) throw new Error("New password must be at least 8 characters");
const emailChanged = requestedEmail !== row.email;
const passwordChanged = newPassword.length > 0;
const needsPasswordCheck = emailChanged || passwordChanged;
if (needsPasswordCheck) {
if (!currentPassword) throw new Error("Current password is required");
if (!verifyPassword(currentPassword, row.passwordHash)) {
throw new Error("Current password is incorrect");
}
}
if (emailChanged) {
const existing = database
.prepare("SELECT id FROM users WHERE email = ? AND id != ? LIMIT 1")
.get(requestedEmail, row.id) as { id: string } | undefined;
if (existing) throw new Error("Email already exists");
}
const nextPasswordHash = passwordChanged ? hashPassword(newPassword) : row.passwordHash;
database
.prepare("UPDATE users SET name = ?, email = ?, passwordHash = ? WHERE id = ?")
.run(requestedName, requestedEmail, nextPasswordHash, row.id);
return {
id: row.id,
name: requestedName,
email: requestedEmail,
createdAt: row.createdAt,
};
}
export function createUserSession(userId: string, rememberMe: boolean): {
token: string;
expiresAt: string;
} {
const database = getDb();
deleteExpiredSessions(database);
const now = Date.now();
const ttlMs = rememberMe
? SESSION_DAYS_REMEMBER * 24 * 60 * 60 * 1000
: SESSION_HOURS_SHORT * 60 * 60 * 1000;
const createdAt = new Date(now).toISOString();
const expiresAt = new Date(now + ttlMs).toISOString();
const token = randomBytes(32).toString("hex");
const tokenHash = hashSessionToken(token);
database
.prepare("INSERT INTO sessions (id, userId, tokenHash, createdAt, expiresAt) VALUES (?, ?, ?, ?, ?)")
.run(`session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, userId, tokenHash, createdAt, expiresAt);
return { token, expiresAt };
}
export function revokeSession(token: string) {
const database = getDb();
const tokenHash = hashSessionToken(token);
database.prepare("DELETE FROM sessions WHERE tokenHash = ?").run(tokenHash);
}
export function getUserBySessionToken(token: string): AuthUser | null {
const database = getDb();
deleteExpiredSessions(database);
const tokenHash = hashSessionToken(token);
const now = new Date().toISOString();
const row = database
.prepare(`
SELECT u.id, u.name, u.email, u.createdAt
FROM sessions s
JOIN users u ON u.id = s.userId
WHERE s.tokenHash = ? AND s.expiresAt > ?
LIMIT 1
`)
.get(tokenHash, now) as AuthUser | undefined;
return row ?? null;
}
export async function setSessionCookie(token: string, rememberMe: boolean) {
const cookieStore = await cookies();
const baseOptions = {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
} as const;
if (rememberMe) {
cookieStore.set(SESSION_COOKIE_NAME, token, {
...baseOptions,
maxAge: SESSION_DAYS_REMEMBER * 24 * 60 * 60,
});
return;
}
// Session cookie (clears on browser close)
cookieStore.set(SESSION_COOKIE_NAME, token, baseOptions);
}
export async function clearSessionCookie() {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, "", {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 0,
});
}
export async function getSessionTokenFromCookies(): Promise<string | null> {
const cookieStore = await cookies();
return cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null;
}
export async function getAuthenticatedUser(): Promise<AuthUser | null> {
const token = await getSessionTokenFromCookies();
if (!token) return null;
return getUserBySessionToken(token);
}

View File

@ -15,10 +15,17 @@ export interface TaskComment {
id: string; id: string;
text: string; text: string;
createdAt: string; createdAt: string;
author: "user" | "assistant"; author: TaskCommentAuthor | "user" | "assistant";
replies?: TaskComment[]; replies?: TaskComment[];
} }
export interface TaskCommentAuthor {
id: string;
name: string;
email?: string;
type: "human" | "assistant";
}
export interface Task { export interface Task {
id: string; id: string;
title: string; title: string;
@ -30,6 +37,10 @@ export interface Task {
sprintId?: string; sprintId?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
createdById?: string;
createdByName?: string;
updatedById?: string;
updatedByName?: string;
dueDate?: string; dueDate?: string;
comments: TaskComment[]; comments: TaskComment[];
tags: string[]; tags: string[];
@ -126,13 +137,43 @@ function normalizeComments(comments: unknown): TaskComment[] {
id: value.id, id: value.id,
text: value.text, text: value.text,
createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(), createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(),
author: value.author === "assistant" ? "assistant" : "user", author: normalizeCommentAuthor(value.author),
replies: normalizeComments(value.replies), replies: normalizeComments(value.replies),
}; };
}) })
.filter((comment): comment is TaskComment => comment !== null); .filter((comment): comment is TaskComment => comment !== null);
} }
function normalizeCommentAuthor(author: unknown): TaskCommentAuthor {
if (author === "assistant") {
return { id: "assistant", name: "Assistant", type: "assistant" };
}
if (author === "user") {
return { id: "legacy-user", name: "User", type: "human" };
}
if (!author || typeof author !== "object") {
return { id: "legacy-user", name: "User", type: "human" };
}
const value = author as Partial<TaskCommentAuthor>;
const type: TaskCommentAuthor["type"] =
value.type === "assistant" || value.id === "assistant" ? "assistant" : "human";
const id = typeof value.id === "string" && value.id.trim().length > 0
? value.id
: type === "assistant"
? "assistant"
: "legacy-user";
const name = typeof value.name === "string" && value.name.trim().length > 0
? value.name.trim()
: type === "assistant"
? "Assistant"
: "User";
const email = typeof value.email === "string" && value.email.trim().length > 0 ? value.email.trim() : undefined;
return { id, name, email, type };
}
function normalizeTask(task: Partial<Task>): Task { function normalizeTask(task: Partial<Task>): Task {
return { return {
id: String(task.id ?? Date.now()), id: String(task.id ?? Date.now()),
@ -145,6 +186,10 @@ function normalizeTask(task: Partial<Task>): Task {
sprintId: task.sprintId || undefined, sprintId: task.sprintId || undefined,
createdAt: task.createdAt || new Date().toISOString(), createdAt: task.createdAt || new Date().toISOString(),
updatedAt: task.updatedAt || new Date().toISOString(), updatedAt: task.updatedAt || new Date().toISOString(),
createdById: typeof task.createdById === "string" && task.createdById.trim().length > 0 ? task.createdById : undefined,
createdByName: typeof task.createdByName === "string" && task.createdByName.trim().length > 0 ? task.createdByName : undefined,
updatedById: typeof task.updatedById === "string" && task.updatedById.trim().length > 0 ? task.updatedById : undefined,
updatedByName: typeof task.updatedByName === "string" && task.updatedByName.trim().length > 0 ? task.updatedByName : undefined,
dueDate: task.dueDate || undefined, dueDate: task.dueDate || undefined,
comments: normalizeComments(task.comments), comments: normalizeComments(task.comments),
tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === "string") : [], tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === "string") : [],
@ -183,8 +228,8 @@ function replaceAllData(database: SqliteDb, data: DataStore) {
VALUES (@id, @name, @goal, @startDate, @endDate, @status, @projectId, @createdAt) VALUES (@id, @name, @goal, @startDate, @endDate, @status, @projectId, @createdAt)
`); `);
const insertTask = database.prepare(` const insertTask = database.prepare(`
INSERT INTO tasks (id, title, description, type, status, priority, projectId, sprintId, createdAt, updatedAt, dueDate, comments, tags, attachments) INSERT INTO tasks (id, title, description, type, status, priority, projectId, sprintId, createdAt, updatedAt, createdById, createdByName, updatedById, updatedByName, dueDate, comments, tags, attachments)
VALUES (@id, @title, @description, @type, @status, @priority, @projectId, @sprintId, @createdAt, @updatedAt, @dueDate, @comments, @tags, @attachments) VALUES (@id, @title, @description, @type, @status, @priority, @projectId, @sprintId, @createdAt, @updatedAt, @createdById, @createdByName, @updatedById, @updatedByName, @dueDate, @comments, @tags, @attachments)
`); `);
for (const project of payload.projects) { for (const project of payload.projects) {
@ -214,6 +259,10 @@ function replaceAllData(database: SqliteDb, data: DataStore) {
insertTask.run({ insertTask.run({
...task, ...task,
sprintId: task.sprintId ?? null, sprintId: task.sprintId ?? null,
createdById: task.createdById ?? null,
createdByName: task.createdByName ?? null,
updatedById: task.updatedById ?? null,
updatedByName: task.updatedByName ?? null,
dueDate: task.dueDate ?? null, dueDate: task.dueDate ?? null,
comments: JSON.stringify(task.comments ?? []), comments: JSON.stringify(task.comments ?? []),
tags: JSON.stringify(task.tags ?? []), tags: JSON.stringify(task.tags ?? []),
@ -280,6 +329,10 @@ function getDb(): SqliteDb {
sprintId TEXT, sprintId TEXT,
createdAt TEXT NOT NULL, createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
createdById TEXT,
createdByName TEXT,
updatedById TEXT,
updatedByName TEXT,
dueDate TEXT, dueDate TEXT,
comments TEXT NOT NULL DEFAULT '[]', comments TEXT NOT NULL DEFAULT '[]',
tags TEXT NOT NULL DEFAULT '[]', tags TEXT NOT NULL DEFAULT '[]',
@ -296,6 +349,18 @@ function getDb(): SqliteDb {
if (!taskColumns.some((column) => column.name === "attachments")) { if (!taskColumns.some((column) => column.name === "attachments")) {
database.exec("ALTER TABLE tasks ADD COLUMN attachments TEXT NOT NULL DEFAULT '[]';"); database.exec("ALTER TABLE tasks ADD COLUMN attachments TEXT NOT NULL DEFAULT '[]';");
} }
if (!taskColumns.some((column) => column.name === "createdById")) {
database.exec("ALTER TABLE tasks ADD COLUMN createdById TEXT;");
}
if (!taskColumns.some((column) => column.name === "createdByName")) {
database.exec("ALTER TABLE tasks ADD COLUMN createdByName TEXT;");
}
if (!taskColumns.some((column) => column.name === "updatedById")) {
database.exec("ALTER TABLE tasks ADD COLUMN updatedById TEXT;");
}
if (!taskColumns.some((column) => column.name === "updatedByName")) {
database.exec("ALTER TABLE tasks ADD COLUMN updatedByName TEXT;");
}
seedIfEmpty(database); seedIfEmpty(database);
db = database; db = database;
@ -335,6 +400,10 @@ export function getData(): DataStore {
sprintId: string | null; sprintId: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
createdById: string | null;
createdByName: string | null;
updatedById: string | null;
updatedByName: string | null;
dueDate: string | null; dueDate: string | null;
comments: string | null; comments: string | null;
tags: string | null; tags: string | null;
@ -370,6 +439,10 @@ export function getData(): DataStore {
sprintId: task.sprintId ?? undefined, sprintId: task.sprintId ?? undefined,
createdAt: task.createdAt, createdAt: task.createdAt,
updatedAt: task.updatedAt, updatedAt: task.updatedAt,
createdById: task.createdById ?? undefined,
createdByName: task.createdByName ?? undefined,
updatedById: task.updatedById ?? undefined,
updatedByName: task.updatedByName ?? undefined,
dueDate: task.dueDate ?? undefined, dueDate: task.dueDate ?? undefined,
comments: normalizeComments(safeParseArray(task.comments, [])), comments: normalizeComments(safeParseArray(task.comments, [])),
tags: safeParseArray(task.tags, []), tags: safeParseArray(task.tags, []),

View File

@ -21,10 +21,23 @@ export interface Comment {
id: string id: string
text: string text: string
createdAt: string createdAt: string
author: 'user' | 'assistant' author: CommentAuthor | 'user' | 'assistant'
replies?: Comment[] replies?: Comment[]
} }
export interface CommentAuthor {
id: string
name: string
email?: string
type: 'human' | 'assistant'
}
export interface UserProfile {
id: string
name: string
email?: string
}
export interface TaskAttachment { export interface TaskAttachment {
id: string id: string
name: string name: string
@ -45,6 +58,10 @@ export interface Task {
sprintId?: string sprintId?: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
createdById?: string
createdByName?: string
updatedById?: string
updatedByName?: string
dueDate?: string dueDate?: string
comments: Comment[] comments: Comment[]
tags: string[] tags: string[]
@ -66,12 +83,14 @@ interface TaskStore {
selectedProjectId: string | null selectedProjectId: string | null
selectedTaskId: string | null selectedTaskId: string | null
selectedSprintId: string | null selectedSprintId: string | null
currentUser: UserProfile
isLoading: boolean isLoading: boolean
lastSynced: number | null lastSynced: number | null
// Sync actions // Sync actions
syncFromServer: () => Promise<void> syncFromServer: () => Promise<void>
syncToServer: () => Promise<void> syncToServer: () => Promise<void>
setCurrentUser: (user: Partial<UserProfile>) => void
// Project actions // Project actions
addProject: (name: string, description?: string) => void addProject: (name: string, description?: string) => void
@ -93,7 +112,7 @@ interface TaskStore {
getTasksBySprint: (sprintId: string) => Task[] getTasksBySprint: (sprintId: string) => Task[]
// Comment actions // Comment actions
addComment: (taskId: string, text: string, author: 'user' | 'assistant') => void addComment: (taskId: string, text: string, author?: CommentAuthor | 'user' | 'assistant') => void
deleteComment: (taskId: string, commentId: string) => void deleteComment: (taskId: string, commentId: string) => void
// Filters // Filters
@ -386,6 +405,72 @@ const defaultTasks: Task[] = [
} }
] ]
const createLocalUserProfile = (): UserProfile => ({
id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: 'Local User',
})
const defaultCurrentUser = createLocalUserProfile()
const normalizeUserProfile = (value: unknown, fallback: UserProfile = defaultCurrentUser): UserProfile => {
if (!value || typeof value !== 'object') return fallback
const candidate = value as Partial<UserProfile>
const id = typeof candidate.id === 'string' && candidate.id.trim().length > 0 ? candidate.id : fallback.id
const name = typeof candidate.name === 'string' && candidate.name.trim().length > 0 ? candidate.name.trim() : fallback.name
const email = typeof candidate.email === 'string' && candidate.email.trim().length > 0 ? candidate.email.trim() : undefined
return { id, name, email }
}
const profileToCommentAuthor = (profile: UserProfile): CommentAuthor => ({
id: profile.id,
name: profile.name,
email: profile.email,
type: 'human',
})
const assistantAuthor: CommentAuthor = {
id: 'assistant',
name: 'Assistant',
type: 'assistant',
}
const normalizeCommentAuthor = (value: unknown): CommentAuthor => {
if (value === 'assistant') return assistantAuthor
if (value === 'user') {
return {
id: 'legacy-user',
name: 'User',
type: 'human',
}
}
if (!value || typeof value !== 'object') {
return {
id: 'legacy-user',
name: 'User',
type: 'human',
}
}
const candidate = value as Partial<CommentAuthor>
const type: CommentAuthor['type'] =
candidate.type === 'assistant' || candidate.id === 'assistant' ? 'assistant' : 'human'
const id = typeof candidate.id === 'string' && candidate.id.trim().length > 0
? candidate.id
: type === 'assistant'
? 'assistant'
: 'legacy-user'
const name = typeof candidate.name === 'string' && candidate.name.trim().length > 0
? candidate.name.trim()
: type === 'assistant'
? 'Assistant'
: 'User'
const email = typeof candidate.email === 'string' && candidate.email.trim().length > 0 ? candidate.email.trim() : undefined
return { id, name, email, type }
}
const normalizeComments = (value: unknown): Comment[] => { const normalizeComments = (value: unknown): Comment[] => {
if (!Array.isArray(value)) return [] if (!Array.isArray(value)) return []
@ -395,12 +480,11 @@ const normalizeComments = (value: unknown): Comment[] => {
const candidate = entry as Partial<Comment> const candidate = entry as Partial<Comment>
if (typeof candidate.id !== 'string' || typeof candidate.text !== 'string') return null if (typeof candidate.id !== 'string' || typeof candidate.text !== 'string') return null
const author = candidate.author === 'assistant' ? 'assistant' : 'user'
return { return {
id: candidate.id, id: candidate.id,
text: candidate.text, text: candidate.text,
createdAt: typeof candidate.createdAt === 'string' ? candidate.createdAt : new Date().toISOString(), createdAt: typeof candidate.createdAt === 'string' ? candidate.createdAt : new Date().toISOString(),
author, author: normalizeCommentAuthor(candidate.author),
replies: normalizeComments(candidate.replies), replies: normalizeComments(candidate.replies),
} }
}) })
@ -435,6 +519,10 @@ const normalizeTask = (task: Task): Task => ({
comments: normalizeComments(task.comments), comments: normalizeComments(task.comments),
tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0) : [], tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0) : [],
attachments: normalizeAttachments(task.attachments), attachments: normalizeAttachments(task.attachments),
createdById: typeof task.createdById === 'string' && task.createdById.trim().length > 0 ? task.createdById : undefined,
createdByName: typeof task.createdByName === 'string' && task.createdByName.trim().length > 0 ? task.createdByName : undefined,
updatedById: typeof task.updatedById === 'string' && task.updatedById.trim().length > 0 ? task.updatedById : undefined,
updatedByName: typeof task.updatedByName === 'string' && task.updatedByName.trim().length > 0 ? task.updatedByName : undefined,
}) })
const removeCommentFromThread = (comments: Comment[], targetId: string): Comment[] => const removeCommentFromThread = (comments: Comment[], targetId: string): Comment[] =>
@ -478,6 +566,7 @@ export const useTaskStore = create<TaskStore>()(
selectedProjectId: '1', selectedProjectId: '1',
selectedTaskId: null, selectedTaskId: null,
selectedSprintId: null, selectedSprintId: null,
currentUser: defaultCurrentUser,
isLoading: false, isLoading: false,
lastSynced: null, lastSynced: null,
@ -523,6 +612,10 @@ export const useTaskStore = create<TaskStore>()(
set({ lastSynced: Date.now() }) set({ lastSynced: Date.now() })
}, },
setCurrentUser: (user) => {
set((state) => ({ currentUser: normalizeUserProfile(user, state.currentUser) }))
},
addProject: (name, description) => { addProject: (name, description) => {
const colors = ['#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'] const colors = ['#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
const newProject: Project = { const newProject: Project = {
@ -566,11 +659,16 @@ export const useTaskStore = create<TaskStore>()(
selectProject: (id) => set({ selectedProjectId: id, selectedTaskId: null, selectedSprintId: null }), selectProject: (id) => set({ selectedProjectId: id, selectedTaskId: null, selectedSprintId: null }),
addTask: (task) => { addTask: (task) => {
const actor = profileToCommentAuthor(get().currentUser)
const newTask: Task = { const newTask: Task = {
...task, ...task,
id: Date.now().toString(), id: Date.now().toString(),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
createdById: actor.id,
createdByName: actor.name,
updatedById: actor.id,
updatedByName: actor.name,
comments: normalizeComments([]), comments: normalizeComments([]),
attachments: normalizeAttachments(task.attachments), attachments: normalizeAttachments(task.attachments),
} }
@ -584,6 +682,7 @@ export const useTaskStore = create<TaskStore>()(
updateTask: (id, updates) => { updateTask: (id, updates) => {
console.log('updateTask called:', id, updates) console.log('updateTask called:', id, updates)
set((state) => { set((state) => {
const actor = profileToCommentAuthor(state.currentUser)
const newTasks = state.tasks.map((t) => const newTasks = state.tasks.map((t) =>
t.id === id t.id === id
? normalizeTask({ ? normalizeTask({
@ -592,6 +691,8 @@ export const useTaskStore = create<TaskStore>()(
comments: updates.comments !== undefined ? normalizeComments(updates.comments) : t.comments, comments: updates.comments !== undefined ? normalizeComments(updates.comments) : t.comments,
attachments: updates.attachments !== undefined ? normalizeAttachments(updates.attachments) : t.attachments, attachments: updates.attachments !== undefined ? normalizeAttachments(updates.attachments) : t.attachments,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
updatedById: actor.id,
updatedByName: actor.name,
} as Task) } as Task)
: t : t
) )
@ -661,17 +762,25 @@ export const useTaskStore = create<TaskStore>()(
}, },
addComment: (taskId, text, author) => { addComment: (taskId, text, author) => {
const actor = normalizeCommentAuthor(author ?? profileToCommentAuthor(get().currentUser))
const newComment: Comment = { const newComment: Comment = {
id: Date.now().toString(), id: Date.now().toString(),
text, text,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
author, author: actor,
replies: [], replies: [],
} }
set((state) => { set((state) => {
const updater = profileToCommentAuthor(state.currentUser)
const newTasks = state.tasks.map((t) => const newTasks = state.tasks.map((t) =>
t.id === taskId t.id === taskId
? { ...t, comments: [...normalizeComments(t.comments), newComment], updatedAt: new Date().toISOString() } ? {
...t,
comments: [...normalizeComments(t.comments), newComment],
updatedAt: new Date().toISOString(),
updatedById: updater.id,
updatedByName: updater.name,
}
: t : t
) )
syncToServer(state.projects, newTasks, state.sprints) syncToServer(state.projects, newTasks, state.sprints)
@ -681,9 +790,16 @@ export const useTaskStore = create<TaskStore>()(
deleteComment: (taskId, commentId) => { deleteComment: (taskId, commentId) => {
set((state) => { set((state) => {
const updater = profileToCommentAuthor(state.currentUser)
const newTasks = state.tasks.map((t) => const newTasks = state.tasks.map((t) =>
t.id === taskId t.id === taskId
? { ...t, comments: removeCommentFromThread(normalizeComments(t.comments), commentId), updatedAt: new Date().toISOString() } ? {
...t,
comments: removeCommentFromThread(normalizeComments(t.comments), commentId),
updatedAt: new Date().toISOString(),
updatedById: updater.id,
updatedByName: updater.name,
}
: t : t
) )
syncToServer(state.projects, newTasks, state.sprints) syncToServer(state.projects, newTasks, state.sprints)
@ -706,7 +822,8 @@ export const useTaskStore = create<TaskStore>()(
{ {
name: 'task-store', name: 'task-store',
partialize: (state) => ({ partialize: (state) => ({
// Only persist UI state, not data // Persist user identity and UI state, not task data
currentUser: state.currentUser,
selectedProjectId: state.selectedProjectId, selectedProjectId: state.selectedProjectId,
selectedTaskId: state.selectedTaskId, selectedTaskId: state.selectedTaskId,
selectedSprintId: state.selectedSprintId, selectedSprintId: state.selectedSprintId,
@ -718,6 +835,7 @@ export const useTaskStore = create<TaskStore>()(
console.log('>>> PERSIST: Rehydration error:', error) console.log('>>> PERSIST: Rehydration error:', error)
} else { } else {
console.log('>>> PERSIST: Rehydrated state:', { console.log('>>> PERSIST: Rehydrated state:', {
currentUser: state?.currentUser?.name,
selectedProjectId: state?.selectedProjectId, selectedProjectId: state?.selectedProjectId,
selectedTaskId: state?.selectedTaskId, selectedTaskId: state?.selectedTaskId,
tasksCount: state?.tasks?.length, tasksCount: state?.tasks?.length,