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:
parent
605afa3e66
commit
e89a372152
|
|
@ -10,16 +10,16 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@langchain/core": "^1.2.2",
|
"@langchain/core": "^1.2.2",
|
||||||
"@langchain/openai": "^1.5.4",
|
"@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": "^4.18.2",
|
||||||
"express-session": "^1.17.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"markdown-it": "^14.3.0",
|
"markdown-it": "^14.3.0",
|
||||||
"nodemailer": "^9.0.3"
|
"nodemailer": "^9.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/express-session": "^1.17.10",
|
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
|
|
@ -143,18 +143,10 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/connect": "*",
|
"@types/connect": "*",
|
||||||
|
|
@ -165,18 +157,26 @@
|
||||||
"version": "3.4.38",
|
"version": "3.4.38",
|
||||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/express": {
|
||||||
"version": "4.17.25",
|
"version": "4.17.25",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
|
||||||
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^4.17.33",
|
"@types/express-serve-static-core": "^4.17.33",
|
||||||
|
|
@ -188,7 +188,6 @@
|
||||||
"version": "4.19.8",
|
"version": "4.19.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
|
||||||
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
|
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
|
|
@ -197,35 +196,38 @@
|
||||||
"@types/send": "*"
|
"@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": {
|
"node_modules/@types/http-errors": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.43",
|
"version": "20.19.43",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -246,21 +248,18 @@
|
||||||
"version": "6.15.1",
|
"version": "6.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||||
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
|
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/range-parser": {
|
"node_modules/@types/range-parser": {
|
||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/send": {
|
"node_modules/@types/send": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
|
|
@ -270,7 +269,6 @@
|
||||||
"version": "1.15.10",
|
"version": "1.15.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
||||||
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/http-errors": "*",
|
"@types/http-errors": "*",
|
||||||
|
|
@ -282,7 +280,6 @@
|
||||||
"version": "0.17.6",
|
"version": "0.17.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
||||||
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/mime": "^1",
|
"@types/mime": "^1",
|
||||||
|
|
@ -367,12 +364,6 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.5",
|
"version": "1.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||||
|
|
@ -397,6 +388,12 @@
|
||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
|
@ -465,6 +462,25 @@
|
||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
|
@ -530,6 +546,15 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
|
@ -654,29 +679,6 @@
|
||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/finalhandler": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
|
|
@ -851,6 +853,55 @@
|
||||||
"base64-js": "^1.5.1"
|
"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": {
|
"node_modules/langsmith": {
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.8.0.tgz",
|
||||||
|
|
@ -903,6 +954,48 @@
|
||||||
"uc.micro": "^2.0.0"
|
"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": {
|
"node_modules/make-error": {
|
||||||
"version": "1.3.6",
|
"version": "1.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||||
|
|
@ -1069,15 +1162,6 @@
|
||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/openai": {
|
||||||
"version": "6.45.0",
|
"version": "6.45.0",
|
||||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz",
|
"resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz",
|
||||||
|
|
@ -1199,15 +1283,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
|
@ -1258,6 +1333,18 @@
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/send": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||||
|
|
@ -1477,23 +1564,10 @@
|
||||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,16 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@langchain/core": "^1.2.2",
|
"@langchain/core": "^1.2.2",
|
||||||
"@langchain/openai": "^1.5.4",
|
"@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": "^4.18.2",
|
||||||
"express-session": "^1.17.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"markdown-it": "^14.3.0",
|
"markdown-it": "^14.3.0",
|
||||||
"nodemailer": "^9.0.3"
|
"nodemailer": "^9.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/express-session": "^1.17.10",
|
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,33 @@
|
||||||
interface AuthUser {
|
const AUTH_HOST = 'https://account.rokojori.com';
|
||||||
id: number;
|
const APP_URL = 'https://roject.rokojori.com';
|
||||||
username: string;
|
|
||||||
|
interface JwtUser {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
roles: string[];
|
||||||
|
products: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppNav extends HTMLElement {
|
class AppNav extends HTMLElement {
|
||||||
async connectedCallback(): Promise<void> {
|
async connectedCallback(): Promise<void> {
|
||||||
const res = await fetch('/api/auth/me');
|
const res = await fetch( '/api/auth/me' );
|
||||||
if (!res.ok) { location.href = '/login.html'; return; }
|
|
||||||
const user = await res.json() as AuthUser;
|
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 = `
|
this.innerHTML = `
|
||||||
<nav>
|
<nav>
|
||||||
|
|
@ -18,23 +38,11 @@ class AppNav extends HTMLElement {
|
||||||
<a href="/projects.html">Projects</a>
|
<a href="/projects.html">Projects</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-user">
|
<div class="nav-user">
|
||||||
<span>${user.username}</span>
|
<span>${user.email}</span>
|
||||||
<button class="btn-logout">Logout</button>
|
<a href="${logoutHref}">Log out</a>
|
||||||
<button class="btn-delete">Delete Account</button>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</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 );
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -5,13 +5,6 @@ import { randomUUID } from 'crypto';
|
||||||
const DATA_DIR = path.join(__dirname, '..', '..', 'build', 'data', 'db');
|
const DATA_DIR = path.join(__dirname, '..', '..', 'build', 'data', 'db');
|
||||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
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 {
|
export interface Group {
|
||||||
id: string;
|
id: string;
|
||||||
name: 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));
|
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 = {
|
export const groups = {
|
||||||
all: (): Group[] => read<Group>('groups'),
|
all: (): Group[] => read<Group>('groups'),
|
||||||
findById: (id: string): Group | undefined => read<Group>('groups').find(g => g.id === id),
|
findById: (id: string): Group | undefined => read<Group>('groups').find(g => g.id === id),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export interface EmailSender
|
|
||||||
{
|
|
||||||
sendEmail( to: string, subject: string, body: string ): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
@ -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 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
} );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import session from 'express-session';
|
import cookieParser from 'cookie-parser';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { JsonSessionStore } from './sessionStore';
|
import { jwtMiddleware, requireAuth } from './middleware/auth';
|
||||||
import authRouter from './routes/auth';
|
|
||||||
import groupsRouter from './routes/groups';
|
import groupsRouter from './routes/groups';
|
||||||
import projectsRouter from './routes/projects';
|
import projectsRouter from './routes/projects';
|
||||||
import filesRouter from './routes/files';
|
import filesRouter from './routes/files';
|
||||||
|
|
@ -17,18 +16,11 @@ const app = express();
|
||||||
|
|
||||||
app.use( express.json() );
|
app.use( express.json() );
|
||||||
app.use( express.text( { type: 'text/plain' } ) );
|
app.use( express.text( { type: 'text/plain' } ) );
|
||||||
app.use( session(
|
app.use( cookieParser() );
|
||||||
{
|
app.use( jwtMiddleware );
|
||||||
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( express.static( path.join( __dirname, '..', '..', 'build', 'app' ) ) );
|
app.use( express.static( path.join( __dirname, '..', '..', 'build', 'app' ) ) );
|
||||||
|
|
||||||
app.use( '/api/auth', authRouter );
|
|
||||||
app.use( '/api/groups', groupsRouter );
|
app.use( '/api/groups', groupsRouter );
|
||||||
app.use( '/api/projects', projectsRouter );
|
app.use( '/api/projects', projectsRouter );
|
||||||
app.use( '/api/files', filesRouter );
|
app.use( '/api/files', filesRouter );
|
||||||
|
|
@ -36,10 +28,9 @@ app.use( '/api/locales', localesRouter );
|
||||||
app.use( '/api/layout', layoutRouter );
|
app.use( '/api/layout', layoutRouter );
|
||||||
app.use( '/api/rojos', rojosRouter );
|
app.use( '/api/rojos', rojosRouter );
|
||||||
|
|
||||||
app.get( '/', ( req, res ) =>
|
app.get( '/api/auth/me', requireAuth, ( req, res ) => res.json( req.user ) );
|
||||||
{
|
|
||||||
res.redirect( req.session.userId ? '/dashboard.html' : '/login.html' );
|
app.get( '/', ( _req, res ) => res.redirect( '/dashboard.html' ) );
|
||||||
} );
|
|
||||||
|
|
||||||
const PORT = process.env.PORT ? Number( process.env.PORT ) : 3000;
|
const PORT = process.env.PORT ? Number( process.env.PORT ) : 3000;
|
||||||
app.listen( PORT, () => console.log( `Roject running on http://localhost:${PORT}` ) );
|
app.listen( PORT, () => console.log( `Roject running on http://localhost:${PORT}` ) );
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,58 @@
|
||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
declare module 'express-session' {
|
const AUTH_HOST = process.env.AUTH_HOST ?? 'https://account.rokojori.com';
|
||||||
interface SessionData {
|
const JWT_SECRET = process.env.JWT_SECRET ?? '';
|
||||||
userId?: string;
|
|
||||||
username?: string;
|
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 {
|
function extractToken( req: Request ): string | undefined {
|
||||||
if (!req.session.userId) {
|
const cookie = req.cookies?.accessToken as string | undefined;
|
||||||
res.status(401).json({ error: 'Not authenticated' });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -22,7 +22,7 @@ router.get( '/', ( req, res ) =>
|
||||||
{
|
{
|
||||||
const deviceId = req.query.deviceId as string;
|
const deviceId = req.query.deviceId as string;
|
||||||
if ( !deviceId ) { res.json( null ); return; }
|
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; }
|
if ( !fs.existsSync( fp ) ) { res.json( null ); return; }
|
||||||
try { res.json( JSON.parse( fs.readFileSync( fp, 'utf8' ) ) ); }
|
try { res.json( JSON.parse( fs.readFileSync( fp, 'utf8' ) ) ); }
|
||||||
catch { res.json( null ); }
|
catch { res.json( null ); }
|
||||||
|
|
@ -32,7 +32,7 @@ router.put( '/', ( req, res ) =>
|
||||||
{
|
{
|
||||||
const deviceId = req.query.deviceId as string;
|
const deviceId = req.query.deviceId as string;
|
||||||
if ( !deviceId ) { res.status( 400 ).json( { error: 'Missing deviceId' } ); return; }
|
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
|
try
|
||||||
{
|
{
|
||||||
fs.writeFileSync( fp, JSON.stringify( req.body ), 'utf8' );
|
fs.writeFileSync( fp, JSON.stringify( req.body ), 'utf8' );
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ router.get('/', (_req, res) => res.json(projects.all()));
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
const { name } = req.body as { name?: string };
|
const { name } = req.body as { name?: string };
|
||||||
if (!name) { res.status(400).json({ error: 'Name required' }); return; }
|
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);
|
createProjectStorage(project.id);
|
||||||
res.json(project);
|
res.json(project);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 ); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue