From 65b72cf81786e5e77caaa425273a31da55497546 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 17 Sep 2026 13:41:02 +0700 Subject: [PATCH] =?UTF-8?q?mock=20login=20e-Court=20=E0=B8=AB=E0=B8=99?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=20/login=20+=20identity=20=E0=B8=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=81=20cookie=20oacCase=20=E0=B9=81=E0=B8=97=E0=B8=99=20lo?= =?UTF-8?q?calStorage=20=E2=80=94=20dropdown=20=E0=B8=9C=E0=B8=B9=E0=B9=89?= =?UTF-8?q?=E0=B9=83=E0=B8=8A=E0=B9=89=20MOCK=5FUSERS=20(user1=E2=80=93use?= =?UTF-8?q?r5,=20utils/user.ts=20pure=20module=20=E0=B9=83=E0=B8=AB?= =?UTF-8?q?=E0=B9=89=20client+server=20=E0=B9=83=E0=B8=8A=E0=B9=89?= =?UTF-8?q?=E0=B8=A3=E0=B9=88=E0=B8=A7=E0=B8=A1)=20=E0=B8=A3=E0=B8=AB?= =?UTF-8?q?=E0=B8=B1=E0=B8=AA=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=E0=B8=81?= =?UTF-8?q?=E0=B8=A5=E0=B8=B2=E0=B8=87=20Demo1234=20=E0=B8=9C=E0=B9=88?= =?UTF-8?q?=E0=B8=B2=E0=B8=99=20server=20action=20login=20(=E0=B9=80?= =?UTF-8?q?=E0=B8=8A=E0=B9=87=E0=B8=84=E0=B8=A3=E0=B8=B2=E0=B8=A2=E0=B8=8A?= =?UTF-8?q?=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=9D=E0=B8=B1=E0=B9=88=E0=B8=87?= =?UTF-8?q?=20server=20=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20defense=20?= =?UTF-8?q?=E0=B8=81=E0=B8=B1=E0=B8=99=20hand-craft=20+=20delay=20500ms=20?= =?UTF-8?q?=E0=B9=83=E0=B8=AB=E0=B9=89=E0=B9=80=E0=B8=AB=E0=B9=87=E0=B8=99?= =?UTF-8?q?=20"=E0=B8=81=E0=B8=B3=E0=B8=A5=E0=B8=B1=E0=B8=87=E0=B8=95?= =?UTF-8?q?=E0=B8=A3=E0=B8=A7=E0=B8=88=E0=B8=AA=E0=B8=AD=E0=B8=9A=E2=80=A6?= =?UTF-8?q?")=20=E0=B8=95=E0=B8=B1=E0=B9=89=E0=B8=87=20cookie=20oacCase=20?= =?UTF-8?q?=E0=B8=95=E0=B8=B2=E0=B8=A1=20format=20=E0=B8=88=E0=B8=A3?= =?UTF-8?q?=E0=B8=B4=E0=B8=87=E0=B8=82=E0=B8=AD=E0=B8=87=E0=B8=A3=E0=B8=B0?= =?UTF-8?q?=E0=B8=9A=E0=B8=9A=E0=B8=A8=E0=B8=B2=E0=B8=A5=2016=20field=20?= =?UTF-8?q?=E2=80=94=20u=5Fuserid/u=5Fuserlogin=20=E0=B8=95=E0=B9=88?= =?UTF-8?q?=E0=B8=B2=E0=B8=87=E0=B8=81=E0=B8=B1=E0=B8=99=E0=B8=95=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=E0=B8=84=E0=B8=99=20(=3D=20=E0=B8=8A=E0=B8=B7?= =?UTF-8?q?=E0=B9=88=E0=B8=AD=E0=B8=9C=E0=B8=B9=E0=B9=89=E0=B9=83=E0=B8=8A?= =?UTF-8?q?=E0=B9=89)=20=E0=B9=80=E0=B8=9E=E0=B8=B7=E0=B9=88=E0=B8=AD?= =?UTF-8?q?=E0=B9=83=E0=B8=AB=E0=B9=89=E0=B8=AA=E0=B8=B4=E0=B8=97=E0=B8=98?= =?UTF-8?q?=E0=B8=B4=E0=B9=8C=20"=E0=B9=81=E0=B8=81=E0=B9=89=E0=B9=84?= =?UTF-8?q?=E0=B8=82=E0=B9=84=E0=B8=94=E0=B9=89=E0=B9=80=E0=B8=89=E0=B8=9E?= =?UTF-8?q?=E0=B8=B2=E0=B8=B0=E0=B8=82=E0=B8=AD=E0=B8=87=E0=B8=95=E0=B8=B1?= =?UTF-8?q?=E0=B8=A7=E0=B9=80=E0=B8=AD=E0=B8=87"=20(guard=20=E0=B8=94?= =?UTF-8?q?=E0=B9=89=E0=B8=A7=E0=B8=A2=20userId)=20=E0=B9=81=E0=B8=A2?= =?UTF-8?q?=E0=B8=81=E0=B8=81=E0=B8=B1=E0=B8=99=E0=B8=88=E0=B8=A3=E0=B8=B4?= =?UTF-8?q?=E0=B8=87=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=205=20=E0=B8=84?= =?UTF-8?q?=E0=B8=99=20httpOnly/sameSite=20lax/8=20=E0=B8=8A=E0=B8=A1.=20+?= =?UTF-8?q?=20middleware.ts=20=E0=B8=9A=E0=B8=B1=E0=B8=87=E0=B8=84?= =?UTF-8?q?=E0=B8=B1=E0=B8=9A=20login=20=E0=B8=97=E0=B8=B8=E0=B8=81?= =?UTF-8?q?=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2=20(parse=20=E0=B8=88?= =?UTF-8?q?=E0=B8=A3=E0=B8=B4=E0=B8=87=E0=B8=94=E0=B9=89=E0=B8=A7=E0=B8=A2?= =?UTF-8?q?=20parseOacCase=20=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B9=83=E0=B8=8A?= =?UTF-8?q?=E0=B9=88=E0=B9=81=E0=B8=84=E0=B9=88=E0=B8=A1=E0=B8=B5=20cookie?= =?UTF-8?q?=20=E2=80=94=20=E0=B8=82=E0=B8=A2=E0=B8=B0/=E0=B8=A7=E0=B9=88?= =?UTF-8?q?=E0=B8=B2=E0=B8=87/=E0=B8=82=E0=B8=B2=E0=B8=94=20field=20=3D=20?= =?UTF-8?q?redirect=20/login;=20matcher=20=E0=B8=A2=E0=B8=81=E0=B9=80?= =?UTF-8?q?=E0=B8=A7=E0=B9=89=E0=B8=99=20/login,=20/api/*,=20/uploads/*,?= =?UTF-8?q?=20=5Fnext=20=E0=B9=81=E0=B8=A5=E0=B8=B0=20static=20=E0=B9=83?= =?UTF-8?q?=E0=B8=99=20public/=20=E2=80=94=20SSE/export=20=E0=B8=88?= =?UTF-8?q?=E0=B8=B6=E0=B8=87=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B9=82=E0=B8=94?= =?UTF-8?q?=E0=B8=99=20302)=20+=20lib/session.ts=20getSessionUser=20?= =?UTF-8?q?=E0=B8=AD=E0=B9=88=E0=B8=B2=E0=B8=99=20identity=20=E0=B8=9D?= =?UTF-8?q?=E0=B8=B1=E0=B9=88=E0=B8=87=20server=20=E0=B9=83=E0=B8=8A?= =?UTF-8?q?=E0=B9=89=E0=B9=83=E0=B8=99=E0=B8=97=E0=B8=B8=E0=B8=81=E0=B8=AB?= =?UTF-8?q?=E0=B8=99=E0=B9=89=E0=B8=B2=20(defense=20=E0=B8=8B=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=99=20middleware)=20+=20PDFViewer=20=E0=B8=A3?= =?UTF-8?q?=E0=B8=B1=E0=B8=9A=20user=20=E0=B9=80=E0=B8=9B=E0=B9=87?= =?UTF-8?q?=E0=B8=99=20prop=20=E0=B8=88=E0=B8=B2=E0=B8=81=20annotate=20pag?= =?UTF-8?q?e=20=E0=B8=95=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B9=81=E0=B8=95?= =?UTF-8?q?=E0=B9=88=20first=20paint=20(=E0=B8=AB=E0=B8=A1=E0=B8=94?= =?UTF-8?q?=E0=B8=8A=E0=B9=88=E0=B8=A7=E0=B8=87=20null=20user=20=E0=B8=97?= =?UTF-8?q?=E0=B8=B5=E0=B9=88=E0=B9=80=E0=B8=84=E0=B8=A2=E0=B8=AA=E0=B8=A3?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=87=20annotation=20=E0=B8=94=E0=B9=89?= =?UTF-8?q?=E0=B8=A7=E0=B8=A2=20userId=3D""=20=E0=B9=84=E0=B8=94=E0=B9=89?= =?UTF-8?q?=20+=20=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=A1=E0=B8=B5=20hydratio?= =?UTF-8?q?n=20mismatch;=20Server=20Actions=20=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87=E0=B9=81=E0=B8=81=E0=B9=89?= =?UTF-8?q?=20=E2=80=94=20vString=20=E0=B8=A3=E0=B8=B1=E0=B8=9A=20userId?= =?UTF-8?q?=20=E0=B9=83=E0=B8=AB=E0=B8=A1=E0=B9=88=E0=B8=AD=E0=B8=A2?= =?UTF-8?q?=E0=B8=B9=E0=B9=88=E0=B9=81=E0=B8=A5=E0=B9=89=E0=B8=A7)=20+=20S?= =?UTF-8?q?essionBar=20=E0=B9=81=E0=B8=AA=E0=B8=94=E0=B8=87=20"=E0=B9=80?= =?UTF-8?q?=E0=B8=82=E0=B9=89=E0=B8=B2=E0=B8=AA=E0=B8=B9=E0=B9=88=E0=B8=A3?= =?UTF-8?q?=E0=B8=B0=E0=B8=9A=E0=B8=9A=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99?= =?UTF-8?q?=20"=20+=20=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=E0=B8=AD?= =?UTF-8?q?=E0=B8=AD=E0=B8=81=E0=B8=88=E0=B8=B2=E0=B8=81=E0=B8=A3=E0=B8=B0?= =?UTF-8?q?=E0=B8=9A=E0=B8=9A=20(=E0=B8=A5=E0=B8=9A=20cookie=20=E0=B8=94?= =?UTF-8?q?=E0=B9=89=E0=B8=A7=E0=B8=A2=20path=20"/"=20=E0=B8=95=E0=B8=A3?= =?UTF-8?q?=E0=B8=87=E0=B8=95=E0=B8=AD=E0=B8=99=20set=20+=20redirect=20?= =?UTF-8?q?=E0=B9=83=E0=B8=99=20action=20=E0=B9=80=E0=B8=AD=E0=B8=87=20?= =?UTF-8?q?=E2=80=94=20client=20router.replace=20=E0=B9=80=E0=B8=AD?= =?UTF-8?q?=E0=B8=87=E0=B8=8A=E0=B8=99=E0=B8=81=E0=B8=B1=E0=B8=9A=20re-ren?= =?UTF-8?q?der=20=E0=B8=82=E0=B8=AD=E0=B8=87=E0=B8=AB=E0=B8=99=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B9=80=E0=B8=9E=E0=B8=B4?= =?UTF-8?q?=E0=B9=88=E0=B8=87=E0=B9=80=E0=B8=AA=E0=B8=B5=E0=B8=A2=20sessio?= =?UTF-8?q?n=20=E0=B8=88=E0=B8=99=20navigation=20=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B9=80=E0=B8=81=E0=B8=B4=E0=B8=94)=20=E0=B9=81?= =?UTF-8?q?=E0=B8=97=E0=B8=99=20UserPicker=20mock=2010=20=E0=B8=84?= =?UTF-8?q?=E0=B8=99=20(=E0=B8=A5=E0=B8=9A=E0=B9=84=E0=B8=9F=E0=B8=A5?= =?UTF-8?q?=E0=B9=8C=E0=B8=9E=E0=B8=A3=E0=B9=89=E0=B8=AD=E0=B8=A1=20MOCK?= =?UTF-8?q?=5FUSERS/localStorage/getLocalUser=20=E0=B8=97=E0=B8=B1?= =?UTF-8?q?=E0=B9=89=E0=B8=87=E0=B8=8A=E0=B8=B8=E0=B8=94)=20+=20LoginForm?= =?UTF-8?q?=20=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B9=8C=E0=B8=94=20max-w-sm=20?= =?UTF-8?q?=E0=B8=81=E0=B8=A5=E0=B8=B2=E0=B8=87=E0=B8=88=E0=B8=AD=20reuse?= =?UTF-8?q?=20brand=20lockup=20=E0=B9=81=E0=B8=A5=E0=B8=B0=E0=B8=9B?= =?UTF-8?q?=E0=B8=B8=E0=B9=88=E0=B8=A1=20primary=20=E0=B9=80=E0=B8=94?= =?UTF-8?q?=E0=B8=B4=E0=B8=A1,=20input=20text-base=2016px=20=E0=B8=81?= =?UTF-8?q?=E0=B8=B1=E0=B8=99=20iOS=20zoom=20=E0=B8=9A=E0=B8=99=20iPad,=20?= =?UTF-8?q?dropdown=20placeholder=20"=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B8=AD?= =?UTF-8?q?=E0=B8=81=E0=B8=9C=E0=B8=B9=E0=B9=89=E0=B9=83=E0=B8=8A=E0=B9=89?= =?UTF-8?q?"=20=E0=B8=9A=E0=B8=B1=E0=B8=87=E0=B8=84=E0=B8=B1=E0=B8=9A?= =?UTF-8?q?=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B8=AD=E0=B8=81=20(=E0=B9=84?= =?UTF-8?q?=E0=B8=A1=E0=B9=88=20preselect=20=E0=B8=81=E0=B8=B1=E0=B8=99=20?= =?UTF-8?q?login=20=E0=B8=9C=E0=B8=B4=E0=B8=94=E0=B8=84=E0=B8=99=E0=B9=82?= =?UTF-8?q?=E0=B8=94=E0=B8=A2=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=95=E0=B8=B1?= =?UTF-8?q?=E0=B9=89=E0=B8=87=E0=B9=83=E0=B8=88),=20error=20=E0=B9=81?= =?UTF-8?q?=E0=B8=96=E0=B8=9A=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7?= =?UTF-8?q?=20role=3Dalert=20"=E0=B8=A3=E0=B8=AB=E0=B8=B1=E0=B8=AA?= =?UTF-8?q?=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=96=E0=B8=B9=E0=B8=81=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87?= =?UTF-8?q?"=20(dropdown=20=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B8=AD=E0=B8=81?= =?UTF-8?q?=E0=B8=9C=E0=B8=B4=E0=B8=94=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B9=84?= =?UTF-8?q?=E0=B8=94=E0=B9=89=E0=B9=81=E0=B8=A5=E0=B9=89=E0=B8=A7),=20?= =?UTF-8?q?=E0=B8=95=E0=B8=AD=E0=B8=99=E0=B8=9C=E0=B8=B4=E0=B8=94=E0=B8=A5?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=87=20password=20=E0=B8=84=E0=B8=87?= =?UTF-8?q?=E0=B8=9C=E0=B8=B9=E0=B9=89=E0=B9=83=E0=B8=8A=E0=B9=89=E0=B8=97?= =?UTF-8?q?=E0=B8=B5=E0=B9=88=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B8=AD=E0=B8=81?= =?UTF-8?q?=E0=B9=84=E0=B8=A7=E0=B9=89=20+=20focus+select=20+=20tests/util?= =?UTF-8?q?s/user.test.ts=20=E0=B8=A5=E0=B9=87=E0=B8=AD=E0=B8=81=20format?= =?UTF-8?q?=20=E0=B8=88=E0=B8=A3=E0=B8=B4=E0=B8=87=E0=B8=82=E0=B8=AD?= =?UTF-8?q?=E0=B8=87=20e-Court=20(parse=20=E0=B8=95=E0=B8=B1=E0=B8=A7?= =?UTF-8?q?=E0=B8=AD=E0=B8=A2=E0=B9=88=E0=B8=B2=E0=B8=87=20cookie=20?= =?UTF-8?q?=E0=B8=88=E0=B8=A3=E0=B8=B4=E0=B8=87=20235821/tu=5F2,=20garbage?= =?UTF-8?q?/empty/=E0=B8=82=E0=B8=B2=E0=B8=94=20field=20=E2=86=92=20null,?= =?UTF-8?q?=20round-trip,=20buildOacCaseCookie=20=E0=B8=84=E0=B8=A3?= =?UTF-8?q?=E0=B8=9A=2016=20field)=20+=20sync=20CLAUDE.md/PLAN.md=20=C2=A7?= =?UTF-8?q?0=20A4/README=20=E2=80=94=20annotation=20=E0=B8=AA=E0=B8=A1?= =?UTF-8?q?=E0=B8=B1=E0=B8=A2=20mock-user-01/UUID=20=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B9=83=E0=B8=8A=E0=B9=88=E0=B8=82=E0=B8=AD=E0=B8=87?= =?UTF-8?q?=E0=B8=95=E0=B8=B1=E0=B8=A7=E0=B9=80=E0=B8=AD=E0=B8=87=E0=B8=AD?= =?UTF-8?q?=E0=B8=B5=E0=B8=81=20(auth=20=E0=B8=88=E0=B8=A3=E0=B8=B4?= =?UTF-8?q?=E0=B8=87=E0=B9=80=E0=B8=97=E0=B8=B5=E0=B8=A2=E0=B8=9A=E0=B8=A3?= =?UTF-8?q?=E0=B8=B0=E0=B8=9A=E0=B8=9A=E0=B8=A8=E0=B8=B2=E0=B8=A5=20+=20ro?= =?UTF-8?q?les=20=E0=B8=A2=E0=B8=B1=E0=B8=87=20out=20of=20scope;=20/api/*?= =?UTF-8?q?=20=E0=B9=81=E0=B8=A5=E0=B8=B0=20/uploads/*=20=E0=B8=A2?= =?UTF-8?q?=E0=B8=B1=E0=B8=87=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=95=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=20login=20=E0=B8=95=E0=B8=B2=E0=B8=A1?= =?UTF-8?q?=E0=B8=82=E0=B8=AD=E0=B8=9A=E0=B9=80=E0=B8=82=E0=B8=95=20mock)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- CLAUDE.md | 16 +++-- README.md | 6 +- app/actions/auth.ts | 41 +++++++++++ app/annotate/[id]/page.tsx | 8 ++- app/login/page.tsx | 21 ++++++ app/page.tsx | 10 ++- components/LoginForm.tsx | 132 +++++++++++++++++++++++++++++++++++ components/LogoutButton.tsx | 29 ++++++++ components/SessionBar.tsx | 15 ++++ components/UserPicker.tsx | 66 ------------------ components/pdf/PDFViewer.tsx | 13 ++-- docs/PLAN.md | 8 +-- lib/session.ts | 8 +++ middleware.ts | 24 +++++++ tests/utils/user.test.ts | 58 +++++++++++++++ utils/user.ts | 90 ++++++++++++------------ 16 files changed, 414 insertions(+), 131 deletions(-) create mode 100644 app/actions/auth.ts create mode 100644 app/login/page.tsx create mode 100644 components/LoginForm.tsx create mode 100644 components/LogoutButton.tsx create mode 100644 components/SessionBar.tsx delete mode 100644 components/UserPicker.tsx create mode 100644 lib/session.ts create mode 100644 middleware.ts create mode 100644 tests/utils/user.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index f2eeafd..56919df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ - pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/pdf.worker.min.mjs` (postinstall คัดลอกจาก node_modules) - Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`) - Tailwind CSS -- ไม่มีระบบ auth — anonymous identity จาก localStorage (`utils/user.ts`) +- mock login e-Court — หน้า `/login` (dropdown `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` ตาม format จริงของระบบศาล → identity (userId = `u_userid`, userName = `u_userlogin` — **ต่างกันต่อคน** เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope) ## Invariants ที่ต้องรักษา (ห้ามละเมิด) @@ -34,26 +34,30 @@ ``` db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false) +middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /login (ยกเว้น /login, /api/*, /uploads/*, static ใน public/) lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe) lib/server-pdf.ts ← path guard อ่าน PDF จาก public/ (export ใช้) +lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court) utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP (sidebar/สรุป/CSV ใช้ร่วม) utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route) -utils/user.ts ← anonymous identity (localStorage) +utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้) utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed) utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3) utils/image-to-pdf.ts ← สร้าง PDF ใหม่จากไฟล์รูป embedJpg/embedPng + EXIF rotate + TIFF→PNG (N3, export route) hooks/useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe ด้วย id hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2) app/actions/annotations.ts ← Server Actions CRUD + validation + publish event +app/actions/auth.ts ← Server Action login (mock user_x/Demo1234 → ตั้ง cookie oacCase) / logout (ลบ cookie) app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/uploads/ (N4) app/api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=) app/api/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8) app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16) -app/api/bookmarks/export/route.ts ← รายงานรายการ bookmark เป็น PDF (N7 — สร้าง PDF ใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ) +app/api/bookmarks/export/route.ts ← ส่งออกรายการ bookmark เป็น JSON (N7 — ข้อมูลล้วนจาก DB ไม่โหลดไฟล์ต้นฉบับ; เดิมเป็นรายงาน PDF เปลี่ยนตามคำขอผู้ใช้ 2026-09-17) app/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route) app/page.tsx ← รายการไฟล์ (server component) +app/login/page.tsx ← หน้า login (มี session แล้ว redirect /) app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer app/annotate/[id]/summary/page.tsx ← หน้าสรุปหมายเหตุจัดกลุ่มตามหน้า (F15) components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า) @@ -62,13 +66,15 @@ components/pdf/TextboxStampLayer.tsx ← overlay TEXTBOX + STAMP (F17/F18) components/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles ย้าย/ปรับขนาด (F1) + แถบสี/ความหนา + ปุ่มลบ components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1) components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ (N4) +components/LoginForm.tsx ← ฟอร์ม login (client — เรียก action login แล้ว router.replace("/")) +components/SessionBar.tsx ← แถบแสดงผู้ใช้ที่ login + ปุ่มออกจากระบบบนหน้ารายการ (แทน UserPicker เดิม) ``` ## ขอบเขต — ห้ามทำเกิน (Out of Scope) -> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md`) +> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md` — **2026-09-17 เปลี่ยน deliverable เป็นส่งออก JSON** แทนรายงาน PDF ตามคำขอผู้ใช้: flat array `BookmarkDTO` เรียง N5 + envelope fileId/title/exportedAt ผ่าน route เดิม) -- ไม่มี login/roles, ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions` +- ไม่มี auth จริง/roles (login เป็น mock หน้า `/login` ตั้ง cookie `oacCase` — ไม่ได้ตรวจกับระบบ e-Court จริง), ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions` - Annotation: สร้าง + ลบ + แก้ไข/ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา/แก้ไขข้อความ **ของตัวเอง ในหน้าเดียวกัน** — resolve เป็นสิ่งเดียวที่ใครก็ toggle ได้ (D6) - ปุ่มลบ/แก้ไข/ประวัติแสดงเฉพาะรายการของตัวเอง (`userId` ตรงกัน) เท่านั้น - หน้า `/` อ่านรายการจาก DB + ฟอร์มอัปโหลดไฟล์เดียวแบบง่าย (N4 — PDF/jpg/jpeg/png/tif/tiff ≤ 100MB เขียนไฟล์ใหม่ที่ `public/uploads/` เท่านั้น; ไม่มีหน้าจัดการ/ลบไฟล์ใน UI; sample data sync ด้วย `npm run seed-samples`) diff --git a/README.md b/README.md index ba8a69b..ae13d78 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - **Bookmark** — จดตำแหน่ง (หน้า + % ความสูง) กดแล้ว smooth-scroll กลับตำแหน่งเดิม - **Realtime sync** — เปิด 2 แท็บ วาดในแท็บ A แท็บ B เห็นทันทีผ่าน SSE (`EventSource` ต่อใหม่อัตโนมัติเมื่อขาด) - **Page Virtualization** — render เฉพาะหน้าใน viewport ± 1 หน้า ไฟล์หลายสิบหน้าเลื่อนลื่น -- ไม่มีระบบ login — anonymous identity เก็บใน `localStorage` (ปุ่มลบแสดงเฉพาะรายการของตัวเอง) +- หน้า login (mock e-Court — dropdown `user1`–`user5` รหัสผ่านกลาง `Demo1234`) — identity จาก cookie `oacCase` บังคับทุกหน้า (ปุ่มลบ/แก้ไขแสดงเฉพาะรายการของตัวเอง) ## สิ่งที่ต้องมี (Prerequisites) @@ -85,7 +85,7 @@ npm run dev | bookmark+ | เพิ่ม bookmark ที่ตำแหน่งกึ่งกลางจอปัจจุบัน | | zoom − / + | สเกล 50%–300% (annotation ทั้งหมด scale ตามเพราะเก็บเป็น %) | -Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกรายการเพื่อเลื่อนกลับไปตำแหน่งนั้น และมีปุ่มลบเฉพาะรายการของตัวเอง (anonymous identity จาก `localStorage`) +Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกรายการเพื่อเลื่อนกลับไปตำแหน่งนั้น และมีปุ่มลบเฉพาะรายการของตัวเอง (identity จากหน้า login) ทดสอบ realtime: เปิดหน้า annotate ไฟล์เดียวกันใน 2 แท็บ แล้ววาด/comment จากแท็บหนึ่ง — อีกแท็บเห็นทันทีโดยไม่ต้อง refresh @@ -112,7 +112,7 @@ db/schema.ts ตาราง + JSONB strict union + DTO + toDTO db/index.ts postgres.js + drizzle (prepare: false) lib/annotate-events.ts pub/sub in-process (actions → SSE route) utils/coordinates.ts px ⇄ % + clamp + mergeRectsByLine -utils/user.ts anonymous identity (localStorage) +utils/user.ts parse/build cookie oacCase (mock login e-Court) hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว) app/api/annotations/stream/route.ts SSE endpoint (GET ?fileId=) diff --git a/app/actions/auth.ts b/app/actions/auth.ts new file mode 100644 index 0000000..79499a9 --- /dev/null +++ b/app/actions/auth.ts @@ -0,0 +1,41 @@ +"use server"; + +// mock login e-Court: รับเฉพาะผู้ใช้ใน MOCK_USERS (user1–user5) + Demo1234 +// → ตั้ง cookie oacCase ตาม format จริง (identity ของแอปอ่านผ่าน lib/session.ts +// — บังคับด้วย middleware.ts) +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +import { buildOacCaseCookie, MOCK_USERS, OAC_CASE_COOKIE } from "@/utils/user"; + +const MOCK_PASSWORD = "Demo1234"; + +export async function login( + username: string, + password: string, +): Promise<{ ok: true } | { ok: false; error: string }> { + // mock ตอบเร็วกว่าตา — หน่วงเล็กน้อยให้เห็นสถานะ "กำลังตรวจสอบ…" + await new Promise((resolve) => setTimeout(resolve, 500)); + const name = username.trim(); + // username มาจาก dropdown เลือกผิดไม่ได้ — เช็ครายชื่อฝั่ง server เป็น defense กัน hand-craft + if (!MOCK_USERS.includes(name) || password !== MOCK_PASSWORD) { + return { ok: false, error: "รหัสผ่านไม่ถูกต้อง" }; + } + // u_userid ต่างกันต่อคน (= u_userlogin) — สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" (guard ด้วย + // userId) ต้องแยกกันจริงทั้ง 5 คน ห้ามแชร์ค่าเดียว + cookies().set(OAC_CASE_COOKIE, buildOacCaseCookie(name, name), { + httpOnly: true, + sameSite: "lax", + path: "/", + maxAge: 8 * 60 * 60, + }); + return { ok: true }; +} + +export async function logout(): Promise { + // ลบ cookie ด้วย path "/" เท่านั้น (ตรงกับตอน set) ไม่งั้น browser ไม่ลบให้ + cookies().set(OAC_CASE_COOKIE, "", { path: "/", maxAge: 0 }); + // redirect ใน action เอง — ให้ Next จัดการ navigation กลไกเดียว + // (ถ้าให้ client router.replace เอง จะชนกับ re-render ของหน้า "/" ที่เพิ่งเสีย session จน navigation ไม่เกิด) + redirect("/login"); +} diff --git a/app/annotate/[id]/page.tsx b/app/annotate/[id]/page.tsx index 6d1e4a3..50c1b4c 100644 --- a/app/annotate/[id]/page.tsx +++ b/app/annotate/[id]/page.tsx @@ -1,5 +1,5 @@ import { asc, eq, inArray } from "drizzle-orm"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { db } from "@/db"; import { @@ -13,6 +13,7 @@ import { } from "@/db/schema"; import PDFViewer from "@/components/pdf/PDFViewer"; import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์) +import { getSessionUser } from "@/lib/session"; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -27,6 +28,10 @@ export default async function AnnotatePage({ const { id } = params; if (!UUID_RE.test(id)) notFound(); + // defense ซ้อน middleware — identity จาก cookie oacCase ส่งเข้า viewer ตั้งแต่ first paint + const user = getSessionUser(); + if (!user) redirect("/login"); + const [file] = await db.select().from(files).where(eq(files.id, id)); if (!file) notFound(); @@ -62,6 +67,7 @@ export default async function AnnotatePage({ initialAnnotations={annotationRows.map(toAnnotationDTO)} initialBookmarks={bookmarkRows.map(toBookmarkDTO)} initialReplies={replyRows.map(toReplyDTO)} + user={user} /> ); diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..51bf203 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import LoginForm from "@/components/LoginForm"; +import { getSessionUser } from "@/lib/session"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { title: "เข้าสู่ระบบ | Judge's Note" }; + +export default function LoginPage() { + // เข้าสู่ระบบอยู่แล้ว (cookie ครบ) → ไม่ต้องเห็นฟอร์มอีก + const user = getSessionUser(); + if (user) redirect("/"); + + return ( +
+ +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 64f2e83..7ed5772 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,12 +1,14 @@ import Link from "next/link"; +import { redirect } from "next/navigation"; import { asc } from "drizzle-orm"; import { FileImage, FileText } from "lucide-react"; import { db } from "@/db"; import { files } from "@/db/schema"; -import UserPicker from "@/components/UserPicker"; +import SessionBar from "@/components/SessionBar"; import UploadFileForm from "@/components/UploadFileForm"; import { imageFormatFromUrl } from "@/utils/file-kind"; +import { getSessionUser } from "@/lib/session"; export const dynamic = "force-dynamic"; @@ -25,6 +27,10 @@ function kindBadge(pdfUrl: string): string { } export default async function HomePage() { + // defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session + const user = getSessionUser(); + if (!user) redirect("/login"); + const rows = await db.select().from(files).orderBy(asc(files.createdAt)); return ( @@ -43,7 +49,7 @@ export default async function HomePage() { - +

เพิ่มเอกสาร

diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx new file mode 100644 index 0000000..08e4365 --- /dev/null +++ b/components/LoginForm.tsx @@ -0,0 +1,132 @@ +"use client"; + +// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace("/") +// (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail) +import { useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +import { login } from "@/app/actions/auth"; +import Select from "@/components/Select"; +import { MOCK_USERS } from "@/utils/user"; + +// text input ของแอป — text-base (16px) เสมอ: กัน iOS auto-zoom ตอน focus บน iPad +const INPUT_CLASS = + "mt-1 block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-base text-slate-900 placeholder:text-slate-400 transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:bg-slate-50 aria-invalid:border-red-600 aria-invalid:focus:ring-red-600/30"; +// dropdown ผู้ใช้ — เหมือน INPUT_CLASS แต่ไม่มี mt-1 (Select ครอบ div.relative: มาร์จินที่ wrapper ข้างนอกแทน) +const SELECT_CLASS = + "block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-base text-slate-900 transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:bg-slate-50"; + +export default function LoginForm() { + const router = useRouter(); + const passwordRef = useRef(null); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + setError(null); + const usernameInput = form.elements.namedItem("username") as HTMLSelectElement; + const passwordInput = form.elements.namedItem("password") as HTMLInputElement; + startTransition(async () => { + try { + const result = await login(usernameInput.value, passwordInput.value); + if (result.ok) { + router.replace("/"); + } else { + setError(result.error); + // คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที + passwordInput.value = ""; + passwordRef.current?.focus(); + passwordRef.current?.select(); + } + } catch { + setError("เข้าสู่ระบบไม่สำเร็จ — ลองใหม่อีกครั้ง"); + } + }); + }; + + return ( +
+ {/* brand lockup เดียวกับ header หน้ารายการ (ลดขนาดตัวอักษรให้พอการ์ดแคบ) */} +
+
+ JN +
+
+

