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). - Added unlimited nested replies (reply to comment, reply to reply, no depth limit).
- Updated UI wording from "notes" to "comments". - 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. - 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 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:
@ -31,6 +33,25 @@ 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.

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

@ -31,7 +31,7 @@ import {
markdownPreviewObjectUrl, markdownPreviewObjectUrl,
textPreviewObjectUrl, textPreviewObjectUrl,
} from "@/lib/attachments" } 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 { 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"
@ -304,6 +304,7 @@ export default function Home() {
projects, projects,
tasks, tasks,
sprints, sprints,
currentUser,
selectedProjectId, selectedProjectId,
selectedTaskId, selectedTaskId,
addTask, addTask,
@ -312,6 +313,7 @@ export default function Home() {
selectTask, selectTask,
addComment, addComment,
deleteComment, deleteComment,
setCurrentUser,
syncFromServer, syncFromServer,
isLoading, isLoading,
} = useTaskStore() } = useTaskStore()
@ -332,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[]
@ -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) => { 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"]
@ -380,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) {
@ -549,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
@ -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 ( 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 */}
@ -649,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>
@ -1208,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">
@ -1244,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

@ -19,11 +19,13 @@ import {
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> = {
@ -73,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(),
@ -161,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()
@ -174,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) {
@ -244,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("")
} }
@ -256,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]: "" }))
@ -323,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">
@ -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) { 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">
@ -422,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">
@ -441,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>

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,