Add auth, remember-me sessions, and account settings
This commit is contained in:
parent
dc6722cd3f
commit
ed1d2d956a
21
README.md
21
README.md
@ -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.
|
||||
|
||||
54
src/app/api/auth/account/route.ts
Normal file
54
src/app/api/auth/account/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
38
src/app/api/auth/login/route.ts
Normal file
38
src/app/api/auth/login/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
15
src/app/api/auth/logout/route.ts
Normal file
15
src/app/api/auth/logout/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
38
src/app/api/auth/register/route.ts
Normal file
38
src/app/api/auth/register/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
16
src/app/api/auth/session/route.ts
Normal file
16
src/app/api/auth/session/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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
148
src/app/login/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
src/app/page.tsx
117
src/app/page.tsx
@ -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')
|
||||
})
|
||||
}, [syncFromServer])
|
||||
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()
|
||||
}, [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
240
src/app/settings/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 comment = entry as Partial<TaskComment>
|
||||
if (typeof comment.id !== "string" || typeof comment.text !== "string") 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") continue
|
||||
|
||||
return {
|
||||
id: comment.id,
|
||||
text: comment.text,
|
||||
createdAt: typeof comment.createdAt === "string" ? comment.createdAt : new Date().toISOString(),
|
||||
author: comment.author === "assistant" ? "assistant" : "user",
|
||||
replies: getComments(comment.replies),
|
||||
}
|
||||
normalized.push({
|
||||
id: comment.id,
|
||||
text: comment.text,
|
||||
createdAt: typeof comment.createdAt === "string" ? comment.createdAt : new Date().toISOString(),
|
||||
author: getCommentAuthor(comment.author),
|
||||
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)}`,
|
||||
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>
|
||||
<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 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
295
src/lib/server/auth.ts
Normal 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);
|
||||
}
|
||||
@ -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, []),
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user