Judge's Note

+

ระบบ PDF Annotation สำหรับ e-Court

+
+
+ +

เข้าสู่ระบบ

+ +
+
+ +
+ +
+
+ +
+ + +
+ + + + {/* รหัสผ่านทดสอบ — รายชื่อเห็นใน dropdown แล้ว เหลือบอกแค่รหัสผ่าน (mock ไม่มีระบบสมัคร) */} +

+ รหัสผ่านทดสอบ:{" "} + Demo1234 +

+ + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/components/LogoutButton.tsx b/components/LogoutButton.tsx new file mode 100644 index 0000000..c95704d --- /dev/null +++ b/components/LogoutButton.tsx @@ -0,0 +1,29 @@ +"use client"; + +// ปุ่มออกจากระบบ — server action logout ลบ cookie oacCase แล้ว redirect /login เอง +import { useTransition } from "react"; +import { LogOut } from "lucide-react"; + +import { logout } from "@/app/actions/auth"; + +export default function LogoutButton() { + const [pending, startTransition] = useTransition(); + + const onLogout = () => { + startTransition(async () => { + await logout(); + }); + }; + + return ( + + ); +} diff --git a/components/SessionBar.tsx b/components/SessionBar.tsx new file mode 100644 index 0000000..360d950 --- /dev/null +++ b/components/SessionBar.tsx @@ -0,0 +1,15 @@ +// แถบแสดงผู้ใช้ที่เข้าสู่ระบบบนหน้ารายการ (แทน UserPicker เดิม) — server component +// รับค่าจาก cookie ผ่าน props จากหน้า (middleware การันตีว่ามี session ก่อนถึงหน้านี้) +import LogoutButton from "./LogoutButton"; + +export default function SessionBar({ userName }: { userName: string }) { + return ( +
+

