diff --git a/fullstack/jira-lite/.env.example b/fullstack/jira-lite/.env.example new file mode 100644 index 00000000..259c4897 --- /dev/null +++ b/fullstack/jira-lite/.env.example @@ -0,0 +1,2 @@ +DATABASE_URL="file:./dev.db" +AUTH_SECRET="generate-with-npx-auth-secret" diff --git a/fullstack/jira-lite/.github/workflows/ci.yml b/fullstack/jira-lite/.github/workflows/ci.yml new file mode 100644 index 00000000..e69de29b diff --git a/fullstack/jira-lite/.github/workflows/deploy.yml b/fullstack/jira-lite/.github/workflows/deploy.yml new file mode 100644 index 00000000..e69de29b diff --git a/fullstack/jira-lite/.gitignore b/fullstack/jira-lite/.gitignore new file mode 100644 index 00000000..bce6ffca --- /dev/null +++ b/fullstack/jira-lite/.gitignore @@ -0,0 +1,46 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# sqlite +*.db +*.db-journal diff --git a/fullstack/jira-lite/AGENTS.md b/fullstack/jira-lite/AGENTS.md new file mode 100644 index 00000000..8bd0e390 --- /dev/null +++ b/fullstack/jira-lite/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/fullstack/jira-lite/CLAUDE.md b/fullstack/jira-lite/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/fullstack/jira-lite/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/fullstack/jira-lite/README.md b/fullstack/jira-lite/README.md new file mode 100644 index 00000000..bb6786d5 --- /dev/null +++ b/fullstack/jira-lite/README.md @@ -0,0 +1,149 @@ +# jira-lite + +A minimal, Jira-styled todo app built with Next.js — authentication, role-based +access control, a ticket status pipeline, and a Kanban board. + +## Tech stack + +- **Framework**: Next.js 16 (App Router, Turbopack) +- **Language**: TypeScript +- **Styling**: Tailwind CSS v4 (class-based dark mode, custom light/dark toggle) +- **Auth**: NextAuth.js (Auth.js v5) — Credentials provider, JWT sessions +- **Database**: SQLite via Prisma ORM 7 (`@prisma/adapter-better-sqlite3` driver adapter) +- **Validation**: Zod +- **Password hashing**: bcryptjs +- **Testing**: Vitest (unit tests for validation schemas and the ABAC policy) +- **Linting**: ESLint (`eslint-config-next`) + +## Getting started + +```bash +npm install +npx prisma migrate dev # creates dev.db and applies all migrations +npx prisma db seed # seeds demo accounts (see below) +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000). + +Copy `.env.example` to `.env` first if it doesn't already exist, and generate +your own `AUTH_SECRET` (e.g. `npx auth secret`). + +### Seeded accounts + +All passwords are `password123`. + +| Email | Role | Group | +|---|---|---| +| `admin@example.com` | Admin | — | +| `dev@example.com` | Dev | Team Alpha | +| `qa@example.com` | QA | Team Alpha | +| `solo@example.com` | Dev | — (no group) | + +### Scripts + +```bash +npm run dev # start the dev server +npm run build # production build +npm run start # run the production build +npm run lint # eslint +npm test # vitest +``` + +## Access control model + +Access isn't a flat role → permission table — it's **attribute-based**: the +decision depends on the combination of the viewer's role, their group, and +who owns the ticket. + +- **Admin** — full control over every ticket, any status, any time. Manages + groups and assigns roles/groups to users via `/admin`. +- **Dev** — owns the first half of the pipeline: **To Do → In Progress → + Ready for Testing**. Can see and toggle a same-group teammate's ticket + status, but can't rename or delete anyone else's ticket. +- **QA** — owns the second half: **Ready for Testing → Tested and Verified → + Completed**. QA's own tickets skip straight to "Ready for Testing" (their + work is already verification work). QA's board only ever shows those three + columns — To Do / In Progress tickets are invisible to QA entirely. +- A user with **no group** only ever sees their own tickets. + +## User flow by role + +### Everyone + +1. Register at `/register` → redirected to `/confirm-email` (a no-op + confirmation screen for this demo — click through to `/login`). +2. Sign in with email/password (NextAuth Credentials, bcrypt-verified). +3. Land on `/todos`, a Kanban board scoped to what that role can see. +4. Toggle light/dark mode any time via the sun/moon button in the header. + +### Dev + +1. See a 5-column board: **To Do | In Progress | Ready for Testing | Tested + and Verified | Completed**. +2. Create a ticket via **New ticket** — title, story points, description, + problem, acceptance criteria, technical implementation (all but title are + optional). New tickets start at **To Do**. +3. Move their own tickets through **To Do → In Progress → Ready for + Testing** via the status dropdown on the card. +4. See a same-group QA teammate's tickets too (read-only for status once + past their own stages) — the **Members** dropdown in the navbar lists the + whole team. +5. Click **View** on any card to see its full details in a read-only modal. +6. Can rename or delete only their own tickets. + +### QA + +1. See a 3-column board: **Ready for Testing | Tested and Verified | + Completed** — To Do / In Progress never appear. +2. Any ticket they create starts directly at **Ready for Testing**. +3. Move any visible ticket (their own, or a same-group dev's) through + **Ready for Testing → Tested and Verified → Completed**. +4. Cannot rename or delete a teammate's ticket, and cannot move a ticket + backward into To Do / In Progress. + +### Admin + +1. Sees every ticket from every user, all 5 columns, and can set any status + on any ticket at any time. +2. Has an **Admin** link in the navbar leading to `/admin`: + - **Groups** — create teams. + - **Users** — reassign any user's role (Admin/Dev/QA) and group. Changes + take effect on that user's very next request — no re-login required. +3. Not a member of a group by default, so has no "teammates" of their own — + their reach comes from the admin role, not group membership. + +## Project structure + +``` +app/ + api/ REST routes (todos, auth, admin) — Next.js Route Handlers + admin/ Admin-only pages (groups, users) + todos/ The Kanban board page + login/ register/ confirm-email/ Auth pages +components/ Client components (board, modals, navbar, admin tables) +lib/ + auth.ts / auth.config.ts NextAuth setup (Node vs edge-safe split) + authorization.ts The ABAC policy — pure functions, unit tested + current-user.ts Session → fresh DB user lookup + validations/ Zod schemas for auth and todo input +prisma/ + schema.prisma User, Group, Todo models + Role/TodoStatus enums + migrations/ Incremental schema history + seed.ts Demo data +``` + +## Testing + +```bash +npm test +``` + +Covers: +- `lib/validations/auth.ts` — register/login schema rules +- `lib/validations/todo.ts` — ticket field validation (title, status, points, detail fields) +- `lib/authorization.ts` — the full ABAC policy matrix (owner, admin, same-group + teammate, outsider, no-group × read/edit/delete/status-transition) + +Not yet covered: API route handlers, UI components, and end-to-end flows — +see the app for manual verification steps if extending this further. diff --git a/fullstack/jira-lite/app/admin/groups/page.tsx b/fullstack/jira-lite/app/admin/groups/page.tsx new file mode 100644 index 00000000..d6679969 --- /dev/null +++ b/fullstack/jira-lite/app/admin/groups/page.tsx @@ -0,0 +1,19 @@ +import { prisma } from "@/lib/prisma"; +import { GroupsManager } from "@/components/admin/groups-manager"; + +export default async function AdminGroupsPage() { + const groups = await prisma.group.findMany({ + include: { _count: { select: { members: true } } }, + orderBy: { name: "asc" }, + }); + + return ( + ({ + id: group.id, + name: group.name, + memberCount: group._count.members, + }))} + /> + ); +} diff --git a/fullstack/jira-lite/app/admin/layout.tsx b/fullstack/jira-lite/app/admin/layout.tsx new file mode 100644 index 00000000..c5de7d53 --- /dev/null +++ b/fullstack/jira-lite/app/admin/layout.tsx @@ -0,0 +1,47 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { isAdmin } from "@/lib/authorization"; +import { getCurrentUser } from "@/lib/current-user"; +import { ThemeToggle } from "@/components/theme-toggle"; + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const currentUser = await getCurrentUser(); + if (!currentUser) { + redirect("/login"); + } + if (!isAdmin(currentUser)) { + redirect("/todos"); + } + + return ( +
+
+ +
+
{children}
+
+ ); +} diff --git a/fullstack/jira-lite/app/admin/users/page.tsx b/fullstack/jira-lite/app/admin/users/page.tsx new file mode 100644 index 00000000..905f627a --- /dev/null +++ b/fullstack/jira-lite/app/admin/users/page.tsx @@ -0,0 +1,20 @@ +import { prisma } from "@/lib/prisma"; +import { UsersManager } from "@/components/admin/users-manager"; + +export default async function AdminUsersPage() { + const [users, groups] = await Promise.all([ + prisma.user.findMany({ + select: { + id: true, + name: true, + email: true, + role: true, + groupId: true, + }, + orderBy: { name: "asc" }, + }), + prisma.group.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }), + ]); + + return ; +} diff --git a/fullstack/jira-lite/app/api/admin/groups/route.ts b/fullstack/jira-lite/app/api/admin/groups/route.ts new file mode 100644 index 00000000..511d5a33 --- /dev/null +++ b/fullstack/jira-lite/app/api/admin/groups/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; + +import { isAdmin } from "@/lib/authorization"; +import { getCurrentUser } from "@/lib/current-user"; +import { prisma } from "@/lib/prisma"; +import { createGroupSchema } from "@/lib/validations/admin"; + +export async function GET() { + const currentUser = await getCurrentUser(); + if (!currentUser || !isAdmin(currentUser)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const groups = await prisma.group.findMany({ + include: { _count: { select: { members: true } } }, + orderBy: { name: "asc" }, + }); + + return NextResponse.json({ + groups: groups.map((group) => ({ + id: group.id, + name: group.name, + memberCount: group._count.members, + })), + }); +} + +export async function POST(request: Request) { + const currentUser = await getCurrentUser(); + if (!currentUser || !isAdmin(currentUser)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const body = await request.json(); + const parsed = createGroupSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0].message }, + { status: 400 } + ); + } + + const existing = await prisma.group.findUnique({ where: { name: parsed.data.name } }); + if (existing) { + return NextResponse.json( + { error: "A group with this name already exists" }, + { status: 409 } + ); + } + + const group = await prisma.group.create({ data: { name: parsed.data.name } }); + + return NextResponse.json( + { group: { id: group.id, name: group.name, memberCount: 0 } }, + { status: 201 } + ); +} diff --git a/fullstack/jira-lite/app/api/admin/users/[id]/route.ts b/fullstack/jira-lite/app/api/admin/users/[id]/route.ts new file mode 100644 index 00000000..c47c672a --- /dev/null +++ b/fullstack/jira-lite/app/api/admin/users/[id]/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; + +import { isAdmin } from "@/lib/authorization"; +import { getCurrentUser } from "@/lib/current-user"; +import { prisma } from "@/lib/prisma"; +import { updateUserSchema } from "@/lib/validations/admin"; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const currentUser = await getCurrentUser(); + if (!currentUser || !isAdmin(currentUser)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json(); + const parsed = updateUserSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0].message }, + { status: 400 } + ); + } + + const user = await prisma.user.findUnique({ where: { id } }); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { role, groupId } = parsed.data; + + if (groupId) { + const group = await prisma.group.findUnique({ where: { id: groupId } }); + if (!group) { + return NextResponse.json({ error: "Group not found" }, { status: 400 }); + } + } + + const updated = await prisma.user.update({ + where: { id }, + data: { + ...(role !== undefined ? { role } : {}), + ...(groupId !== undefined ? { groupId } : {}), + }, + select: { + id: true, + name: true, + email: true, + role: true, + groupId: true, + group: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json({ user: updated }); +} diff --git a/fullstack/jira-lite/app/api/admin/users/route.ts b/fullstack/jira-lite/app/api/admin/users/route.ts new file mode 100644 index 00000000..95c6fce5 --- /dev/null +++ b/fullstack/jira-lite/app/api/admin/users/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; + +import { isAdmin } from "@/lib/authorization"; +import { getCurrentUser } from "@/lib/current-user"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + const currentUser = await getCurrentUser(); + if (!currentUser || !isAdmin(currentUser)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const users = await prisma.user.findMany({ + select: { + id: true, + name: true, + email: true, + role: true, + groupId: true, + group: { select: { id: true, name: true } }, + }, + orderBy: { name: "asc" }, + }); + + return NextResponse.json({ users }); +} diff --git a/fullstack/jira-lite/app/api/auth/[...nextauth]/route.ts b/fullstack/jira-lite/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000..c55a45ed --- /dev/null +++ b/fullstack/jira-lite/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/lib/auth"; + +export const { GET, POST } = handlers; diff --git a/fullstack/jira-lite/app/api/auth/register/route.ts b/fullstack/jira-lite/app/api/auth/register/route.ts new file mode 100644 index 00000000..6f3417e2 --- /dev/null +++ b/fullstack/jira-lite/app/api/auth/register/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; + +import { prisma } from "@/lib/prisma"; +import { registerSchema } from "@/lib/validations/auth"; + +export async function POST(request: Request) { + const body = await request.json(); + const parsed = registerSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0].message }, + { status: 400 } + ); + } + + const { name, email, password } = parsed.data; + + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) { + return NextResponse.json( + { error: "An account with this email already exists" }, + { status: 409 } + ); + } + + const hashedPassword = await bcrypt.hash(password, 10); + const user = await prisma.user.create({ + data: { name, email, password: hashedPassword }, + select: { id: true, name: true, email: true }, + }); + + return NextResponse.json({ user }, { status: 201 }); +} diff --git a/fullstack/jira-lite/app/api/todos/[id]/route.ts b/fullstack/jira-lite/app/api/todos/[id]/route.ts new file mode 100644 index 00000000..aad160b6 --- /dev/null +++ b/fullstack/jira-lite/app/api/todos/[id]/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from "next/server"; + +import { allowedStatusTransitions, canDeleteTodo, canEditTodo, canReadTodo } from "@/lib/authorization"; +import { getCurrentUser } from "@/lib/current-user"; +import { prisma } from "@/lib/prisma"; +import { serializeTodo } from "@/lib/serialize-todo"; +import { updateTodoSchema } from "@/lib/validations/todo"; + +const OWNER_SELECT = { + user: { select: { id: true, name: true, groupId: true } }, +} as const; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const currentUser = await getCurrentUser(); + if (!currentUser) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const parsed = updateTodoSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0].message }, + { status: 400 } + ); + } + + const todo = await prisma.todo.findUnique({ where: { id }, include: OWNER_SELECT }); + const owner = todo ? { id: todo.user.id, groupId: todo.user.groupId } : null; + + if (!todo || !owner || !canReadTodo(currentUser, owner)) { + return NextResponse.json({ error: "Todo not found" }, { status: 404 }); + } + + const { title, status } = parsed.data; + + if (title !== undefined && !canEditTodo(currentUser, todo.userId)) { + return NextResponse.json( + { error: "Only the owner or an admin can rename this todo" }, + { status: 403 } + ); + } + + if (status !== undefined && !allowedStatusTransitions(currentUser, owner).includes(status)) { + return NextResponse.json( + { error: "You don't have permission to set this status" }, + { status: 403 } + ); + } + + const updated = await prisma.todo.update({ + where: { id }, + data: { title, status }, + include: OWNER_SELECT, + }); + + return NextResponse.json({ todo: serializeTodo(updated, currentUser) }); +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const currentUser = await getCurrentUser(); + if (!currentUser) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const todo = await prisma.todo.findUnique({ where: { id }, include: OWNER_SELECT }); + const owner = todo ? { id: todo.user.id, groupId: todo.user.groupId } : null; + + if (!todo || !owner || !canReadTodo(currentUser, owner)) { + return NextResponse.json({ error: "Todo not found" }, { status: 404 }); + } + + if (!canDeleteTodo(currentUser, todo.userId)) { + return NextResponse.json( + { error: "Only the owner or an admin can delete this todo" }, + { status: 403 } + ); + } + + await prisma.todo.delete({ where: { id } }); + + return NextResponse.json({ success: true }); +} diff --git a/fullstack/jira-lite/app/api/todos/route.ts b/fullstack/jira-lite/app/api/todos/route.ts new file mode 100644 index 00000000..58e4c4b3 --- /dev/null +++ b/fullstack/jira-lite/app/api/todos/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; + +import { visibleColumnsFor } from "@/lib/authorization"; +import { getCurrentUser } from "@/lib/current-user"; +import { prisma } from "@/lib/prisma"; +import { serializeTodo } from "@/lib/serialize-todo"; +import { createTodoSchema } from "@/lib/validations/todo"; + +const OWNER_SELECT = { + user: { select: { id: true, name: true, groupId: true } }, +} as const; + +export async function GET() { + const currentUser = await getCurrentUser(); + if (!currentUser) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const todos = await prisma.todo.findMany({ + where: { + status: { in: visibleColumnsFor(currentUser.role) }, + ...(currentUser.role === "ADMIN" + ? {} + : { + OR: [ + { userId: currentUser.id }, + ...(currentUser.groupId + ? [{ user: { groupId: currentUser.groupId } }] + : []), + ], + }), + }, + include: OWNER_SELECT, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json({ + todos: todos.map((todo) => serializeTodo(todo, currentUser)), + }); +} + +export async function POST(request: Request) { + const currentUser = await getCurrentUser(); + if (!currentUser) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const parsed = createTodoSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0].message }, + { status: 400 } + ); + } + + const { title, description, problem, acceptanceCriteria, technicalImplementation, points } = + parsed.data; + + const todo = await prisma.todo.create({ + data: { + title, + description, + problem, + acceptanceCriteria, + technicalImplementation, + points, + userId: currentUser.id, + status: currentUser.role === "QA" ? "READY_FOR_TESTING" : "TO_DO", + }, + include: OWNER_SELECT, + }); + + return NextResponse.json( + { todo: serializeTodo(todo, currentUser) }, + { status: 201 } + ); +} diff --git a/fullstack/jira-lite/app/confirm-email/page.tsx b/fullstack/jira-lite/app/confirm-email/page.tsx new file mode 100644 index 00000000..5f33cfc5 --- /dev/null +++ b/fullstack/jira-lite/app/confirm-email/page.tsx @@ -0,0 +1,40 @@ +import Link from "next/link"; + +import { ThemeToggle } from "@/components/theme-toggle"; + +export default async function ConfirmEmailPage({ + searchParams, +}: { + searchParams: Promise<{ email?: string }>; +}) { + const { email } = await searchParams; + + return ( +
+
+ +
+
+
+ 📧 +
+

+ Confirm your email +

+

+ We've sent a confirmation link to{" "} + {email ? {email} : "your email address"}. + Click the link to activate your account. +

+ +
+ Note: this is a test/demo app, so no email is actually sent.{" "} + + Click here + {" "} + to go straight to the login page. +
+
+
+ ); +} diff --git a/fullstack/jira-lite/app/favicon.ico b/fullstack/jira-lite/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/fullstack/jira-lite/app/favicon.ico differ diff --git a/fullstack/jira-lite/app/globals.css b/fullstack/jira-lite/app/globals.css new file mode 100644 index 00000000..eea6d987 --- /dev/null +++ b/fullstack/jira-lite/app/globals.css @@ -0,0 +1,12 @@ +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme inline { + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +body { + font-family: Arial, Helvetica, sans-serif; +} diff --git a/fullstack/jira-lite/app/layout.tsx b/fullstack/jira-lite/app/layout.tsx new file mode 100644 index 00000000..9a81f5a4 --- /dev/null +++ b/fullstack/jira-lite/app/layout.tsx @@ -0,0 +1,40 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +import { SessionProvider } from "@/components/session-provider"; +import { ThemeProvider } from "@/components/theme-provider"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Jira-lite", + description: "A simple, jira-styled todo app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + {children} + + + + ); +} diff --git a/fullstack/jira-lite/app/login/page.tsx b/fullstack/jira-lite/app/login/page.tsx new file mode 100644 index 00000000..6f903e4b --- /dev/null +++ b/fullstack/jira-lite/app/login/page.tsx @@ -0,0 +1,93 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { signIn } from "next-auth/react"; + +import { ThemeToggle } from "@/components/theme-toggle"; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setIsSubmitting(true); + + const result = await signIn("credentials", { + email, + password, + redirect: false, + }); + + setIsSubmitting(false); + + if (result?.error) { + setError("Invalid email or password"); + return; + } + + router.push("/todos"); + router.refresh(); + } + + return ( +
+
+ +
+
+

+ Sign in +

+
+
+ + setEmail(e.target.value)} + className="rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 user-invalid:border-red-500 user-invalid:text-red-600 user-invalid:focus:border-red-500 user-invalid:focus:ring-red-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-50 dark:user-invalid:border-red-500 dark:user-invalid:text-red-400" + /> +
+
+ + setPassword(e.target.value)} + className="rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 user-invalid:border-red-500 user-invalid:text-red-600 user-invalid:focus:border-red-500 user-invalid:focus:ring-red-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-50 dark:user-invalid:border-red-500 dark:user-invalid:text-red-400" + /> +
+ {error &&

{error}

} + +
+

