Add auth, remember-me sessions, and account settings

This commit is contained in:
OpenClaw Bot 2026-02-20 13:12:33 -06:00
parent dc6722cd3f
commit ed1d2d956a
14 changed files with 1329 additions and 51 deletions

View File

@ -12,11 +12,13 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist
- 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
- 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.
- A task is considered in Backlog when `sprintId` is empty.
- Current status values:
@ -31,6 +33,25 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist
- `done`
- 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
- Project selection UI for tasks was removed in favor of labels.

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 { getData, saveData, type DataStore, type Task } from "@/lib/server/taskDb";
import { getAuthenticatedUser } from "@/lib/server/auth";
export const runtime = "nodejs";
// GET - fetch all tasks, projects, and sprints
export async function GET() {
try {
const user = await getAuthenticatedUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const data = getData();
return NextResponse.json(data);
} catch (error) {
@ -17,6 +22,11 @@ export async function GET() {
// POST - create or update tasks, projects, or sprints
export async function POST(request: Request) {
try {
const user = await getAuthenticatedUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { task, tasks, projects, sprints } = body as {
task?: Task;
@ -33,19 +43,34 @@ export async function POST(request: Request) {
if (task) {
const existingIndex = data.tasks.findIndex((t) => t.id === task.id);
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 {
data.tasks.push({
...task,
id: task.id || Date.now().toString(),
createdAt: 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)) {
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);
@ -59,6 +84,11 @@ export async function POST(request: Request) {
// DELETE - remove a task
export async function DELETE(request: Request) {
try {
const user = await getAuthenticatedUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = (await request.json()) as { id: string };
const data = getData();
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 });
}
}

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

@ -31,7 +31,7 @@ import {
markdownPreviewObjectUrl,
textPreviewObjectUrl,
} from "@/lib/attachments"
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment } from "@/stores/useTaskStore"
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, type CommentAuthor } from "@/stores/useTaskStore"
import { BacklogView } from "@/components/BacklogView"
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download } from "lucide-react"
@ -304,6 +304,7 @@ export default function Home() {
projects,
tasks,
sprints,
currentUser,
selectedProjectId,
selectedTaskId,
addTask,
@ -312,6 +313,7 @@ export default function Home() {
selectTask,
addComment,
deleteComment,
setCurrentUser,
syncFromServer,
isLoading,
} = useTaskStore()
@ -332,6 +334,7 @@ export default function Home() {
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
const [activeKanbanTaskId, setActiveKanbanTaskId] = useState<string | null>(null)
const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState<string | null>(null)
const [authReady, setAuthReady] = useState(false)
const getTags = (taskLike: { tags?: unknown }) => {
if (!Array.isArray(taskLike.tags)) return [] as string[]
@ -352,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) => {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"
const units = ["B", "KB", "MB", "GB"]
@ -380,13 +404,39 @@ export default function Home() {
const allLabels = useMemo(() => labelUsage.map(([label]) => label), [labelUsage])
// Sync from server on mount
useEffect(() => {
console.log('>>> PAGE: useEffect for syncFromServer running')
syncFromServer().then(() => {
console.log('>>> PAGE: syncFromServer completed')
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,
})
}, [syncFromServer])
setAuthReady(true)
} catch {
if (isMounted) router.replace("/login")
}
}
loadSession()
return () => {
isMounted = false
}
}, [router, setCurrentUser])
useEffect(() => {
if (!authReady) return
syncFromServer()
}, [authReady, syncFromServer])
useEffect(() => {
if (selectedTaskId) {
@ -549,11 +599,20 @@ export default function Home() {
const handleAddComment = () => {
if (newComment.trim() && selectedTaskId) {
addComment(selectedTaskId, newComment.trim(), "user")
addComment(selectedTaskId, newComment.trim())
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 files = Array.from(event.target.files || [])
if (files.length === 0) return
@ -622,6 +681,14 @@ export default function Home() {
}
}
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">
{/* Header */}
@ -649,6 +716,23 @@ export default function Home() {
<span className="hidden md:inline text-sm text-slate-400">
{tasks.length} tasks · {allLabels.length} labels
</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>
@ -1208,26 +1292,30 @@ export default function Home() {
{!editedTask.comments || editedTask.comments.length === 0 ? (
<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
key={comment.id}
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
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-slate-700 text-slate-300"
}`}
>
{comment.author === "assistant" ? "AI" : "You"}
{isAssistant ? "AI" : displayName.slice(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-slate-300">
{comment.author === "assistant" ? "Assistant" : "You"}
{isAssistant ? "Assistant" : displayName}
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">
@ -1244,7 +1332,8 @@ export default function Home() {
<p className="text-slate-300 text-sm whitespace-pre-wrap">{comment.text}</p>
</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

@ -19,11 +19,13 @@ import {
import {
useTaskStore,
type Comment as TaskComment,
type CommentAuthor,
type Priority,
type Task,
type TaskAttachment,
type TaskStatus,
type TaskType,
type UserProfile,
} from "@/stores/useTaskStore"
const typeColors: Record<TaskType, string> = {
@ -73,24 +75,53 @@ const getAttachments = (taskLike: { attachments?: unknown }) => {
const getComments = (value: unknown): TaskComment[] => {
if (!Array.isArray(value)) return []
return value
.map((entry) => {
if (!entry || typeof entry !== "object") return null
const normalized: TaskComment[] = []
for (const entry of value) {
if (!entry || typeof entry !== "object") continue
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,
text: comment.text,
createdAt: typeof comment.createdAt === "string" ? comment.createdAt : new Date().toISOString(),
author: comment.author === "assistant" ? "assistant" : "user",
author: getCommentAuthor(comment.author),
replies: getComments(comment.replies),
}
})
.filter((comment): comment is TaskComment => comment !== null)
}
const buildComment = (text: string, author: "user" | "assistant" = "user"): TaskComment => ({
return normalized
}
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)}`,
text,
createdAt: new Date().toISOString(),
@ -161,8 +192,10 @@ export default function TaskDetailPage() {
const {
tasks,
sprints,
currentUser,
updateTask,
deleteTask,
setCurrentUser,
syncFromServer,
isLoading,
} = useTaskStore()
@ -174,10 +207,40 @@ export default function TaskDetailPage() {
const [isSaving, setIsSaving] = useState(false)
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({})
const [openReplyEditors, setOpenReplyEditors] = useState<Record<string, boolean>>({})
const [authReady, setAuthReady] = useState(false)
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])
}, [authReady, syncFromServer])
useEffect(() => {
if (selectedTask) {
@ -244,9 +307,10 @@ export default function TaskDetailPage() {
const handleAddComment = () => {
if (!editedTask || !newComment.trim()) return
const actor = profileToAuthor(currentUser)
setEditedTask({
...editedTask,
comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), "user")],
comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), actor)],
})
setNewComment("")
}
@ -256,9 +320,10 @@ export default function TaskDetailPage() {
const text = replyDrafts[parentId]?.trim()
if (!text) return
const actor = profileToAuthor(currentUser)
setEditedTask({
...editedTask,
comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, "user")),
comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, actor)),
})
setReplyDrafts((prev) => ({ ...prev, [parentId]: "" }))
@ -323,21 +388,33 @@ export default function TaskDetailPage() {
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[] =>
comments.map((comment) => {
const replies = getComments(comment.replies)
const isReplying = !!openReplyEditors[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 (
<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 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"}`}>
{comment.author === "assistant" ? "AI" : "You"}
<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"}`}>
{isAssistant ? "AI" : displayName.slice(0, 2).toUpperCase()}
</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>
</div>
<div className="flex items-center gap-2">
@ -396,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) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 p-6">
@ -422,7 +507,23 @@ export default function TaskDetailPage() {
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Board
</Button>
<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 className="border border-slate-800 rounded-xl bg-slate-900/60 p-4 md:p-6">
@ -441,6 +542,9 @@ export default function TaskDetailPage() {
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"
/>
<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>

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;
text: string;
createdAt: string;
author: "user" | "assistant";
author: TaskCommentAuthor | "user" | "assistant";
replies?: TaskComment[];
}
export interface TaskCommentAuthor {
id: string;
name: string;
email?: string;
type: "human" | "assistant";
}
export interface Task {
id: string;
title: string;
@ -30,6 +37,10 @@ export interface Task {
sprintId?: string;
createdAt: string;
updatedAt: string;
createdById?: string;
createdByName?: string;
updatedById?: string;
updatedByName?: string;
dueDate?: string;
comments: TaskComment[];
tags: string[];
@ -126,13 +137,43 @@ function normalizeComments(comments: unknown): TaskComment[] {
id: value.id,
text: value.text,
createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(),
author: value.author === "assistant" ? "assistant" : "user",
author: normalizeCommentAuthor(value.author),
replies: normalizeComments(value.replies),
};
})
.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 {
return {
id: String(task.id ?? Date.now()),
@ -145,6 +186,10 @@ function normalizeTask(task: Partial<Task>): Task {
sprintId: task.sprintId || undefined,
createdAt: task.createdAt || 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,
comments: normalizeComments(task.comments),
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)
`);
const insertTask = database.prepare(`
INSERT INTO tasks (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, @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, @createdById, @createdByName, @updatedById, @updatedByName, @dueDate, @comments, @tags, @attachments)
`);
for (const project of payload.projects) {
@ -214,6 +259,10 @@ function replaceAllData(database: SqliteDb, data: DataStore) {
insertTask.run({
...task,
sprintId: task.sprintId ?? null,
createdById: task.createdById ?? null,
createdByName: task.createdByName ?? null,
updatedById: task.updatedById ?? null,
updatedByName: task.updatedByName ?? null,
dueDate: task.dueDate ?? null,
comments: JSON.stringify(task.comments ?? []),
tags: JSON.stringify(task.tags ?? []),
@ -280,6 +329,10 @@ function getDb(): SqliteDb {
sprintId TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
createdById TEXT,
createdByName TEXT,
updatedById TEXT,
updatedByName TEXT,
dueDate TEXT,
comments TEXT NOT NULL DEFAULT '[]',
tags TEXT NOT NULL DEFAULT '[]',
@ -296,6 +349,18 @@ function getDb(): SqliteDb {
if (!taskColumns.some((column) => column.name === "attachments")) {
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);
db = database;
@ -335,6 +400,10 @@ export function getData(): DataStore {
sprintId: string | null;
createdAt: string;
updatedAt: string;
createdById: string | null;
createdByName: string | null;
updatedById: string | null;
updatedByName: string | null;
dueDate: string | null;
comments: string | null;
tags: string | null;
@ -370,6 +439,10 @@ export function getData(): DataStore {
sprintId: task.sprintId ?? undefined,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
createdById: task.createdById ?? undefined,
createdByName: task.createdByName ?? undefined,
updatedById: task.updatedById ?? undefined,
updatedByName: task.updatedByName ?? undefined,
dueDate: task.dueDate ?? undefined,
comments: normalizeComments(safeParseArray(task.comments, [])),
tags: safeParseArray(task.tags, []),

View File

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