+ เข้าสู่ระบบเป็น{" "} + {userName} +

+ +
+ ); +} diff --git a/components/UserPicker.tsx b/components/UserPicker.tsx deleted file mode 100644 index 1717fa7..0000000 --- a/components/UserPicker.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -// เลือก mock user ที่หน้าแรกก่อนเข้า annotate — เขียนลง localStorage key เดิม -// ที่ getLocalUser() อ่าน (PDFViewer ไม่ต้องแก้อะไร) -import { useEffect, useState } from "react"; - -import Select from "./Select"; -import { - MOCK_USERS, - getStoredLocalUser, - setLocalUser, - type LocalUser, -} from "@/utils/user"; - -export default function UserPicker() { - const [current, setCurrent] = useState(null); - - // ต้องรันใน effect เพราะ SSR ไม่มี localStorage — ก่อน mount แสดงคนแรกไว้ก่อน - useEffect(() => { - const stored = getStoredLocalUser(); - if (!stored) { - // ยังไม่เคยมี identity → เริ่มด้วย mock user คนแรก - setLocalUser(MOCK_USERS[0]); - setCurrent(MOCK_USERS[0]); - return; - } - setCurrent(stored); - }, []); - - // identity สุ่มเดิมไม่อยู่ในรายการ mock → แสดงเป็น option ชั่วคราวให้เห็นว่าปัจจุบันคือใคร - const isMock = current !== null && MOCK_USERS.some((u) => u.userId === current.userId); - - const select = (userId: string) => { - const user = MOCK_USERS.find((u) => u.userId === userId); - if (!user) return; - setLocalUser(user); - setCurrent(user); - }; - - return ( -
-