+ No account?{" "} + + Create one + +

+
+
+ ); +} diff --git a/fullstack/jira-lite/app/page.tsx b/fullstack/jira-lite/app/page.tsx new file mode 100644 index 00000000..554984e4 --- /dev/null +++ b/fullstack/jira-lite/app/page.tsx @@ -0,0 +1,43 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { auth } from "@/lib/auth"; +import { ThemeToggle } from "@/components/theme-toggle"; + +export default async function Home() { + const session = await auth(); + if (session?.user) { + redirect("/todos"); + } + + return ( +
+
+ +
+
+ + J + + jira-lite +
+

+ A minimal, board-styled todo app. Sign in to track what needs doing. +

+
+ + Sign in + + + Create account + +
+
+ ); +} diff --git a/fullstack/jira-lite/app/register/page.tsx b/fullstack/jira-lite/app/register/page.tsx new file mode 100644 index 00000000..85b363bb --- /dev/null +++ b/fullstack/jira-lite/app/register/page.tsx @@ -0,0 +1,110 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { ThemeToggle } from "@/components/theme-toggle"; + +export default function RegisterPage() { + const router = useRouter(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setIsSubmitting(true); + + const response = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name.trim(), email: email.trim(), password }), + }); + + setIsSubmitting(false); + + if (!response.ok) { + const data = await response.json(); + setError(data.error ?? "Something went wrong"); + return; + } + + router.push(`/confirm-email?email=${encodeURIComponent(email.trim())}`); + } + + return ( +
+
+ +
+
+

+ Create account +

+
+
+ + setName(e.target.value)} + className="rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 user-invalid:border-red-500 user-invalid:text-red-600 user-invalid:focus:border-red-500 user-invalid:focus:ring-red-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-50 dark:user-invalid:border-red-500 dark:user-invalid:text-red-400" + /> +
+
+ + setEmail(e.target.value)} + className="rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 user-invalid:border-red-500 user-invalid:text-red-600 user-invalid:focus:border-red-500 user-invalid:focus:ring-red-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-50 dark:user-invalid:border-red-500 dark:user-invalid:text-red-400" + /> +
+
+ + setPassword(e.target.value)} + className="rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 user-invalid:border-red-500 user-invalid:text-red-600 user-invalid:focus:border-red-500 user-invalid:focus:ring-red-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-50 dark:user-invalid:border-red-500 dark:user-invalid:text-red-400" + /> +

