From a353ed0feb4434f225d8071f01abec87ecd17016 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Fri, 20 Feb 2026 13:43:41 -0600 Subject: [PATCH] Signed-off-by: OpenClaw Bot --- README.md | 32 +++- src/app/api/auth/account/route.ts | 4 + src/app/api/auth/users/route.ts | 17 ++ src/app/api/tasks/route.ts | 15 ++ src/app/page.tsx | 255 +++++++++++++++++++++++++++--- src/app/settings/page.tsx | 238 ++++++++++++++++++++++++---- src/app/tasks/[taskId]/page.tsx | 178 ++++++++++++++++++++- src/components/BacklogView.tsx | 15 ++ src/lib/avatar.ts | 58 +++++++ src/lib/server/auth.ts | 56 ++++++- src/lib/server/taskDb.ts | 132 ++++++++++++---- src/stores/useTaskStore.ts | 30 +++- 12 files changed, 923 insertions(+), 107 deletions(-) create mode 100644 src/app/api/auth/users/route.ts create mode 100644 src/lib/avatar.ts diff --git a/README.md b/README.md index 14c921c..d8e667b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist - Tasks use labels (`tags: string[]`) and can have multiple labels. - Tasks support attachments (`attachments: TaskAttachment[]`). -- Tasks now track `createdById`, `createdByName`, `updatedById`, and `updatedByName`. +- Tasks now track `createdById`, `createdByName`, `createdByAvatarUrl`, `updatedById`, `updatedByName`, and `updatedByAvatarUrl`. +- Tasks now track assignment via `assigneeId`, `assigneeName`, `assigneeEmail`, and `assigneeAvatarUrl`. - There is no active `backlog` status in workflow logic. - A task is considered in Backlog when `sprintId` is empty. - Current status values: @@ -45,12 +46,39 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist - 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. +- Added `PATCH /api/auth/account` to update profile and 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. +- Added `GET /api/auth/users` so task forms can load assignable users. +- User records now include optional `avatarUrl` profile photos. + +### Task assignment + +- You can assign or unassign tasks in: + - New Task modal + - Task detail popup on board + - URL task detail page (`/tasks/{taskId}`) +- New tasks default to the current signed-in user as assignee. +- Kanban and Backlog cards show a visible assignee avatar/initial pill. +- When someone else updates status (for example closes a task), updater fields still track who made that change. + +### Profile photos and account settings + +- Account Settings (`/settings`) has separate save actions: + - `Save Profile` for name/email/photo + - `Update Password` for password-only changes +- Profile photos can be uploaded/removed in settings. +- Users without uploaded photos get a generated default avatar (unique by user seed). +- Settings also provides multiple preset avatar choices. +- Profile photos appear in: + - Board/task header identity chips + - Assignee pills on Kanban and Backlog + - Threaded comment author avatars + - Creator/updater identity rows on task detail views +- Avatar updates persist in SQLite and propagate through session/user APIs. ### Labels diff --git a/src/app/api/auth/account/route.ts b/src/app/api/auth/account/route.ts index d714d07..634b54f 100644 --- a/src/app/api/auth/account/route.ts +++ b/src/app/api/auth/account/route.ts @@ -13,12 +13,14 @@ export async function PATCH(request: Request) { const body = (await request.json()) as { name?: string; email?: string; + avatarUrl?: string | null; currentPassword?: string; newPassword?: string; }; const nextName = typeof body.name === "string" ? body.name : undefined; const nextEmail = typeof body.email === "string" ? body.email : undefined; + const nextAvatarUrl = body.avatarUrl === null || typeof body.avatarUrl === "string" ? body.avatarUrl : undefined; const currentPassword = typeof body.currentPassword === "string" ? body.currentPassword : undefined; const newPassword = typeof body.newPassword === "string" ? body.newPassword : undefined; @@ -26,6 +28,7 @@ export async function PATCH(request: Request) { userId: sessionUser.id, name: nextName, email: nextEmail, + avatarUrl: nextAvatarUrl, currentPassword, newPassword, }); @@ -46,6 +49,7 @@ export async function PATCH(request: Request) { message.includes("Current password is required") || message.includes("at least") || message.includes("Invalid email") + || message.includes("Avatar") ) { return NextResponse.json({ error: message }, { status: 400 }); } diff --git a/src/app/api/auth/users/route.ts b/src/app/api/auth/users/route.ts new file mode 100644 index 0000000..4a33e52 --- /dev/null +++ b/src/app/api/auth/users/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { getAuthenticatedUser, listUsers } from "@/lib/server/auth"; + +export const runtime = "nodejs"; + +export async function GET() { + try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + return NextResponse.json({ users: listUsers() }); + } catch { + return NextResponse.json({ error: "Failed to load users" }, { status: 500 }); + } +} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 47ee38d..1b97f86 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -43,11 +43,14 @@ export async function POST(request: Request) { if (task) { const existingIndex = data.tasks.findIndex((t) => t.id === task.id); if (existingIndex >= 0) { + const existingTask = data.tasks[existingIndex]; data.tasks[existingIndex] = { + ...existingTask, ...task, updatedAt: new Date().toISOString(), updatedById: user.id, updatedByName: user.name, + updatedByAvatarUrl: user.avatarUrl, }; } else { data.tasks.push({ @@ -57,8 +60,14 @@ export async function POST(request: Request) { updatedAt: new Date().toISOString(), createdById: task.createdById || user.id, createdByName: task.createdByName || user.name, + createdByAvatarUrl: task.createdByAvatarUrl || user.avatarUrl, updatedById: user.id, updatedByName: user.name, + updatedByAvatarUrl: user.avatarUrl, + assigneeId: task.assigneeId || user.id, + assigneeName: task.assigneeName || user.name, + assigneeEmail: task.assigneeEmail || user.email, + assigneeAvatarUrl: task.assigneeAvatarUrl || user.avatarUrl, }); } } @@ -68,8 +77,14 @@ export async function POST(request: Request) { ...entry, createdById: entry.createdById || user.id, createdByName: entry.createdByName || user.name, + createdByAvatarUrl: entry.createdByAvatarUrl || (entry.createdById === user.id ? user.avatarUrl : undefined), updatedById: entry.updatedById || user.id, updatedByName: entry.updatedByName || user.name, + updatedByAvatarUrl: entry.updatedByAvatarUrl || (entry.updatedById === user.id ? user.avatarUrl : undefined), + assigneeId: entry.assigneeId || undefined, + assigneeName: entry.assigneeName || undefined, + assigneeEmail: entry.assigneeEmail || undefined, + assigneeAvatarUrl: entry.assigneeAvatarUrl || undefined, })); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 46c75bc..1229de6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -22,6 +22,7 @@ import { Badge } from "@/components/ui/badge" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" import { Textarea } from "@/components/ui/textarea" import { Label } from "@/components/ui/label" +import { generateAvatarDataUrl } from "@/lib/avatar" import { blobFromDataUrl, coerceDataUrlMimeType, @@ -35,6 +36,13 @@ import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, typ import { BacklogView } from "@/components/BacklogView" import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download } from "lucide-react" +interface AssignableUser { + id: string + name: string + email?: string + avatarUrl?: string +} + const typeColors: Record = { idea: "bg-purple-500", task: "bg-blue-500", @@ -60,6 +68,30 @@ const priorityColors: Record = { const allStatuses: TaskStatus[] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"] +function AvatarCircle({ + name, + avatarUrl, + seed, + sizeClass = "h-6 w-6", + title, +}: { + name?: string + avatarUrl?: string + seed?: string + sizeClass?: string + title?: string +}) { + const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "default", name || "User") + return ( + {name + ) +} + // Sprint board columns mapped to workflow statuses const sprintColumns: { key: string; label: string; statuses: TaskStatus[] }[] = [ { @@ -272,12 +304,20 @@ function KanbanTaskCard({ )} - {task.dueDate && ( - - - {new Date(task.dueDate).toLocaleDateString()} - - )} +
+ + {task.dueDate && ( + + + {new Date(task.dueDate).toLocaleDateString()} + + )} +
{taskTags.length > 0 && ( @@ -335,6 +375,7 @@ export default function Home() { const [activeKanbanTaskId, setActiveKanbanTaskId] = useState(null) const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState(null) const [authReady, setAuthReady] = useState(false) + const [users, setUsers] = useState([]) const getTags = (taskLike: { tags?: unknown }) => { if (!Array.isArray(taskLike.tags)) return [] as string[] @@ -372,6 +413,7 @@ export default function Home() { 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, + avatarUrl: typeof candidate.avatarUrl === "string" && candidate.avatarUrl ? candidate.avatarUrl : undefined, type, } } @@ -403,6 +445,21 @@ export default function Home() { }, [tasks]) const allLabels = useMemo(() => labelUsage.map(([label]) => label), [labelUsage]) + const assignableUsers = useMemo(() => { + const byId = new Map() + users.forEach((user) => { + if (user.id) byId.set(user.id, user) + }) + if (currentUser.id) { + byId.set(currentUser.id, { + id: currentUser.id, + name: currentUser.name, + email: currentUser.email, + avatarUrl: currentUser.avatarUrl, + }) + } + return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name)) + }, [users, currentUser]) useEffect(() => { let isMounted = true @@ -420,6 +477,7 @@ export default function Home() { id: data.user.id, name: data.user.name, email: data.user.email, + avatarUrl: data.user.avatarUrl, }) setAuthReady(true) } catch { @@ -438,6 +496,49 @@ export default function Home() { syncFromServer() }, [authReady, syncFromServer]) + useEffect(() => { + if (!authReady) return + let isMounted = true + + const loadUsers = async () => { + try { + const res = await fetch("/api/auth/users", { cache: "no-store" }) + if (!res.ok) return + const data = await res.json() + if (!isMounted) return + const nextUsers = Array.isArray(data.users) ? (data.users as Array>) : [] + setUsers( + nextUsers + .filter((entry): entry is Partial & { id: string; name: string } => + !!entry && typeof entry.id === "string" && typeof entry.name === "string" + ) + .map((entry) => ({ id: entry.id, name: entry.name, email: entry.email, avatarUrl: entry.avatarUrl })) + ) + } catch { + // ignore + } + } + + loadUsers() + return () => { + isMounted = false + } + }, [authReady]) + + useEffect(() => { + if (!authReady) return + setNewTask((prev) => { + if (prev.assigneeId) return prev + return { + ...prev, + assigneeId: currentUser.id, + assigneeName: currentUser.name, + assigneeEmail: currentUser.email, + assigneeAvatarUrl: currentUser.avatarUrl, + } + }) + }, [authReady, currentUser.id, currentUser.name, currentUser.email, currentUser.avatarUrl]) + useEffect(() => { if (selectedTaskId) { selectTask(null) @@ -573,6 +674,56 @@ export default function Home() { setDragOverKanbanColumnKey(null) } + const resolveAssignee = (assigneeId: string | undefined) => { + if (!assigneeId) return null + return assignableUsers.find((user) => user.id === assigneeId) || null + } + + const setNewTaskAssignee = (assigneeId: string) => { + if (!assigneeId) { + setNewTask((prev) => ({ + ...prev, + assigneeId: undefined, + assigneeName: undefined, + assigneeEmail: undefined, + assigneeAvatarUrl: undefined, + })) + return + } + + const assignee = resolveAssignee(assigneeId) + setNewTask((prev) => ({ + ...prev, + assigneeId, + assigneeName: assignee?.name || prev.assigneeName, + assigneeEmail: assignee?.email, + assigneeAvatarUrl: assignee?.avatarUrl, + })) + } + + const setEditedTaskAssignee = (assigneeId: string) => { + if (!editedTask) return + if (!assigneeId) { + setEditedTask({ + ...editedTask, + assigneeId: undefined, + assigneeName: undefined, + assigneeEmail: undefined, + assigneeAvatarUrl: undefined, + }) + return + } + + const assignee = resolveAssignee(assigneeId) + setEditedTask({ + ...editedTask, + assigneeId, + assigneeName: assignee?.name || editedTask.assigneeName, + assigneeEmail: assignee?.email, + assigneeAvatarUrl: assignee?.avatarUrl, + }) + } + const handleAddTask = () => { if (newTask.title?.trim()) { // If a specific sprint is selected, use that sprint's project @@ -588,10 +739,26 @@ export default function Home() { tags: newTask.tags || [], projectId: targetProjectId, sprintId: newTask.sprintId || currentSprint?.id, + assigneeId: newTask.assigneeId, + assigneeName: newTask.assigneeName, + assigneeEmail: newTask.assigneeEmail, + assigneeAvatarUrl: newTask.assigneeAvatarUrl, } addTask(taskToCreate) - setNewTask({ title: "", description: "", type: "task", priority: "medium", status: "open", tags: [], sprintId: undefined }) + setNewTask({ + title: "", + description: "", + type: "task", + priority: "medium", + status: "open", + tags: [], + sprintId: undefined, + assigneeId: currentUser.id, + assigneeName: currentUser.name, + assigneeEmail: currentUser.email, + assigneeAvatarUrl: currentUser.avatarUrl, + }) setNewTaskLabelInput("") setNewTaskOpen(false) } @@ -716,9 +883,10 @@ export default function Home() { {tasks.length} tasks · {allLabels.length} labels - - {currentUser.name} - +
+ + {currentUser.name} +
+ )} + + +

+ {profileAvatarUrl ? "Using custom avatar." : `Using generated default avatar (${getInitials(name || email || "User")}).`} +

+
+

Or pick a preset:

+
+ {avatarPresets.map((presetUrl, index) => ( + + ))} +
+
+ +
+ +
+ + setProfileCurrentPassword(event.target.value)} + className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white" + placeholder="Current password" + /> +
+ + + {profileError &&

{profileError}

} + {profileSuccess &&

{profileSuccess}

} + +
+

Change Password

-

Leave blank if you do not want to change it.

+

Update password separately from profile settings.

@@ -225,12 +395,12 @@ export default function SettingsPage() {
- {error &&

{error}

} - {success &&

{success}

} + {passwordError &&

{passwordError}

} + {passwordSuccess &&

{passwordSuccess}

}
-
diff --git a/src/app/tasks/[taskId]/page.tsx b/src/app/tasks/[taskId]/page.tsx index de51963..ecbf187 100644 --- a/src/app/tasks/[taskId]/page.tsx +++ b/src/app/tasks/[taskId]/page.tsx @@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" +import { generateAvatarDataUrl } from "@/lib/avatar" import { blobFromDataUrl, coerceDataUrlMimeType, @@ -28,6 +29,13 @@ import { type UserProfile, } from "@/stores/useTaskStore" +interface AssignableUser { + id: string + name: string + email?: string + avatarUrl?: string +} + const typeColors: Record = { idea: "bg-purple-500", task: "bg-blue-500", @@ -110,6 +118,7 @@ const getCommentAuthor = (value: unknown): CommentAuthor => { 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, + avatarUrl: typeof candidate.avatarUrl === "string" && candidate.avatarUrl ? candidate.avatarUrl : undefined, type, } } @@ -118,6 +127,7 @@ const profileToAuthor = (profile: UserProfile): CommentAuthor => ({ id: profile.id, name: profile.name, email: profile.email, + avatarUrl: profile.avatarUrl, type: "human", }) @@ -175,6 +185,30 @@ const formatBytes = (bytes: number) => { return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}` } +function AvatarCircle({ + name, + avatarUrl, + seed, + sizeClass = "h-7 w-7", + title, +}: { + name?: string + avatarUrl?: string + seed?: string + sizeClass?: string + title?: string +}) { + const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "default", name || "User") + return ( + {name + ) +} + const readFileAsDataUrl = (file: File) => new Promise((resolve, reject) => { const reader = new FileReader() @@ -208,6 +242,7 @@ export default function TaskDetailPage() { const [replyDrafts, setReplyDrafts] = useState>({}) const [openReplyEditors, setOpenReplyEditors] = useState>({}) const [authReady, setAuthReady] = useState(false) + const [users, setUsers] = useState([]) useEffect(() => { let isMounted = true @@ -224,6 +259,7 @@ export default function TaskDetailPage() { id: data.user.id, name: data.user.name, email: data.user.email, + avatarUrl: data.user.avatarUrl, }) setAuthReady(true) } catch { @@ -242,6 +278,35 @@ export default function TaskDetailPage() { syncFromServer() }, [authReady, syncFromServer]) + useEffect(() => { + if (!authReady) return + let isMounted = true + + const loadUsers = async () => { + try { + const res = await fetch("/api/auth/users", { cache: "no-store" }) + if (!res.ok) return + const data = await res.json() + if (!isMounted) return + const nextUsers = Array.isArray(data.users) ? (data.users as Array>) : [] + setUsers( + nextUsers + .filter((entry): entry is Partial & { id: string; name: string } => + !!entry && typeof entry.id === "string" && typeof entry.name === "string" + ) + .map((entry) => ({ id: entry.id, name: entry.name, email: entry.email, avatarUrl: entry.avatarUrl })) + ) + } catch { + // ignore + } + } + + loadUsers() + return () => { + isMounted = false + } + }, [authReady]) + useEffect(() => { if (selectedTask) { setEditedTask({ @@ -268,6 +333,22 @@ export default function TaskDetailPage() { return Array.from(labels.keys()) }, [tasks]) + const assignableUsers = useMemo(() => { + const byId = new Map() + users.forEach((user) => { + if (user.id) byId.set(user.id, user) + }) + if (currentUser.id) { + byId.set(currentUser.id, { + id: currentUser.id, + name: currentUser.name, + email: currentUser.email, + avatarUrl: currentUser.avatarUrl, + }) + } + return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name)) + }, [users, currentUser]) + const handleAttachmentUpload = async (event: ChangeEvent) => { const files = Array.from(event.target.files || []) if (files.length === 0) return @@ -339,6 +420,34 @@ export default function TaskDetailPage() { }) } + const resolveAssignee = (assigneeId: string | undefined) => { + if (!assigneeId) return null + return assignableUsers.find((user) => user.id === assigneeId) || null + } + + const setEditedTaskAssignee = (assigneeId: string) => { + if (!editedTask) return + if (!assigneeId) { + setEditedTask({ + ...editedTask, + assigneeId: undefined, + assigneeName: undefined, + assigneeEmail: undefined, + assigneeAvatarUrl: undefined, + }) + return + } + + const assignee = resolveAssignee(assigneeId) + setEditedTask({ + ...editedTask, + assigneeId, + assigneeName: assignee?.name || editedTask.assigneeName, + assigneeEmail: assignee?.email, + assigneeAvatarUrl: assignee?.avatarUrl, + }) + } + const handleSave = () => { if (!editedTask) return setIsSaving(true) @@ -397,7 +506,7 @@ export default function TaskDetailPage() { } } - const renderThread = (comments: TaskComment[], depth = 0): JSX.Element[] => + const renderThread = (comments: TaskComment[], depth = 0) => comments.map((comment) => { const replies = getComments(comment.replies) const isReplying = !!openReplyEditors[comment.id] @@ -405,15 +514,20 @@ export default function TaskDetailPage() { const author = getCommentAuthor(comment.author) const isAssistant = author.type === "assistant" const displayName = isAssistant ? "Assistant" : author.id === currentUser.id ? "You" : author.name + const resolvedAuthorAvatar = author.avatarUrl || resolveAssignee(author.id)?.avatarUrl return (
- - {isAssistant ? "AI" : displayName.slice(0, 2).toUpperCase()} - + {isAssistant ? ( + + AI + + ) : ( + + )} {displayName} {new Date(comment.createdAt).toLocaleString()}
@@ -521,7 +635,10 @@ export default function TaskDetailPage() { onClick={handleLogout} className="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:border-slate-500" > - Logout {currentUser.name} + + + Logout {currentUser.name} +
@@ -542,9 +659,38 @@ 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" /> -

- Created by {editedTask.createdByName || "Unknown"}{editedTask.updatedByName ? ` · Last updated by ${editedTask.updatedByName}` : ""} -

+
+ + + Created by {editedTask.createdByName || "Unknown"} + + + + Last updated by {editedTask.updatedByName || "Unknown"} + +
+
+ Assignee + + + {editedTask.assigneeName || "Unassigned"} + +
@@ -611,6 +757,22 @@ export default function TaskDetailPage() {
+
+ + +
+
diff --git a/src/components/BacklogView.tsx b/src/components/BacklogView.tsx index d5a96ed..449c366 100644 --- a/src/components/BacklogView.tsx +++ b/src/components/BacklogView.tsx @@ -25,6 +25,7 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Plus, GripVertical, ChevronDown, ChevronRight, Calendar } from "lucide-react" import { format, isValid, parseISO } from "date-fns" +import { generateAvatarDataUrl } from "@/lib/avatar" const priorityColors: Record = { low: "bg-slate-600", @@ -41,6 +42,18 @@ const typeLabels: Record = { plan: "📐", } +function AssigneeAvatar({ name, avatarUrl, seed }: { name?: string; avatarUrl?: string; seed?: string }) { + const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "unassigned", name || "Unassigned") + return ( + {name + ) +} + // Sortable Task Row function SortableTaskRow({ task, @@ -90,6 +103,7 @@ function SortableTaskRow({ {task.comments && task.comments.length > 0 && ( 💬 {task.comments.length} )} +
) } @@ -106,6 +120,7 @@ function DragOverlayItem({ task }: { task: Task }) { {task.priority} +
) } diff --git a/src/lib/avatar.ts b/src/lib/avatar.ts new file mode 100644 index 0000000..02eb315 --- /dev/null +++ b/src/lib/avatar.ts @@ -0,0 +1,58 @@ +const AVATAR_PALETTES = [ + ["#1f2937", "#3b82f6"], + ["#0f172a", "#22c55e"], + ["#172554", "#06b6d4"], + ["#3f1d2e", "#ec4899"], + ["#1f2937", "#f59e0b"], + ["#111827", "#a855f7"], + ["#052e16", "#14b8a6"], + ["#3f3f46", "#e11d48"], +] as const; + +export function getInitials(name?: string): string { + return (name || "User") + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() || "") + .join("") || "U"; +} + +function hashSeed(seed: string): number { + let hash = 0; + for (let i = 0; i < seed.length; i += 1) { + hash = (hash * 31 + seed.charCodeAt(i)) >>> 0; + } + return hash; +} + +export function generateAvatarDataUrl(seed: string, name?: string, paletteOffset = 0): string { + const safeSeed = seed || "default"; + const hash = hashSeed(safeSeed); + const paletteIndex = (hash + paletteOffset) % AVATAR_PALETTES.length; + const palette = AVATAR_PALETTES[paletteIndex]; + const initials = getInitials(name); + + const svg = ` + + + + + + + + + + ${initials} +`; + + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} + +export function buildAvatarPresets(seed: string, name?: string, count = 6): string[] { + const urls: string[] = []; + for (let i = 0; i < count; i += 1) { + urls.push(generateAvatarDataUrl(`${seed}:${i}`, name, i)); + } + return urls; +} diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index c29de6d..26eae54 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -18,6 +18,7 @@ export interface AuthUser { id: string; name: string; email: string; + avatarUrl?: string; createdAt: string; } @@ -29,8 +30,31 @@ function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } +function normalizeAvatarDataUrl(value: string | null | undefined): string | undefined { + if (value == null) return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (!trimmed.startsWith("data:image/")) { + throw new Error("Avatar must be an image"); + } + if (trimmed.length > 2_000_000) { + throw new Error("Avatar image is too large"); + } + return trimmed; +} + +function ensureUserSchema(database: SqliteDb) { + const userColumns = database.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>; + if (!userColumns.some((column) => column.name === "avatarUrl")) { + database.exec("ALTER TABLE users ADD COLUMN avatarUrl TEXT;"); + } +} + function getDb(): SqliteDb { - if (db) return db; + if (db) { + ensureUserSchema(db); + return db; + } mkdirSync(DATA_DIR, { recursive: true }); const database = new Database(DB_FILE); @@ -40,6 +64,7 @@ function getDb(): SqliteDb { id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, + avatarUrl TEXT, passwordHash TEXT NOT NULL, createdAt TEXT NOT NULL ); @@ -57,6 +82,8 @@ function getDb(): SqliteDb { CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expiresAt); `); + ensureUserSchema(database); + db = database; return database; } @@ -115,12 +142,13 @@ export function registerUser(params: { id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, name, email, + avatarUrl: undefined, 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); + .prepare("INSERT INTO users (id, name, email, avatarUrl, passwordHash, createdAt) VALUES (?, ?, ?, ?, ?, ?)") + .run(user.id, user.name, user.email, user.avatarUrl ?? null, hashPassword(password), user.createdAt); return user; } @@ -133,7 +161,7 @@ export function authenticateUser(params: { deleteExpiredSessions(database); const email = normalizeEmail(params.email); const row = database - .prepare("SELECT id, name, email, passwordHash, createdAt FROM users WHERE email = ? LIMIT 1") + .prepare("SELECT id, name, email, avatarUrl, 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; @@ -142,6 +170,7 @@ export function authenticateUser(params: { id: row.id, name: row.name, email: row.email, + avatarUrl: row.avatarUrl ?? undefined, createdAt: row.createdAt, }; } @@ -150,6 +179,7 @@ export function updateUserAccount(params: { userId: string; name?: string; email?: string; + avatarUrl?: string | null; currentPassword?: string; newPassword?: string; }): AuthUser { @@ -157,13 +187,15 @@ export function updateUserAccount(params: { deleteExpiredSessions(database); const row = database - .prepare("SELECT id, name, email, passwordHash, createdAt FROM users WHERE id = ? LIMIT 1") + .prepare("SELECT id, name, email, avatarUrl, 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 hasAvatarInput = Object.prototype.hasOwnProperty.call(params, "avatarUrl"); + const requestedAvatar = hasAvatarInput ? normalizeAvatarDataUrl(params.avatarUrl) : row.avatarUrl; const currentPassword = params.currentPassword || ""; const newPassword = params.newPassword || ""; @@ -192,17 +224,25 @@ export function updateUserAccount(params: { const nextPasswordHash = passwordChanged ? hashPassword(newPassword) : row.passwordHash; database - .prepare("UPDATE users SET name = ?, email = ?, passwordHash = ? WHERE id = ?") - .run(requestedName, requestedEmail, nextPasswordHash, row.id); + .prepare("UPDATE users SET name = ?, email = ?, avatarUrl = ?, passwordHash = ? WHERE id = ?") + .run(requestedName, requestedEmail, requestedAvatar ?? null, nextPasswordHash, row.id); return { id: row.id, name: requestedName, email: requestedEmail, + avatarUrl: requestedAvatar ?? undefined, createdAt: row.createdAt, }; } +export function listUsers(): AuthUser[] { + const database = getDb(); + return database + .prepare("SELECT id, name, email, avatarUrl, createdAt FROM users ORDER BY LOWER(name) ASC") + .all() as AuthUser[]; +} + export function createUserSession(userId: string, rememberMe: boolean): { token: string; expiresAt: string; @@ -240,7 +280,7 @@ export function getUserBySessionToken(token: string): AuthUser | null { const now = new Date().toISOString(); const row = database .prepare(` - SELECT u.id, u.name, u.email, u.createdAt + SELECT u.id, u.name, u.email, u.avatarUrl, u.createdAt FROM sessions s JOIN users u ON u.id = s.userId WHERE s.tokenHash = ? AND s.expiresAt > ? diff --git a/src/lib/server/taskDb.ts b/src/lib/server/taskDb.ts index f860882..9d9e5ec 100644 --- a/src/lib/server/taskDb.ts +++ b/src/lib/server/taskDb.ts @@ -23,6 +23,7 @@ export interface TaskCommentAuthor { id: string; name: string; email?: string; + avatarUrl?: string; type: "human" | "assistant"; } @@ -39,8 +40,14 @@ export interface Task { updatedAt: string; createdById?: string; createdByName?: string; + createdByAvatarUrl?: string; updatedById?: string; updatedByName?: string; + updatedByAvatarUrl?: string; + assigneeId?: string; + assigneeName?: string; + assigneeEmail?: string; + assigneeAvatarUrl?: string; dueDate?: string; comments: TaskComment[]; tags: string[]; @@ -91,6 +98,43 @@ type SqliteDb = InstanceType; let db: SqliteDb | null = null; +function ensureTaskSchema(database: SqliteDb) { + const taskColumns = database.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; + 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 === "createdByAvatarUrl")) { + database.exec("ALTER TABLE tasks ADD COLUMN createdByAvatarUrl 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;"); + } + if (!taskColumns.some((column) => column.name === "updatedByAvatarUrl")) { + database.exec("ALTER TABLE tasks ADD COLUMN updatedByAvatarUrl TEXT;"); + } + if (!taskColumns.some((column) => column.name === "assigneeId")) { + database.exec("ALTER TABLE tasks ADD COLUMN assigneeId TEXT;"); + } + if (!taskColumns.some((column) => column.name === "assigneeName")) { + database.exec("ALTER TABLE tasks ADD COLUMN assigneeName TEXT;"); + } + if (!taskColumns.some((column) => column.name === "assigneeEmail")) { + database.exec("ALTER TABLE tasks ADD COLUMN assigneeEmail TEXT;"); + } + if (!taskColumns.some((column) => column.name === "assigneeAvatarUrl")) { + database.exec("ALTER TABLE tasks ADD COLUMN assigneeAvatarUrl TEXT;"); + } +} + function safeParseArray(value: string | null, fallback: T[]): T[] { if (!value) return fallback; try { @@ -127,21 +171,22 @@ function normalizeAttachments(attachments: unknown): TaskAttachment[] { function normalizeComments(comments: unknown): TaskComment[] { if (!Array.isArray(comments)) return []; - return comments - .map((entry) => { - if (!entry || typeof entry !== "object") return null; - const value = entry as Partial; - if (typeof value.id !== "string" || typeof value.text !== "string") return null; + const normalized: TaskComment[] = []; + for (const entry of comments) { + if (!entry || typeof entry !== "object") continue; + const value = entry as Partial; + if (typeof value.id !== "string" || typeof value.text !== "string") continue; - return { - id: value.id, - text: value.text, - createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(), - author: normalizeCommentAuthor(value.author), - replies: normalizeComments(value.replies), - }; - }) - .filter((comment): comment is TaskComment => comment !== null); + normalized.push({ + id: value.id, + text: value.text, + createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(), + author: normalizeCommentAuthor(value.author), + replies: normalizeComments(value.replies), + }); + } + + return normalized; } function normalizeCommentAuthor(author: unknown): TaskCommentAuthor { @@ -170,8 +215,9 @@ function normalizeCommentAuthor(author: unknown): TaskCommentAuthor { ? "Assistant" : "User"; const email = typeof value.email === "string" && value.email.trim().length > 0 ? value.email.trim() : undefined; + const avatarUrl = typeof value.avatarUrl === "string" && value.avatarUrl.trim().length > 0 ? value.avatarUrl : undefined; - return { id, name, email, type }; + return { id, name, email, avatarUrl, type }; } function normalizeTask(task: Partial): Task { @@ -188,8 +234,14 @@ function normalizeTask(task: Partial): Task { 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, + createdByAvatarUrl: typeof task.createdByAvatarUrl === "string" && task.createdByAvatarUrl.trim().length > 0 ? task.createdByAvatarUrl : 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, + updatedByAvatarUrl: typeof task.updatedByAvatarUrl === "string" && task.updatedByAvatarUrl.trim().length > 0 ? task.updatedByAvatarUrl : undefined, + assigneeId: typeof task.assigneeId === "string" && task.assigneeId.trim().length > 0 ? task.assigneeId : undefined, + assigneeName: typeof task.assigneeName === "string" && task.assigneeName.trim().length > 0 ? task.assigneeName : undefined, + assigneeEmail: typeof task.assigneeEmail === "string" && task.assigneeEmail.trim().length > 0 ? task.assigneeEmail : undefined, + assigneeAvatarUrl: typeof task.assigneeAvatarUrl === "string" && task.assigneeAvatarUrl.trim().length > 0 ? task.assigneeAvatarUrl : undefined, dueDate: task.dueDate || undefined, comments: normalizeComments(task.comments), tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === "string") : [], @@ -228,8 +280,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, 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) + INSERT INTO tasks (id, title, description, type, status, priority, projectId, sprintId, createdAt, updatedAt, createdById, createdByName, createdByAvatarUrl, updatedById, updatedByName, updatedByAvatarUrl, assigneeId, assigneeName, assigneeEmail, assigneeAvatarUrl, dueDate, comments, tags, attachments) + VALUES (@id, @title, @description, @type, @status, @priority, @projectId, @sprintId, @createdAt, @updatedAt, @createdById, @createdByName, @createdByAvatarUrl, @updatedById, @updatedByName, @updatedByAvatarUrl, @assigneeId, @assigneeName, @assigneeEmail, @assigneeAvatarUrl, @dueDate, @comments, @tags, @attachments) `); for (const project of payload.projects) { @@ -261,8 +313,14 @@ function replaceAllData(database: SqliteDb, data: DataStore) { sprintId: task.sprintId ?? null, createdById: task.createdById ?? null, createdByName: task.createdByName ?? null, + createdByAvatarUrl: task.createdByAvatarUrl ?? null, updatedById: task.updatedById ?? null, updatedByName: task.updatedByName ?? null, + updatedByAvatarUrl: task.updatedByAvatarUrl ?? null, + assigneeId: task.assigneeId ?? null, + assigneeName: task.assigneeName ?? null, + assigneeEmail: task.assigneeEmail ?? null, + assigneeAvatarUrl: task.assigneeAvatarUrl ?? null, dueDate: task.dueDate ?? null, comments: JSON.stringify(task.comments ?? []), tags: JSON.stringify(task.tags ?? []), @@ -293,7 +351,10 @@ function seedIfEmpty(database: SqliteDb) { } function getDb(): SqliteDb { - if (db) return db; + if (db) { + ensureTaskSchema(db); + return db; + } mkdirSync(DATA_DIR, { recursive: true }); const database = new Database(DB_FILE); @@ -331,8 +392,14 @@ function getDb(): SqliteDb { updatedAt TEXT NOT NULL, createdById TEXT, createdByName TEXT, + createdByAvatarUrl TEXT, updatedById TEXT, updatedByName TEXT, + updatedByAvatarUrl TEXT, + assigneeId TEXT, + assigneeName TEXT, + assigneeEmail TEXT, + assigneeAvatarUrl TEXT, dueDate TEXT, comments TEXT NOT NULL DEFAULT '[]', tags TEXT NOT NULL DEFAULT '[]', @@ -345,22 +412,7 @@ function getDb(): SqliteDb { ); `); - const taskColumns = database.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; - 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;"); - } + ensureTaskSchema(database); seedIfEmpty(database); db = database; @@ -402,8 +454,14 @@ export function getData(): DataStore { updatedAt: string; createdById: string | null; createdByName: string | null; + createdByAvatarUrl: string | null; updatedById: string | null; updatedByName: string | null; + updatedByAvatarUrl: string | null; + assigneeId: string | null; + assigneeName: string | null; + assigneeEmail: string | null; + assigneeAvatarUrl: string | null; dueDate: string | null; comments: string | null; tags: string | null; @@ -441,8 +499,14 @@ export function getData(): DataStore { updatedAt: task.updatedAt, createdById: task.createdById ?? undefined, createdByName: task.createdByName ?? undefined, + createdByAvatarUrl: task.createdByAvatarUrl ?? undefined, updatedById: task.updatedById ?? undefined, updatedByName: task.updatedByName ?? undefined, + updatedByAvatarUrl: task.updatedByAvatarUrl ?? undefined, + assigneeId: task.assigneeId ?? undefined, + assigneeName: task.assigneeName ?? undefined, + assigneeEmail: task.assigneeEmail ?? undefined, + assigneeAvatarUrl: task.assigneeAvatarUrl ?? undefined, dueDate: task.dueDate ?? undefined, comments: normalizeComments(safeParseArray(task.comments, [])), tags: safeParseArray(task.tags, []), diff --git a/src/stores/useTaskStore.ts b/src/stores/useTaskStore.ts index ffc0ce7..c498043 100644 --- a/src/stores/useTaskStore.ts +++ b/src/stores/useTaskStore.ts @@ -29,6 +29,7 @@ export interface CommentAuthor { id: string name: string email?: string + avatarUrl?: string type: 'human' | 'assistant' } @@ -36,6 +37,7 @@ export interface UserProfile { id: string name: string email?: string + avatarUrl?: string } export interface TaskAttachment { @@ -60,8 +62,14 @@ export interface Task { updatedAt: string createdById?: string createdByName?: string + createdByAvatarUrl?: string updatedById?: string updatedByName?: string + updatedByAvatarUrl?: string + assigneeId?: string + assigneeName?: string + assigneeEmail?: string + assigneeAvatarUrl?: string dueDate?: string comments: Comment[] tags: string[] @@ -418,13 +426,15 @@ const normalizeUserProfile = (value: unknown, fallback: UserProfile = defaultCur 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 avatarUrl = typeof candidate.avatarUrl === 'string' && candidate.avatarUrl.trim().length > 0 ? candidate.avatarUrl : undefined + return { id, name, email, avatarUrl } } const profileToCommentAuthor = (profile: UserProfile): CommentAuthor => ({ id: profile.id, name: profile.name, email: profile.email, + avatarUrl: profile.avatarUrl, type: 'human', }) @@ -467,8 +477,9 @@ const normalizeCommentAuthor = (value: unknown): CommentAuthor => { ? 'Assistant' : 'User' const email = typeof candidate.email === 'string' && candidate.email.trim().length > 0 ? candidate.email.trim() : undefined + const avatarUrl = typeof candidate.avatarUrl === 'string' && candidate.avatarUrl.trim().length > 0 ? candidate.avatarUrl : undefined - return { id, name, email, type } + return { id, name, email, avatarUrl, type } } const normalizeComments = (value: unknown): Comment[] => { @@ -521,8 +532,14 @@ const normalizeTask = (task: Task): Task => ({ 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, + createdByAvatarUrl: typeof task.createdByAvatarUrl === 'string' && task.createdByAvatarUrl.trim().length > 0 ? task.createdByAvatarUrl : 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, + updatedByAvatarUrl: typeof task.updatedByAvatarUrl === 'string' && task.updatedByAvatarUrl.trim().length > 0 ? task.updatedByAvatarUrl : undefined, + assigneeId: typeof task.assigneeId === 'string' && task.assigneeId.trim().length > 0 ? task.assigneeId : undefined, + assigneeName: typeof task.assigneeName === 'string' && task.assigneeName.trim().length > 0 ? task.assigneeName : undefined, + assigneeEmail: typeof task.assigneeEmail === 'string' && task.assigneeEmail.trim().length > 0 ? task.assigneeEmail : undefined, + assigneeAvatarUrl: typeof task.assigneeAvatarUrl === 'string' && task.assigneeAvatarUrl.trim().length > 0 ? task.assigneeAvatarUrl : undefined, }) const removeCommentFromThread = (comments: Comment[], targetId: string): Comment[] => @@ -667,8 +684,14 @@ export const useTaskStore = create()( updatedAt: new Date().toISOString(), createdById: actor.id, createdByName: actor.name, + createdByAvatarUrl: actor.avatarUrl, updatedById: actor.id, updatedByName: actor.name, + updatedByAvatarUrl: actor.avatarUrl, + assigneeId: task.assigneeId || actor.id, + assigneeName: task.assigneeName || actor.name, + assigneeEmail: task.assigneeEmail || actor.email, + assigneeAvatarUrl: task.assigneeAvatarUrl || actor.avatarUrl, comments: normalizeComments([]), attachments: normalizeAttachments(task.attachments), } @@ -693,6 +716,7 @@ export const useTaskStore = create()( updatedAt: new Date().toISOString(), updatedById: actor.id, updatedByName: actor.name, + updatedByAvatarUrl: actor.avatarUrl, } as Task) : t ) @@ -780,6 +804,7 @@ export const useTaskStore = create()( updatedAt: new Date().toISOString(), updatedById: updater.id, updatedByName: updater.name, + updatedByAvatarUrl: updater.avatarUrl, } : t ) @@ -799,6 +824,7 @@ export const useTaskStore = create()( updatedAt: new Date().toISOString(), updatedById: updater.id, updatedByName: updater.name, + updatedByAvatarUrl: updater.avatarUrl, } : t )