เลือกผู้ใช้ (mock)

-

- annotation ทั้งหมดจะบันทึกในชื่อผู้ใช้ที่เลือก -

- -
- ); -} diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index aacf4ae..e154660 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -113,7 +113,7 @@ import { type Rect, type Size, } from "@/utils/coordinates"; -import { getLocalUser, type LocalUser } from "@/utils/user"; +import type { OacUser } from "@/utils/user"; import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind"; import { decodeTiffPages } from "@/utils/tiff-client"; import { @@ -1138,11 +1138,14 @@ export default function PDFViewer({ initialAnnotations, initialBookmarks, initialReplies, + user, }: { file: { id: string; title: string; pdfUrl: string }; initialAnnotations: AnnotationDTO[]; initialBookmarks: BookmarkDTO[]; initialReplies: ReplyDTO[]; + /** identity จาก cookie oacCase (หน้า annotate การันตีให้ — ไม่มีช่วง null หลัง mount) */ + user: OacUser; }) { const [pdfjs, setPdfjs] = useState(null); const [doc, setDoc] = useState(null); @@ -1194,7 +1197,6 @@ export default function PDFViewer({ const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null); const [bookmarkPos, setBookmarkPos] = useState<{ left: number; top: number } | null>(null); const [scale, setScale] = useState(1); - const [user, setUser] = useState(null); const [currentPage, setCurrentPage] = useState(0); // tool highlight — live preview มาร์กตามรูปแบบ/สีที่ตั้งไว้ ระหว่างลากคลุมข้อความ const [liveMark, setLiveMark] = useState<{ pageIndex: number; rects: Rect[] } | null>(null); @@ -1323,11 +1325,10 @@ export default function PDFViewer({ [replies, visibleAnnotations], ); - // anonymous identity จาก localStorage — ต้องรันใน effect (SSR ไม่มี localStorage) + // UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount + // ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม) + // (identity มาจาก prop user แล้ว — ไม่ต้องอ่านใน effect) useEffect(() => { - setUser(getLocalUser()); - // UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount - // ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม) if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false); }, []); diff --git a/docs/PLAN.md b/docs/PLAN.md index f2f6c2e..fd65c3f 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -11,7 +11,7 @@ | A1 | **ตำแหน่งโปรเจกต์** | `D:\dms\judes-note\` — directory แยกต่างหาก (ไม่ใช่ subdirectory ของ dms-main) เพื่อไม่ชนกับโปรเจกต์ Nuxt ที่มีโฟลเดอร์ `app/` อยู่แล้ว | | A2 | **Framework** | Next.js 14 (App Router) + TypeScript strict | | A3 | **PDF Renderer** | `pdfjs-dist` เวอร์ชัน **v4** (`^4.10.38`) — ใช้ built-in `TextLayer` class; worker ตั้ง path ตรง ๆ: คัดลอก `pdf.worker.min.mjs` (จาก `node_modules/pdfjs-dist/build/`) มาไว้ที่ `public/` ด้วย **postinstall script** แล้วตั้ง `pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` — **ไม่ใช้** `new URL(..., import.meta.url)` เพราะ v4 เป็นไฟล์ `.mjs` และมักชนกับ Turbopack/webpack ใน Next 14 | -| A4 | **Identity** | สเปกไม่ระบุระบบ auth → ใช้ **anonymous user** สร้างครั้งเดียวเก็บใน `localStorage` (`userId` = UUID, `userName` = "ผู้ใช้ XXXX") เพื่อเติม field `userId`/`userName` ในตาราง | +| A4 | **Identity (ปรับปรุง 2026-09-17 — mock login)** | หน้า `/login` mock e-Court (dropdown ผู้ใช้ `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` เป็น query string ตาม format จริงของระบบศาล → identity อ่านจาก cookie: `userId` = `u_userid`, `userName` = `u_userlogin` (**ต่างกันต่อคน** = ชื่อผู้ใช้ เพื่อให้สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" แยกกันจริง) ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope; เดิมเป็น anonymous user ใน `localStorage` — annotation ที่สร้างสมัย `mock-user-01`/UUID จึงไม่ใช่ของตัวเองอีก) | | A5 | **Upload แบบง่าย (ปรับปรุง 2026-09-15 — N4)** | หน้า `/` อ่านรายการจาก DB + ฟอร์มอัปโหลดไฟล์เดียว (PDF/jpg/jpeg/png/tif/tiff, ≤ 100MB) ผ่าน Server Action เขียนไฟล์**ใหม่**ที่ `public/uploads/.` เท่านั้น (ไม่แตะไฟล์เดิม) — sample เก็บใน `public/samples/` แล้ว sync ด้วย `npm run seed-samples` (รายละเอียดด้านล่าง) | | A6 | **ทุก annotation ลบได้** | แสดงปุ่มลบเฉพาะรายการของตัวเอง (`userId` ตรงกัน) — ไม่มี role/permission ในเฟสนี้ | | A7 | **Database & Realtime (ปรับปรุง 2026-09-08)** | PostgreSQL แบบ **plain container เดียว** (`judes-note-db`, Docker, image `postgres:17-alpine`, พอร์ต 54322) — **เลิกใช้ Supabase stack ทั้งหมด** (ไม่มี CLI/Kong/realtime container) เพราะโปรเจกต์ใช้จริงแค่ DB + sync; realtime sync ใช้ **SSE (Server-Sent Events) + pub/sub in-process** แทน Supabase Realtime | @@ -102,7 +102,7 @@ Percent (จาก DB) ──×size÷100──▶ Pixel (render ลง SVG/div | id | uuid PK | | | file_id | uuid FK → files ON DELETE CASCADE | | | page_index | integer NOT NULL | 0-based | -| user_id | text NOT NULL | anonymous id จาก localStorage | +| user_id | text NOT NULL | จาก cookie oacCase (`u_userid`) | | user_name | text NOT NULL | | | type | annotation_type NOT NULL | | | data | jsonb NOT NULL `$type()` | **strict typed union** (ด้านล่าง) | @@ -171,7 +171,7 @@ judes-note/ │ └── annotate-events.ts ← pub/sub in-process (Server Actions publish → SSE route subscribe) ├── utils/ │ ├── coordinates.ts ← pixel⇄% (point/rect/path) + getPointerPercent + clamp + mergeRectsByLine (รวบ rects บรรทัดเดียวกันเหลือ 1 rect/บรรทัดก่อนบันทึก) -│ └── user.ts ← anonymous user identity (localStorage) +│ └── user.ts ← parse/build ค่า cookie oacCase → identity (mock login e-Court) ├── hooks/ │ └── useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe + expose setters ├── app/ @@ -248,7 +248,7 @@ judes-note/ ## 9. ขอบเขตที่ **ไม่** ทำในเฟสนี้ (Out of Scope) -- ระบบ login/roles (ใช้ anonymous identity) +- auth จริงเทียบระบบ e-Court + roles/สิทธิ์ — login ปัจจุบันเป็น mock หน้า `/login` (ตั้ง cookie `oacCase` ตาม format จริง แต่ไม่ได้ตรวจกับระบบศาลจริง) - แก้ไข/ย้าย annotation ที่วาดแล้ว (มีเฉพาะสร้าง + ลบ) — **ข้อนี้ถูกขยายโดย `docs/PLAN-FEAT.md` F1**: อนุญาตแก้ไข `data` (ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา) ของตัวเอง ในหน้าเดียวกัน ผ่าน `updateAnnotation` - Undo/redo, export PDF ที่ฝัง annotation - แสดงรายชื่อ "ใครออนไลน์อยู่" (presence) diff --git a/lib/session.ts b/lib/session.ts new file mode 100644 index 0000000..90c2029 --- /dev/null +++ b/lib/session.ts @@ -0,0 +1,8 @@ +// อ่าน identity จาก cookie oacCase ฝั่ง server (mock login e-Court) — เรียกจากทุก page +import { cookies } from "next/headers"; + +import { OAC_CASE_COOKIE, parseOacCase, type OacUser } from "@/utils/user"; + +export function getSessionUser(): OacUser | null { + return parseOacCase(cookies().get(OAC_CASE_COOKIE)?.value); +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..bf8b582 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,24 @@ +import { NextResponse, type NextRequest } from "next/server"; + +import { OAC_CASE_COOKIE, parseOacCase } from "@/utils/user"; + +// บังคับ login (mock e-Court) ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /login +// (parse จริง ไม่ใช่แค่มี cookie — cookie ขยะ/ครึ่ง ๆ ถือว่ายังไม่เข้าสู่ระบบ) +export function middleware(req: NextRequest) { + const user = parseOacCase(req.cookies.get(OAC_CASE_COOKIE)?.value); + if (!user) { + const url = req.nextUrl.clone(); + url.pathname = "/login"; + url.search = ""; + return NextResponse.redirect(url); + } + return NextResponse.next(); +} + +// ยกเว้น: หน้า login เอง, API/SSE/export, ไฟล์อัปโหลด, Next internal, +// และ static ใน public/ (pdf.worker, samples, fonts, favicon) +export const config = { + matcher: [ + "/((?!login|api|uploads|_next|samples|fonts|pdf\\.worker\\.min\\.mjs|favicon\\.ico).*)", + ], +}; diff --git a/tests/utils/user.test.ts b/tests/utils/user.test.ts new file mode 100644 index 0000000..3da2828 --- /dev/null +++ b/tests/utils/user.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + buildOacCaseCookie, + OAC_CASE_COOKIE, + parseOacCase, +} from "@/utils/user"; + +/** ตัวอย่าง cookie จริงจากระบบ e-Court (เก็บใน test เพื่อล็อก format ของจริง) */ +const REAL_OAC_CASE = + "u_userid=235821&u_userlogin=tu_2&u_courtid=4&u_password=&u_AdminStatus=&u_perCardNo=5000000000006&u_perCardNoEncript=1800650010005002003&u_userType=4&u_CidAuthen=NNNENNNNNNNNNNNNNNNN&u_hSecId=NNNNNNNNNNNNNNNNNNNN&u_username=&u_courtName=&u_AuditID=1995227&u_PsGroupNum=1&u_PsGroup=11&u_PsGroupName="; + +describe("parseOacCase", () => { + it("cookie e-Court จริง → identity จาก u_userid/u_userlogin", () => { + expect(parseOacCase(REAL_OAC_CASE)).toEqual({ + userId: "235821", + userName: "tu_2", + }); + }); + + it("ค่าจาก buildOacCaseCookie อ่านกลับได้ (round-trip ค่า mock login จริง user1)", () => { + expect(parseOacCase(buildOacCaseCookie("user1", "user1"))).toEqual({ + userId: "user1", + userName: "user1", + }); + }); + + it("ค่าเสีย/ว่าง/ขาด field → null (ถือว่ายังไม่เข้าสู่ระบบ)", () => { + expect(parseOacCase(null)).toBeNull(); + expect(parseOacCase(undefined)).toBeNull(); + expect(parseOacCase("")).toBeNull(); + expect(parseOacCase("garbage")).toBeNull(); + expect(parseOacCase("u_userlogin=user_x")).toBeNull(); // ขาด u_userid + expect(parseOacCase("u_userid=123&u_userlogin=")).toBeNull(); // u_userlogin ว่าง + }); + + it("ค่ารอบด้วย space ถูก trim", () => { + expect(parseOacCase("u_userid= 123 &u_userlogin= tu_2 ")).toEqual({ + userId: "123", + userName: "tu_2", + }); + }); + + it("ชื่อ cookie ตรงกับระบบ e-Court", () => { + expect(OAC_CASE_COOKIE).toBe("oacCase"); + }); +}); + +describe("buildOacCaseCookie", () => { + it("แทนเฉพาะ u_userid/u_userlogin — ฟิลด์อื่นคงค่าตัวอย่างจริงครบทุก field", () => { + const fields = Object.fromEntries(new URLSearchParams(buildOacCaseCookie("xxxxxx", "user_x"))); + expect(fields["u_userid"]).toBe("xxxxxx"); + expect(fields["u_userlogin"]).toBe("user_x"); + expect(fields["u_courtid"]).toBe("4"); + expect(fields["u_perCardNo"]).toBe("5000000000006"); + expect(fields["u_AuditID"]).toBe("1995227"); + expect(Object.keys(fields)).toHaveLength(16); + }); +}); diff --git a/utils/user.ts b/utils/user.ts index 51dca51..a173b20 100644 --- a/utils/user.ts +++ b/utils/user.ts @@ -1,51 +1,53 @@ -// anonymous identity เก็บใน localStorage สร้างครั้งเดียวต่อ browser (PLAN.md §0 A4) -// ใช้เติม field userId/userName ในตาราง annotations/bookmarks — ไม่มีระบบ auth +// identity ของแอป: mock login e-Court หน้า /login — อ่านจาก cookie "oacCase" +// (userId = u_userid, userName = u_userlogin) แทน localStorage เดิม +// ไฟล์นี้เป็น pure module — middleware (edge) และ client import ร่วมได้ ห้ามใส่ next/headers -export type LocalUser = { userId: string; userName: string }; +export type OacUser = { userId: string; userName: string }; -const STORAGE_KEY = "judes-note:user"; +export const OAC_CASE_COOKIE = "oacCase"; -// mock users สำหรับเลือกที่หน้าแรก (demo ไม่มีระบบ auth) — userId คงที่ -// เพื่อให้กรองรายคนและสิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" ทำงานสม่ำเสมอข้าม refresh -export const MOCK_USERS: LocalUser[] = [ - { userId: "mock-user-01", userName: "ผู้พิพากษา สมชาย" }, - { userId: "mock-user-02", userName: "ผู้พิพากษา สมหญิง" }, - { userId: "mock-user-03", userName: "อัยการ วิชัย" }, - { userId: "mock-user-04", userName: "เลขานุการศาล ปิยะ" }, - { userId: "mock-user-05", userName: "ทนายโจทก์ ณัฐ" }, - { userId: "mock-user-06", userName: "ทนายจำเลย อรุณี" }, - { userId: "mock-user-07", userName: "ผู้ช่วยผู้พิพากษา กันต์" }, - { userId: "mock-user-08", userName: "พนักงานสอบสวน เดชา" }, - { userId: "mock-user-09", userName: "ผู้เชี่ยวชาญ มานะ" }, - { userId: "mock-user-10", userName: "ผู้ช่วยเลขานุการ ฝ้าย" }, +/** ผู้ใช้ mock หน้า /login (dropdown) — LoginForm (client) + action login (server) + * ใช้ร่วมกันผ่าน module นี้กันสองที่ drift; รหัสผ่านกลาง Demo1234 อยู่ใน action */ +export const MOCK_USERS: readonly string[] = [ + "user1", + "user2", + "user3", + "user4", + "user5", ]; -export function setLocalUser(user: LocalUser): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(user)); -} - -// อ่าน identity ที่เก็บไว้โดยไม่สร้างใหม่ — ใช้แสดงค่าปัจจุบันในหน้าเลือกผู้ใช้ -export function getStoredLocalUser(): LocalUser | null { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) { - try { - const parsed = JSON.parse(raw) as LocalUser; - if (typeof parsed.userId === "string" && typeof parsed.userName === "string") { - return parsed; - } - } catch { - // ข้อมูลเก่าเสียหาย → ถือว่าไม่มีข้อมูล - } - } - return null; -} - -export function getLocalUser(): LocalUser { - const stored = getStoredLocalUser(); - if (stored) return stored; - - const userId = crypto.randomUUID(); - const userName = `ผู้ใช้ ${userId.slice(0, 4).toUpperCase()}`; - localStorage.setItem(STORAGE_KEY, JSON.stringify({ userId, userName })); +/** แยกค่า query string ของ cookie oacCase → identity + * ขาด/ว่าง u_userid หรือ u_userlogin = ถือว่ายังไม่เข้าสู่ระบบ (null) + * หมายเหตุ: Next (cookies().get / req.cookies.get) decode ค่า cookie กลับมาให้แล้ว + * จึงส่งให้ URLSearchParams ตรง ๆ — ห้าม decodeURIComponent ซ้ำ */ +export function parseOacCase(raw: string | null | undefined): OacUser | null { + if (!raw) return null; + const params = new URLSearchParams(raw); + const userId = params.get("u_userid")?.trim(); + const userName = params.get("u_userlogin")?.trim(); + if (!userId || !userName) return null; return { userId, userName }; } + +/** ค่า cookie oacCase ตาม format จริงของ e-Court (ฟิลด์อื่นคงค่าจากตัวอย่างจริง) + * mock login แทนเฉพาะ u_userid/u_userlogin */ +export function buildOacCaseCookie(userId: string, userLogin: string): string { + return [ + `u_userid=${userId}`, + `u_userlogin=${userLogin}`, + "u_courtid=4", + "u_password=", + "u_AdminStatus=", + "u_perCardNo=5000000000006", + "u_perCardNoEncript=1800650010005002003", + "u_userType=4", + "u_CidAuthen=NNNENNNNNNNNNNNNNNNN", + "u_hSecId=NNNNNNNNNNNNNNNNNNNN", + "u_username=", + "u_courtName=", + "u_AuditID=1995227", + "u_PsGroupNum=1", + "u_PsGroup=11", + "u_PsGroupName=", + ].join("&"); +}