auth: replace local session auth with rokojori-auth JWT integration

Removes express-session, bcryptjs, local user system, login/register pages,
and email module. Adds JWT verification middleware (cookie + Bearer header),
cookie-parser, and redirects login/logout to account.rokojori.com.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rokojori 2026-07-13 14:45:06 +02:00
parent 605afa3e66
commit e89a372152
19 changed files with 260 additions and 553 deletions

260
package-lock.json generated
View File

@ -10,16 +10,16 @@
"dependencies": {
"@langchain/core": "^1.2.2",
"@langchain/openai": "^1.5.4",
"bcryptjs": "^2.4.3",
"@types/cookie-parser": "^1.4.10",
"@types/jsonwebtoken": "^9.0.10",
"cookie-parser": "^1.4.7",
"express": "^4.18.2",
"express-session": "^1.17.3",
"jsonwebtoken": "^9.0.3",
"markdown-it": "^14.3.0",
"nodemailer": "^9.0.3"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/express": "^4.17.21",
"@types/express-session": "^1.17.10",
"@types/node": "^20.11.0",
"@types/nodemailer": "^8.0.1",
"ts-node": "^10.9.2",
@ -143,18 +143,10 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
@ -165,18 +157,26 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/express": {
"version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
@ -188,7 +188,6 @@
"version": "4.19.8",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@ -197,35 +196,38 @@
"@types/send": "*"
}
},
"node_modules/@types/express-session": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.19.0.tgz",
"integrity": "sha512-GbypG0bog68UbOq2tSAp7SclvCUm3ha1uDi58OPRGK1NfRvCIu7Gz0M7fTGtpNG1T9a29GpuurQj9zEcT/lMXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
@ -246,21 +248,18 @@
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@ -270,7 +269,6 @@
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
@ -282,7 +280,6 @@
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
@ -367,12 +364,6 @@
],
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@ -397,6 +388,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -465,6 +462,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@ -530,6 +546,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -654,29 +679,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-session": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
"license": "MIT",
"dependencies": {
"cookie": "~0.7.2",
"cookie-signature": "~1.0.7",
"debug": "~2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "~5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@ -851,6 +853,55 @@
"base64-js": "^1.5.1"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/langsmith": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.8.0.tgz",
@ -903,6 +954,48 @@
"uc.micro": "^2.0.0"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@ -1069,15 +1162,6 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/openai": {
"version": "6.45.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz",
@ -1199,15 +1283,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -1258,6 +1333,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@ -1477,23 +1564,10 @@
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unpipe": {

View File

@ -9,16 +9,16 @@
"dependencies": {
"@langchain/core": "^1.2.2",
"@langchain/openai": "^1.5.4",
"bcryptjs": "^2.4.3",
"@types/cookie-parser": "^1.4.10",
"@types/jsonwebtoken": "^9.0.10",
"cookie-parser": "^1.4.7",
"express": "^4.18.2",
"express-session": "^1.17.3",
"jsonwebtoken": "^9.0.3",
"markdown-it": "^14.3.0",
"nodemailer": "^9.0.3"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/express": "^4.17.21",
"@types/express-session": "^1.17.10",
"@types/node": "^20.11.0",
"@types/nodemailer": "^8.0.1",
"ts-node": "^10.9.2",

View File

@ -1,13 +1,33 @@
interface AuthUser {
id: number;
username: string;
const AUTH_HOST = 'https://account.rokojori.com';
const APP_URL = 'https://roject.rokojori.com';
interface JwtUser {
userId: string;
email: string;
roles: string[];
products: string[];
}
class AppNav extends HTMLElement {
async connectedCallback(): Promise<void> {
const res = await fetch('/api/auth/me');
if (!res.ok) { location.href = '/login.html'; return; }
const user = await res.json() as AuthUser;
const res = await fetch( '/api/auth/me' );
if ( !res.ok ) {
const loginHref = `${AUTH_HOST}/login.html?redirect=${encodeURIComponent( APP_URL )}`;
this.innerHTML = `
<nav>
<span class="nav-brand">Roject</span>
<div class="nav-links"></div>
<div class="nav-user">
<a href="${loginHref}">Log in</a>
</div>
</nav>
`;
return;
}
const user = await res.json() as JwtUser;
const logoutHref = `${AUTH_HOST}/api/auth/logout?redirect=${encodeURIComponent( APP_URL )}`;
this.innerHTML = `
<nav>
@ -18,23 +38,11 @@ class AppNav extends HTMLElement {
<a href="/projects.html">Projects</a>
</div>
<div class="nav-user">
<span>${user.username}</span>
<button class="btn-logout">Logout</button>
<button class="btn-delete">Delete Account</button>
<span>${user.email}</span>
<a href="${logoutHref}">Log out</a>
</div>
</nav>
`;
this.querySelector('.btn-logout')!.addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST' });
location.href = '/login.html';
});
this.querySelector('.btn-delete')!.addEventListener('click', async () => {
if (!confirm('Delete your account? This cannot be undone.')) return;
await fetch('/api/auth/me', { method: 'DELETE' });
location.href = '/login.html';
});
}
}
customElements.define('app-nav', AppNav);
customElements.define( 'app-nav', AppNav );

View File

@ -1,58 +0,0 @@
login-form {
display: block;
max-width: 360px;
margin: 80px auto;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 6px;
}
login-form form {
display: flex;
flex-direction: column;
gap: 1rem;
}
login-form h2 {
margin: 0;
}
login-form label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.9rem;
}
login-form input {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
login-form button {
padding: 0.6rem;
background: #222;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
login-form button:hover {
background: #444;
}
login-form .error {
color: #c00;
font-size: 0.85rem;
min-height: 1em;
}
login-form a {
text-align: center;
font-size: 0.9rem;
color: #555;
}

View File

@ -1,31 +0,0 @@
class LoginForm extends HTMLElement {
connectedCallback(): void {
this.innerHTML = `
<form>
<h2>Login</h2>
<p class="error"></p>
<label>Username<input name="username" autocomplete="username" required></label>
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Login</button>
<a href="/register.html">Create account</a>
</form>
`;
this.querySelector('form')!.addEventListener('submit', async (e: Event) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (res.ok) {
location.href = '/dashboard.html';
} else {
const err = await res.json() as { error: string };
this.querySelector('.error')!.textContent = err.error;
}
});
}
}
customElements.define('login-form', LoginForm);

View File

@ -1,58 +0,0 @@
register-form {
display: block;
max-width: 360px;
margin: 80px auto;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 6px;
}
register-form form {
display: flex;
flex-direction: column;
gap: 1rem;
}
register-form h2 {
margin: 0;
}
register-form label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.9rem;
}
register-form input {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
register-form button {
padding: 0.6rem;
background: #222;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
register-form button:hover {
background: #444;
}
register-form .error {
color: #c00;
font-size: 0.85rem;
min-height: 1em;
}
register-form a {
text-align: center;
font-size: 0.9rem;
color: #555;
}

View File

@ -1,31 +0,0 @@
class RegisterForm extends HTMLElement {
connectedCallback(): void {
this.innerHTML = `
<form>
<h2>Create Account</h2>
<p class="error"></p>
<label>Username<input name="username" autocomplete="username" required></label>
<label>Password<input name="password" type="password" autocomplete="new-password" required></label>
<button type="submit">Register</button>
<a href="/login.html">Already have an account?</a>
</form>
`;
this.querySelector('form')!.addEventListener('submit', async (e: Event) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (res.ok) {
location.href = '/dashboard.html';
} else {
const err = await res.json() as { error: string };
this.querySelector('.error')!.textContent = err.error;
}
});
}
}
customElements.define('register-form', RegisterForm);

View File

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login — Roject</title>
<link rel="stylesheet" href="/components/login-form/login-form.css">
</head>
<body>
<login-form></login-form>
<script type="module" src="/components/login-form/login-form.js"></script>
</body>
</html>

View File

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register — Roject</title>
<link rel="stylesheet" href="/components/register-form/register-form.css">
</head>
<body>
<register-form></register-form>
<script type="module" src="/components/register-form/register-form.js"></script>
</body>
</html>

View File

@ -5,13 +5,6 @@ import { randomUUID } from 'crypto';
const DATA_DIR = path.join(__dirname, '..', '..', 'build', 'data', 'db');
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
export interface User {
id: string;
username: string;
password: string;
created_at: string;
}
export interface Group {
id: string;
name: string;
@ -50,22 +43,6 @@ function write<T>(table: string, rows: T[]): void {
fs.writeFileSync(path.join(DATA_DIR, `${table}.json`), JSON.stringify(rows, null, 2));
}
export const users = {
all: (): User[] => read<User>('users'),
findById: (id: string): User | undefined => read<User>('users').find(u => u.id === id),
findByUsername: (name: string): User | undefined => read<User>('users').find(u => u.username === name),
create(data: Omit<User, 'id' | 'created_at'>): User {
const rows = read<User>('users');
if (rows.find(u => u.username === data.username)) throw new Error('Taken');
const user: User = { id: randomUUID(), ...data, created_at: new Date().toISOString() };
write('users', [...rows, user]);
return user;
},
delete(id: string): void {
write('users', read<User>('users').filter(u => u.id !== id));
}
};
export const groups = {
all: (): Group[] => read<Group>('groups'),
findById: (id: string): Group | undefined => read<Group>('groups').find(g => g.id === id),

View File

@ -1,4 +0,0 @@
export interface EmailSender
{
sendEmail( to: string, subject: string, body: string ): Promise<void>;
}

View File

@ -1,20 +0,0 @@
import { EmailSender } from './EmailSender';
import { SMTPEmailSender } from './SMTPEmailSender';
export class EmailService
{
private static sender: EmailSender = new SMTPEmailSender(
{
host: process.env.SMTP_HOST ?? '',
port: Number( process.env.SMTP_PORT ?? 587 ),
secure: process.env.SMTP_SECURE === 'true',
user: process.env.SMTP_USER ?? '',
pass: process.env.SMTP_PASS ?? '',
from: process.env.SMTP_FROM ?? ''
} );
static async sendEmail( to: string, subject: string, body: string ): Promise<void>
{
return EmailService.sender.sendEmail( to, subject, body );
}
}

View File

@ -1,45 +0,0 @@
import nodemailer from 'nodemailer';
import { EmailSender } from './EmailSender';
export interface SMTPConfig
{
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
from: string;
}
export class SMTPEmailSender implements EmailSender
{
private transporter: nodemailer.Transporter;
private from: string;
constructor( config: SMTPConfig )
{
this.from = config.from;
this.transporter = nodemailer.createTransport(
{
host: config.host,
port: config.port,
secure: config.secure,
auth:
{
user: config.user,
pass: config.pass
}
} );
}
async sendEmail( to: string, subject: string, body: string ): Promise<void>
{
await this.transporter.sendMail(
{
from: this.from,
to,
subject,
text: body
} );
}
}

View File

@ -1,8 +1,7 @@
import express from 'express';
import session from 'express-session';
import cookieParser from 'cookie-parser';
import path from 'path';
import { JsonSessionStore } from './sessionStore';
import authRouter from './routes/auth';
import { jwtMiddleware, requireAuth } from './middleware/auth';
import groupsRouter from './routes/groups';
import projectsRouter from './routes/projects';
import filesRouter from './routes/files';
@ -17,18 +16,11 @@ const app = express();
app.use( express.json() );
app.use( express.text( { type: 'text/plain' } ) );
app.use( session(
{
store: new JsonSessionStore( path.join( __dirname, '..', '..', 'build', 'data', 'storage', 'sessions' ) ),
secret: 'roject-secret-key',
resave: false,
saveUninitialized: false,
cookie: { maxAge: 7 * 24 * 60 * 60 * 1000 }
} ) );
app.use( cookieParser() );
app.use( jwtMiddleware );
app.use( express.static( path.join( __dirname, '..', '..', 'build', 'app' ) ) );
app.use( '/api/auth', authRouter );
app.use( '/api/groups', groupsRouter );
app.use( '/api/projects', projectsRouter );
app.use( '/api/files', filesRouter );
@ -36,10 +28,9 @@ app.use( '/api/locales', localesRouter );
app.use( '/api/layout', layoutRouter );
app.use( '/api/rojos', rojosRouter );
app.get( '/', ( req, res ) =>
{
res.redirect( req.session.userId ? '/dashboard.html' : '/login.html' );
} );
app.get( '/api/auth/me', requireAuth, ( req, res ) => res.json( req.user ) );
app.get( '/', ( _req, res ) => res.redirect( '/dashboard.html' ) );
const PORT = process.env.PORT ? Number( process.env.PORT ) : 3000;
app.listen( PORT, () => console.log( `Roject running on http://localhost:${PORT}` ) );

View File

@ -1,15 +1,58 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
declare module 'express-session' {
interface SessionData {
userId?: string;
username?: string;
const AUTH_HOST = process.env.AUTH_HOST ?? 'https://account.rokojori.com';
const JWT_SECRET = process.env.JWT_SECRET ?? '';
export interface JwtUser {
userId: string;
email: string;
roles: string[];
products: string[];
settings: Record<string, unknown>;
}
declare global {
namespace Express {
interface Request {
user?: JwtUser;
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
if (!req.session.userId) {
res.status(401).json({ error: 'Not authenticated' });
function extractToken( req: Request ): string | undefined {
const cookie = req.cookies?.accessToken as string | undefined;
if ( cookie ) return cookie;
const header = req.headers.authorization;
if ( header?.startsWith( 'Bearer ' ) ) return header.slice( 7 );
return undefined;
}
function isApiRequest( req: Request ): boolean {
return req.path.startsWith( '/api/' );
}
export function jwtMiddleware( req: Request, res: Response, next: NextFunction ): void {
const token = extractToken( req );
if ( !token ) { next(); return; }
try {
req.user = jwt.verify( token, JWT_SECRET ) as JwtUser;
next();
} catch ( err: unknown ) {
if ( err instanceof jwt.TokenExpiredError && !isApiRequest( req ) ) {
const redirect = encodeURIComponent( req.protocol + '://' + req.get( 'host' ) + req.originalUrl );
res.redirect( `${AUTH_HOST}/api/auth/refresh-session?redirect=${redirect}` );
} else {
next();
}
}
}
export function requireAuth( req: Request, res: Response, next: NextFunction ): void {
if ( !req.user ) {
res.status( 401 ).json( { error: 'Not authenticated' } );
return;
}
next();

View File

@ -1,47 +0,0 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { users } from '../db';
import { requireAuth } from '../middleware/auth';
const router = Router();
router.post('/register', async (req, res) => {
const { username, password } = req.body as { username?: string; password?: string };
if (!username || !password) { res.status(400).json({ error: 'Username and password required' }); return; }
try {
const hash = await bcrypt.hash(password, 10);
const user = users.create({ username, password: hash });
req.session.userId = user.id;
req.session.username = user.username;
res.json({ id: user.id, username: user.username });
} catch {
res.status(409).json({ error: 'Username already taken' });
}
});
router.post('/login', async (req, res) => {
const { username, password } = req.body as { username?: string; password?: string };
const user = username ? users.findByUsername(username) : undefined;
if (!user || !password || !(await bcrypt.compare(password, user.password))) {
res.status(401).json({ error: 'Invalid credentials' });
return;
}
req.session.userId = user.id;
req.session.username = user.username;
res.json({ id: user.id, username: user.username });
});
router.post('/logout', (req, res) => {
req.session.destroy(() => res.json({ ok: true }));
});
router.delete('/me', requireAuth, (req, res) => {
users.delete(req.session.userId!);
req.session.destroy(() => res.json({ ok: true }));
});
router.get('/me', requireAuth, (req, res) => {
res.json({ id: req.session.userId, username: req.session.username });
});
export default router;

View File

@ -22,7 +22,7 @@ router.get( '/', ( req, res ) =>
{
const deviceId = req.query.deviceId as string;
if ( !deviceId ) { res.json( null ); return; }
const fp = layoutFilePath( req.session.userId!, deviceId );
const fp = layoutFilePath( req.user!.userId, deviceId );
if ( !fs.existsSync( fp ) ) { res.json( null ); return; }
try { res.json( JSON.parse( fs.readFileSync( fp, 'utf8' ) ) ); }
catch { res.json( null ); }
@ -32,7 +32,7 @@ router.put( '/', ( req, res ) =>
{
const deviceId = req.query.deviceId as string;
if ( !deviceId ) { res.status( 400 ).json( { error: 'Missing deviceId' } ); return; }
const fp = layoutFilePath( req.session.userId!, deviceId );
const fp = layoutFilePath( req.user!.userId, deviceId );
try
{
fs.writeFileSync( fp, JSON.stringify( req.body ), 'utf8' );

View File

@ -12,7 +12,7 @@ router.get('/', (_req, res) => res.json(projects.all()));
router.post('/', (req, res) => {
const { name } = req.body as { name?: string };
if (!name) { res.status(400).json({ error: 'Name required' }); return; }
const project = projects.create({ name, owner_id: req.session.userId! });
const project = projects.create({ name, owner_id: req.user!.userId });
createProjectStorage(project.id);
res.json(project);
});

View File

@ -1,66 +0,0 @@
import session from 'express-session';
import fs from 'fs';
import path from 'path';
interface SessionFile
{
session: session.SessionData;
expires: number;
}
export class JsonSessionStore extends session.Store
{
_dir: string;
constructor( dir: string )
{
super();
this._dir = dir;
if ( ! fs.existsSync( dir ) ) fs.mkdirSync( dir, { recursive: true } );
}
_filePath( sid: string ): string
{
const safe = sid.replace( /[^a-zA-Z0-9_-]/g, '_' );
return path.join( this._dir, safe + '.json' );
}
get( sid: string, callback: ( err: any, session?: session.SessionData ) => void ): void
{
try
{
const fp = this._filePath( sid );
if ( ! fs.existsSync( fp ) ) { callback( null, null ); return; }
const data: SessionFile = JSON.parse( fs.readFileSync( fp, 'utf8' ) );
if ( Date.now() > data.expires ) { fs.unlinkSync( fp ); callback( null, null ); return; }
callback( null, data.session );
}
catch ( e ) { callback( e ); }
}
set( sid: string, sessionData: session.SessionData, callback?: ( err?: any ) => void ): void
{
try
{
const fp = this._filePath( sid );
const expires = sessionData.cookie?.expires
? new Date( sessionData.cookie.expires ).getTime()
: Date.now() + 7 * 24 * 60 * 60 * 1000;
const file: SessionFile = { session: sessionData, expires };
fs.writeFileSync( fp, JSON.stringify( file ), 'utf8' );
callback?.();
}
catch ( e ) { callback?.( e ); }
}
destroy( sid: string, callback?: ( err?: any ) => void ): void
{
try
{
const fp = this._filePath( sid );
if ( fs.existsSync( fp ) ) fs.unlinkSync( fp );
callback?.();
}
catch ( e ) { callback?.( e ); }
}
}