At least 8 characters

+
+ {error &&

{error}

} + +
+

+ Already have an account?{" "} + + Sign in + +

+
+
+ ); +} diff --git a/fullstack/jira-lite/app/todos/page.tsx b/fullstack/jira-lite/app/todos/page.tsx new file mode 100644 index 00000000..f34eeaaf --- /dev/null +++ b/fullstack/jira-lite/app/todos/page.tsx @@ -0,0 +1,67 @@ +import { redirect } from "next/navigation"; + +import { auth } from "@/lib/auth"; +import { isAdmin, visibleColumnsFor } from "@/lib/authorization"; +import { getCurrentUser } from "@/lib/current-user"; +import { prisma } from "@/lib/prisma"; +import { serializeTodo } from "@/lib/serialize-todo"; +import { NavBar } from "@/components/nav-bar"; +import { TodoBoard } from "@/components/todo-board"; + +export default async function TodosPage() { + const session = await auth(); + if (!session?.user?.id) { + redirect("/login"); + } + + const currentUser = await getCurrentUser(); + if (!currentUser) { + redirect("/login"); + } + + const columns = visibleColumnsFor(currentUser.role); + + const [todos, group] = await Promise.all([ + prisma.todo.findMany({ + where: { + status: { in: columns }, + ...(currentUser.role === "ADMIN" + ? {} + : { + OR: [ + { userId: currentUser.id }, + ...(currentUser.groupId + ? [{ user: { groupId: currentUser.groupId } }] + : []), + ], + }), + }, + include: { user: { select: { id: true, name: true, groupId: true } } }, + orderBy: { createdAt: "desc" }, + }), + currentUser.groupId + ? prisma.group.findUnique({ + where: { id: currentUser.groupId }, + select: { + name: true, + members: { select: { id: true, name: true, role: true } }, + }, + }) + : null, + ]); + + return ( +
+ + serializeTodo(todo, currentUser))} + /> +
+ ); +} diff --git a/fullstack/jira-lite/components/admin/groups-manager.tsx b/fullstack/jira-lite/components/admin/groups-manager.tsx new file mode 100644 index 00000000..05a5ae6a --- /dev/null +++ b/fullstack/jira-lite/components/admin/groups-manager.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useState } from "react"; + +type Group = { + id: string; + name: string; + memberCount: number; +}; + +export function GroupsManager({ initialGroups }: { initialGroups: Group[] }) { + const [groups, setGroups] = useState(initialGroups); + const [name, setName] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleCreate(event: React.FormEvent) { + event.preventDefault(); + const trimmedName = name.trim(); + if (!trimmedName) return; + + setIsSubmitting(true); + setError(null); + + try { + const response = await fetch("/api/admin/groups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: trimmedName }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error ?? "Failed to create group"); + } + + const data = await response.json(); + setGroups((prev) => [...prev, data.group].sort((a, b) => a.name.localeCompare(b.name))); + setName(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+

Groups

+ +
+ setName(e.target.value)} + placeholder="New group name" + className="flex-1 rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 user-invalid:border-red-500 user-invalid:text-red-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-50 dark:user-invalid:border-red-500 dark:user-invalid:text-red-400" + /> + +
+ + {error &&

{error}

} + +
+ + + + + + + + + {groups.length === 0 ? ( + + + + ) : ( + groups.map((group) => ( + + + + + )) + )} + +
NameMembers
+ No groups yet +
{group.name}{group.memberCount}
+
+
+ ); +} diff --git a/fullstack/jira-lite/components/admin/users-manager.tsx b/fullstack/jira-lite/components/admin/users-manager.tsx new file mode 100644 index 00000000..da9e1db4 --- /dev/null +++ b/fullstack/jira-lite/components/admin/users-manager.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState } from "react"; + +type Role = "ADMIN" | "DEV" | "QA"; + +type User = { + id: string; + name: string; + email: string; + role: Role; + groupId: string | null; +}; + +type Group = { + id: string; + name: string; +}; + +const ROLES: Role[] = ["ADMIN", "DEV", "QA"]; + +export function UsersManager({ + initialUsers, + groups, +}: { + initialUsers: User[]; + groups: Group[]; +}) { + const [users, setUsers] = useState(initialUsers); + const [savingId, setSavingId] = useState(null); + const [errors, setErrors] = useState>({}); + const [savedId, setSavedId] = useState(null); + + function updateLocal(id: string, patch: Partial>) { + setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, ...patch } : u))); + } + + async function handleSave(user: User) { + setSavingId(user.id); + setErrors((prev) => ({ ...prev, [user.id]: "" })); + setSavedId(null); + + try { + const response = await fetch(`/api/admin/users/${user.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role: user.role, groupId: user.groupId }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error ?? "Failed to save"); + } + + setSavedId(user.id); + } catch (err) { + setErrors((prev) => ({ + ...prev, + [user.id]: err instanceof Error ? err.message : "Something went wrong", + })); + } finally { + setSavingId(null); + } + } + + return ( +
+

Users

+ +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
NameEmailRoleGroup
{user.name}{user.email} + + + + +
+ + {savedId === user.id && ( + Saved + )} + {errors[user.id] && ( + + {errors[user.id]} + + )} +
+
+
+
+ ); +} diff --git a/fullstack/jira-lite/components/create-ticket-modal.tsx b/fullstack/jira-lite/components/create-ticket-modal.tsx new file mode 100644 index 00000000..3a5276ac --- /dev/null +++ b/fullstack/jira-lite/components/create-ticket-modal.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export type NewTicketInput = { + title: string; + points?: number; + description?: string; + problem?: string; + acceptanceCriteria?: string; + technicalImplementation?: string; +}; + +export function CreateTicketModal({ + onClose, + onCreate, +}: { + onClose: () => void; + onCreate: (input: NewTicketInput) => Promise; +}) { + const [title, setTitle] = useState(""); + const [points, setPoints] = useState(""); + const [description, setDescription] = useState(""); + const [problem, setProblem] = useState(""); + const [acceptanceCriteria, setAcceptanceCriteria] = useState(""); + const [technicalImplementation, setTechnicalImplementation] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + const trimmedTitle = title.trim(); + if (!trimmedTitle) return; + + setIsSubmitting(true); + setError(null); + + try { + await onCreate({ + title: trimmedTitle, + points: points.trim() ? Number(points) : undefined, + description: description.trim() || undefined, + problem: problem.trim() || undefined, + acceptanceCriteria: acceptanceCriteria.trim() || undefined, + technicalImplementation: technicalImplementation.trim() || undefined, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong"); + setIsSubmitting(false); + return; + } + } + + return ( +
+
e.stopPropagation()} + className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-y-auto rounded-lg border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-800 dark:bg-zinc-900" + > +

+ New ticket +

+
+ + setTitle(e.target.value)} + maxLength={200} + className={inputClass} + /> + + + + setPoints(e.target.value)} + placeholder="e.g. 10" + className={inputClass} + /> + + + +