feat: implement user authentication and license management system
- Added schema for users, licenses, and license hostnames in the database. - Created storage utility for reading and writing JSON files. - Developed user service for user registration, authentication, and retrieval. - Implemented authentication middleware to protect routes. - Built LicenseCard component to display license details. - Created SiteNav component for navigation with user authentication status. - Established AuthContext for managing authentication state and actions. - Developed Home page to display available plugins. - Created LicenseManager page for managing licenses with forms for creation and verification. - Implemented PluginDetail page to show detailed information about a specific plugin. - Added utility functions for date formatting.
This commit is contained in:
10
.env.example
Normal file
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# MariaDB / MySQL connection
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASSWORD=secret
|
||||||
|
DB_NAME=siti_plugin_repo
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
JWT_SECRET=please-change-me
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
68
dist/assets/index-BlY_enN1.js
vendored
Normal file
68
dist/assets/index-BlY_enN1.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-CgskK80a.css
vendored
Normal file
1
dist/assets/index-CgskK80a.css
vendored
Normal file
File diff suppressed because one or more lines are too long
16
dist/index.html
vendored
Normal file
16
dist/index.html
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="nl">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Siti Plugin Repo</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-BlY_enN1.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-CgskK80a.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
services:
|
services:
|
||||||
siti-plugin-repo:
|
siti-plugin-repo:
|
||||||
image: siti-plugin-repo:latest
|
image: siti-plugin-repo:local
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
pull_policy: never
|
||||||
container_name: siti-plugin-repo
|
container_name: siti-plugin-repo
|
||||||
ports:
|
ports:
|
||||||
- "${HOST_PORT:-8080}:${PORT:-3001}"
|
- "${HOST_PORT:-8080}:${PORT:-3001}"
|
||||||
environment:
|
environment:
|
||||||
PORT: "${PORT:-3001}"
|
PORT: "${PORT:-3001}"
|
||||||
CACHE_TTL_MS: "${CACHE_TTL_MS:-600000}"
|
CACHE_TTL_MS: "${CACHE_TTL_MS:-600000}"
|
||||||
|
DB_HOST: "${DB_HOST:-127.0.0.1}"
|
||||||
|
DB_PORT: "${DB_PORT:-3306}"
|
||||||
|
DB_USER: "${DB_USER:-sitiapp}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD:-sitiapp}"
|
||||||
|
DB_NAME: "${DB_NAME:-siti_plugin_repo}"
|
||||||
|
JWT_SECRET: "${JWT_SECRET:-change-me}"
|
||||||
|
JWT_EXPIRES_IN: "${JWT_EXPIRES_IN:-7d}"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
216
package-lock.json
generated
216
package-lock.json
generated
@@ -8,7 +8,10 @@
|
|||||||
"name": "siti-plugin-repo",
|
"name": "siti-plugin-repo",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"mysql2": "^3.16.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.23.1"
|
"react-router-dom": "^6.23.1"
|
||||||
@@ -1117,6 +1120,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/aws-ssl-profiles": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.19",
|
"version": "2.9.19",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||||
@@ -1126,6 +1137,14 @@
|
|||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.4",
|
"version": "1.20.4",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||||
@@ -1195,6 +1214,11 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
"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",
|
||||||
@@ -1305,6 +1329,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/denque": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -1335,6 +1367,14 @@
|
|||||||
"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==",
|
||||||
|
"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",
|
||||||
@@ -1567,6 +1607,14 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/generate-function": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"is-property": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gensync": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
@@ -1687,6 +1735,11 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-property": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -1716,6 +1769,97 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"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/semver": {
|
||||||
|
"version": "7.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"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==",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
|
||||||
|
},
|
||||||
|
"node_modules/long": {
|
||||||
|
"version": "5.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||||
|
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -1736,6 +1880,20 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lru.min": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==",
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.0.0",
|
||||||
|
"deno": ">=1.30.0",
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wellwelwel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -1803,6 +1961,51 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/mysql2": {
|
||||||
|
"version": "3.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.2.tgz",
|
||||||
|
"integrity": "sha512-JsqBpYNy7pH20lGfPuSyRSIcCxSeAIwxWADpV64nP9KeyN3ZKpHZgjKXuBKsh7dH6FbOvf1bOgoVKjSUPXRMTw==",
|
||||||
|
"dependencies": {
|
||||||
|
"aws-ssl-profiles": "^1.1.2",
|
||||||
|
"denque": "^2.1.0",
|
||||||
|
"generate-function": "^2.3.1",
|
||||||
|
"iconv-lite": "^0.7.2",
|
||||||
|
"long": "^5.3.2",
|
||||||
|
"lru.min": "^1.1.3",
|
||||||
|
"named-placeholders": "^1.1.6",
|
||||||
|
"seq-queue": "^0.0.5",
|
||||||
|
"sqlstring": "^2.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mysql2/node_modules/iconv-lite": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/named-placeholders": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||||
|
"dependencies": {
|
||||||
|
"lru.min": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -2135,6 +2338,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||||
},
|
},
|
||||||
|
"node_modules/seq-queue": {
|
||||||
|
"version": "0.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||||
|
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
|
||||||
|
},
|
||||||
"node_modules/serve-static": {
|
"node_modules/serve-static": {
|
||||||
"version": "1.16.3",
|
"version": "1.16.3",
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||||
@@ -2231,6 +2439,14 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sqlstring": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
"start": "node server/index.js"
|
"start": "node server/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"mysql2": "^3.16.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.23.1"
|
"react-router-dom": "^6.23.1"
|
||||||
|
|||||||
366
server/index.js
366
server/index.js
@@ -1,218 +1,83 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import {
|
||||||
import fs from "fs/promises";
|
fetchCommits,
|
||||||
|
fetchManifest,
|
||||||
|
fetchRepo,
|
||||||
|
fetchReleases,
|
||||||
|
normalizeRepoInput,
|
||||||
|
parseRepoEntry,
|
||||||
|
readRepos
|
||||||
|
} from "./lib/pluginService.js";
|
||||||
|
import {
|
||||||
|
buildLicensePayload,
|
||||||
|
createLicense,
|
||||||
|
findLicenseByKey,
|
||||||
|
getLicenseById,
|
||||||
|
listLicensesByUser,
|
||||||
|
touchLicenseHostname
|
||||||
|
} from "./lib/licenseService.js";
|
||||||
|
import { HOST, PATHS, PORT } from "./lib/config.js";
|
||||||
|
import { ensureSchema } from "./lib/schema.js";
|
||||||
|
import { authenticateUser, registerUser } from "./lib/userService.js";
|
||||||
|
import { requireAuth } from "./middleware/auth.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
app.use(express.json());
|
||||||
const CACHE_TTL_MS = Number(process.env.CACHE_TTL_MS || 10 * 60 * 1000);
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
const rootDir = path.resolve(__dirname, "..");
|
|
||||||
const distDir = path.join(rootDir, "dist");
|
|
||||||
const reposFile = path.join(__dirname, "repos.json");
|
|
||||||
|
|
||||||
const cache = new Map();
|
|
||||||
|
|
||||||
function parseRepoEntry(entry) {
|
|
||||||
// support legacy string entries and object entries
|
|
||||||
if (typeof entry === "string") {
|
|
||||||
return {
|
|
||||||
provider: "github",
|
|
||||||
ownerRepo: entry,
|
|
||||||
baseUrl: "https://github.com"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const provider = (entry.provider || "github").toLowerCase();
|
|
||||||
const ownerRepo = entry.repo || entry.ownerRepo || "";
|
|
||||||
const baseUrl = entry.baseUrl || (provider === "github" ? "https://github.com" : entry.baseUrl || "");
|
|
||||||
return { provider, ownerRepo, baseUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readRepos() {
|
|
||||||
const content = await fs.readFile(reposFile, "utf-8");
|
|
||||||
const parsed = JSON.parse(content);
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCached(key) {
|
|
||||||
const entry = cache.get(key);
|
|
||||||
if (!entry) return null;
|
|
||||||
if (Date.now() > entry.expiresAt) {
|
|
||||||
cache.delete(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return entry.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCached(key, value) {
|
|
||||||
cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJson(url, cacheKey, opts = {}) {
|
|
||||||
const cached = getCached(cacheKey);
|
|
||||||
if (cached) return cached;
|
|
||||||
const headers = {
|
|
||||||
"User-Agent": "siti-plugin-repo",
|
|
||||||
Accept: "application/json"
|
|
||||||
};
|
|
||||||
// prefer GitHub API accept header when talking to github.com/api
|
|
||||||
if (!opts.provider || opts.provider === "github") {
|
|
||||||
headers.Accept = "application/vnd.github+json";
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, { headers });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`${opts.provider || "git"} request failed for ${url}`);
|
|
||||||
}
|
|
||||||
const data = await response.json().catch(async () => {
|
|
||||||
// attempt to read text for non-json responses
|
|
||||||
const text = await response.text();
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text);
|
await ensureSchema();
|
||||||
} catch {
|
} catch (error) {
|
||||||
return null;
|
console.error("Kon database schema niet initialiseren:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post("/api/auth/register", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, name, email, password } = req.body || {};
|
||||||
|
if (!username || !name || !email || !password) {
|
||||||
|
return res.status(400).json({ error: "Vul gebruikersnaam, naam, e-mail en wachtwoord in." });
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
return res.status(400).json({ error: "Wachtwoord moet minimaal 8 karakters zijn." });
|
||||||
|
}
|
||||||
|
const { user, token } = await registerUser({
|
||||||
|
username: String(username).trim(),
|
||||||
|
name: String(name).trim(),
|
||||||
|
email: String(email).trim().toLowerCase(),
|
||||||
|
password: String(password)
|
||||||
|
});
|
||||||
|
res.status(201).json({ token, user });
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ER_DUP_ENTRY") {
|
||||||
|
const field = error.meta === "EMAIL" ? "e-mailadres" : "gebruikersnaam";
|
||||||
|
return res.status(409).json({ error: `Dit ${field} is al in gebruik.` });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: "Registratie mislukt." });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setCached(cacheKey, data);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchRepo(entry) {
|
app.post("/api/auth/login", async (req, res) => {
|
||||||
const { provider, ownerRepo, baseUrl } = parseRepoEntry(entry);
|
try {
|
||||||
const cacheKey = `repo:${provider}:${ownerRepo}`;
|
const { identifier, password } = req.body || {};
|
||||||
const cached = getCached(cacheKey);
|
if (!identifier || !password) {
|
||||||
if (cached) return cached;
|
return res.status(400).json({ error: "Vul gebruikersnaam/e-mail en wachtwoord in." });
|
||||||
|
}
|
||||||
|
const { user, token } = await authenticateUser(String(identifier).trim(), String(password));
|
||||||
|
res.json({ token, user });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.meta === "INVALID_CREDENTIALS" ? "Onjuiste inloggegevens." : "Login mislukt.";
|
||||||
|
res.status(401).json({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let data;
|
app.get("/api/auth/me", requireAuth, (req, res) => {
|
||||||
if (provider === "gitea") {
|
res.json({ user: req.user });
|
||||||
const url = `${baseUrl.replace(/\/$/, "")}/api/v1/repos/${ownerRepo}`;
|
});
|
||||||
data = await fetchJson(url, `repo-raw:${provider}:${ownerRepo}`, { provider });
|
|
||||||
const mapped = {
|
|
||||||
fullName: data.full_name || `${ownerRepo}`,
|
|
||||||
name: data.name || ownerRepo.split("/")[1] || ownerRepo,
|
|
||||||
description: data.description || null,
|
|
||||||
repoUrl: `${baseUrl.replace(/\/$/, "")}/${ownerRepo}`,
|
|
||||||
defaultBranch: data.default_branch || data.default_branch || "main",
|
|
||||||
stars: data.stargazers_count || data.watchers || 0,
|
|
||||||
forks: data.forks_count || data.forks || 0,
|
|
||||||
issues: data.open_issues_count || 0,
|
|
||||||
updatedAt: data.updated_at || data.updated || null,
|
|
||||||
topics: data.topics || []
|
|
||||||
};
|
|
||||||
setCached(cacheKey, mapped);
|
|
||||||
return mapped;
|
|
||||||
}
|
|
||||||
|
|
||||||
// default: github
|
|
||||||
data = await fetchJson(`https://api.github.com/repos/${ownerRepo}`, `repo-raw:github:${ownerRepo}`, { provider: "github" });
|
|
||||||
const mapped = {
|
|
||||||
fullName: data.full_name,
|
|
||||||
name: data.name,
|
|
||||||
description: data.description,
|
|
||||||
repoUrl: data.html_url,
|
|
||||||
defaultBranch: data.default_branch,
|
|
||||||
stars: data.stargazers_count,
|
|
||||||
forks: data.forks_count,
|
|
||||||
issues: data.open_issues_count,
|
|
||||||
updatedAt: data.updated_at,
|
|
||||||
topics: data.topics || []
|
|
||||||
};
|
|
||||||
setCached(cacheKey, mapped);
|
|
||||||
return mapped;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchManifest(entry, defaultBranch) {
|
|
||||||
const { provider, ownerRepo, baseUrl } = parseRepoEntry(entry);
|
|
||||||
const cacheKey = `manifest:${provider}:${ownerRepo}`;
|
|
||||||
const cached = getCached(cacheKey);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const branches = [defaultBranch, "main", "master"].filter(Boolean);
|
|
||||||
const [owner, repo] = ownerRepo.split("/");
|
|
||||||
for (const branch of branches) {
|
|
||||||
let url;
|
|
||||||
if (provider === "gitea") {
|
|
||||||
url = `${baseUrl.replace(/\/$/, "")}/repos/${owner}/${repo}/raw/${branch}/manifest.json`;
|
|
||||||
} else {
|
|
||||||
url = `https://raw.githubusercontent.com/${ownerRepo}/${branch}/manifest.json`;
|
|
||||||
}
|
|
||||||
const response = await fetch(url, { headers: { "User-Agent": "siti-plugin-repo" } });
|
|
||||||
if (response.ok) {
|
|
||||||
const manifest = await response.json().catch(() => null);
|
|
||||||
setCached(cacheKey, manifest);
|
|
||||||
return manifest;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchReleases(entry) {
|
|
||||||
const { provider, ownerRepo, baseUrl } = parseRepoEntry(entry);
|
|
||||||
if (provider === "gitea") {
|
|
||||||
const url = `${baseUrl.replace(/\/$/, "")}/api/v1/repos/${ownerRepo}/releases?limit=5`;
|
|
||||||
const data = await fetchJson(url, `releases:${provider}:${ownerRepo}`, { provider });
|
|
||||||
return Array.isArray(data)
|
|
||||||
? data.map((release) => ({
|
|
||||||
tag: release.tag_name || release.name,
|
|
||||||
name: release.name || release.tag_name,
|
|
||||||
url: release.html_url || `${baseUrl.replace(/\/$/, "")}/${ownerRepo}/releases`,
|
|
||||||
publishedAt: release.published_at || release.created_at
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await fetchJson(
|
|
||||||
`https://api.github.com/repos/${ownerRepo}/releases?per_page=5`,
|
|
||||||
`releases:github:${ownerRepo}`,
|
|
||||||
{ provider: "github" }
|
|
||||||
);
|
|
||||||
return Array.isArray(data)
|
|
||||||
? data.map((release) => ({
|
|
||||||
tag: release.tag_name,
|
|
||||||
name: release.name || release.tag_name,
|
|
||||||
url: release.html_url,
|
|
||||||
publishedAt: release.published_at
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchCommits(entry) {
|
|
||||||
const { provider, ownerRepo, baseUrl } = parseRepoEntry(entry);
|
|
||||||
if (provider === "gitea") {
|
|
||||||
const url = `${baseUrl.replace(/\/$/, "")}/api/v1/repos/${ownerRepo}/commits?limit=5`;
|
|
||||||
const data = await fetchJson(url, `commits:${provider}:${ownerRepo}`, { provider });
|
|
||||||
return Array.isArray(data)
|
|
||||||
? data.map((commit) => ({
|
|
||||||
sha: commit.sha,
|
|
||||||
message: commit.commit?.message || commit.message,
|
|
||||||
author: commit.commit?.author?.name || commit.author?.name,
|
|
||||||
date: commit.commit?.author?.date || commit.author?.date,
|
|
||||||
url: commit.html_url || `${baseUrl.replace(/\/$/, "")}/${ownerRepo}/commit/${commit.sha}`
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await fetchJson(
|
|
||||||
`https://api.github.com/repos/${ownerRepo}/commits?per_page=5`,
|
|
||||||
`commits:github:${ownerRepo}`,
|
|
||||||
{ provider: "github" }
|
|
||||||
);
|
|
||||||
return Array.isArray(data)
|
|
||||||
? data.map((commit) => ({
|
|
||||||
sha: commit.sha,
|
|
||||||
message: commit.commit?.message,
|
|
||||||
author: commit.commit?.author?.name,
|
|
||||||
date: commit.commit?.author?.date,
|
|
||||||
url: commit.html_url
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get("/api/plugins", async (_req, res) => {
|
app.get("/api/plugins", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const repos = await readRepos();
|
const repos = await readRepos(PATHS.reposFile);
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
repos.map(async (repo) => {
|
repos.map(async (repo) => {
|
||||||
try {
|
try {
|
||||||
@@ -225,13 +90,19 @@ app.get("/api/plugins", async (_req, res) => {
|
|||||||
fullName: parsed.ownerRepo,
|
fullName: parsed.ownerRepo,
|
||||||
name: parsed.ownerRepo.split("/")[1] || parsed.ownerRepo,
|
name: parsed.ownerRepo.split("/")[1] || parsed.ownerRepo,
|
||||||
description: "Kon gegevens niet ophalen.",
|
description: "Kon gegevens niet ophalen.",
|
||||||
repoUrl: parsed.provider === "gitea" ? `${parsed.baseUrl.replace(/\/$/, "")}/${parsed.ownerRepo}` : `https://github.com/${parsed.ownerRepo}`,
|
repoUrl:
|
||||||
|
parsed.provider === "gitea"
|
||||||
|
? `${parsed.baseUrl.replace(/\/$/, "")}/${parsed.ownerRepo}`
|
||||||
|
: `https://github.com/${parsed.ownerRepo}`,
|
||||||
stars: 0,
|
stars: 0,
|
||||||
forks: 0,
|
forks: 0,
|
||||||
issues: 0,
|
issues: 0,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
topics: [],
|
topics: [],
|
||||||
manifest: null
|
manifest: null,
|
||||||
|
provider: parsed.provider,
|
||||||
|
ownerRepo: parsed.ownerRepo,
|
||||||
|
baseUrl: parsed.baseUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -272,12 +143,91 @@ app.get("/api/plugins/:owner/:repo", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(express.static(distDir));
|
app.get("/api/licenses", requireAuth, async (req, res) => {
|
||||||
app.get("*", (_req, res) => {
|
try {
|
||||||
res.sendFile(path.join(distDir, "index.html"));
|
const payload = await listLicensesByUser(req.user.id);
|
||||||
|
res.json({
|
||||||
|
count: payload.length,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
items: payload
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: "Kon licenties niet laden." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/licenses", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = req.body || {};
|
||||||
|
const repoInput =
|
||||||
|
typeof body.repo === "object"
|
||||||
|
? body.repo
|
||||||
|
: typeof body.plugin === "object"
|
||||||
|
? body.plugin
|
||||||
|
: body.repo || body.ownerRepo || body.fullName || body.plugin;
|
||||||
|
const repoEntry =
|
||||||
|
normalizeRepoInput(repoInput, {
|
||||||
|
repo: body.ownerRepo || body.fullName || body.repo,
|
||||||
|
provider: body.provider,
|
||||||
|
baseUrl: body.baseUrl
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
if (!repoEntry) {
|
||||||
|
return res.status(400).json({ error: "Kies een plugin om de licentie aan te koppelen." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchRepo(repoEntry);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ error: "Kon plugin gegevens niet ophalen." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await createLicense(req.user.id, {
|
||||||
|
label: body.label?.trim(),
|
||||||
|
note: body.note?.trim(),
|
||||||
|
repo: repoEntry
|
||||||
|
});
|
||||||
|
res.status(201).json(payload);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: "Kon licentie niet aanmaken." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/licenses/verify", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { key, hostname } = req.body || {};
|
||||||
|
if (!key || !hostname) {
|
||||||
|
return res.status(400).json({ valid: false, error: "Licentiecode en hostname zijn verplicht." });
|
||||||
|
}
|
||||||
|
const license = await findLicenseByKey(String(key).trim());
|
||||||
|
if (!license) {
|
||||||
|
return res.status(404).json({ valid: false, error: "Licentie niet gevonden." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await touchLicenseHostname(license, hostname);
|
||||||
|
if (!result.ok) {
|
||||||
|
const status = result.conflict ? 403 : 400;
|
||||||
|
return res.status(status).json({ valid: false, error: result.error, boundHostname: license.primary_hostname });
|
||||||
|
}
|
||||||
|
|
||||||
|
const freshLicense = await getLicenseById(license.id);
|
||||||
|
const payload = await buildLicensePayload(freshLicense);
|
||||||
|
res.json({
|
||||||
|
valid: true,
|
||||||
|
hostname: payload.primaryHostname,
|
||||||
|
boundNow: !!result.boundNow,
|
||||||
|
license: payload
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ valid: false, error: "Validatie mislukt." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(express.static(PATHS.distDir));
|
||||||
|
app.get("*", (_req, res) => {
|
||||||
|
res.sendFile(path.join(PATHS.distDir, "index.html"));
|
||||||
});
|
});
|
||||||
|
|
||||||
const HOST = process.env.HOST || "::";
|
|
||||||
app.listen(PORT, HOST, () => {
|
app.listen(PORT, HOST, () => {
|
||||||
console.log(`Server draait op http://${HOST}:${PORT}`);
|
console.log(`Server draait op http://${HOST}:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
21
server/lib/cache.js
Normal file
21
server/lib/cache.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { CACHE_TTL_MS } from "./config.js";
|
||||||
|
|
||||||
|
const cache = new Map();
|
||||||
|
|
||||||
|
export function getCached(key) {
|
||||||
|
const entry = cache.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCached(key, value, ttlMs = CACHE_TTL_MS) {
|
||||||
|
cache.set(key, { value, expiresAt: Date.now() + ttlMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCached(key) {
|
||||||
|
cache.delete(key);
|
||||||
|
}
|
||||||
29
server/lib/config.js
Normal file
29
server/lib/config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const serverDir = path.resolve(__dirname, "..");
|
||||||
|
const rootDir = path.resolve(serverDir, "..");
|
||||||
|
|
||||||
|
export const PATHS = {
|
||||||
|
serverDir,
|
||||||
|
rootDir,
|
||||||
|
distDir: path.join(rootDir, "dist"),
|
||||||
|
reposFile: path.join(serverDir, "repos.json")
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PORT = process.env.PORT || 3001;
|
||||||
|
export const HOST = process.env.HOST || "::";
|
||||||
|
export const CACHE_TTL_MS = Number(process.env.CACHE_TTL_MS || 10 * 60 * 1000);
|
||||||
|
|
||||||
|
export const DB_CONFIG = {
|
||||||
|
host: process.env.DB_HOST || "127.0.0.1",
|
||||||
|
port: Number(process.env.DB_PORT || 3306),
|
||||||
|
user: process.env.DB_USER || "root",
|
||||||
|
password: process.env.DB_PASSWORD || "",
|
||||||
|
database: process.env.DB_NAME || "siti_plugin_repo"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
|
||||||
|
export const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d";
|
||||||
11
server/lib/db.js
Normal file
11
server/lib/db.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import mysql from "mysql2/promise";
|
||||||
|
import { DB_CONFIG } from "./config.js";
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
...DB_CONFIG,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: Number(process.env.DB_POOL_SIZE || 10),
|
||||||
|
namedPlaceholders: false
|
||||||
|
});
|
||||||
|
|
||||||
|
export default pool;
|
||||||
192
server/lib/licenseService.js
Normal file
192
server/lib/licenseService.js
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import db from "./db.js";
|
||||||
|
import { fetchManifest, fetchRepo, normalizeRepoInput } from "./pluginService.js";
|
||||||
|
|
||||||
|
function toIso(value) {
|
||||||
|
return value ? new Date(value).toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateLicenseKey() {
|
||||||
|
const raw = crypto.randomBytes(8).toString("hex").toUpperCase();
|
||||||
|
const segments = raw.match(/.{1,4}/g) || [];
|
||||||
|
return `SITI-${segments.slice(0, 4).join("-")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHostname(value) {
|
||||||
|
return value ? value.trim().toLowerCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLicensesByUser(userId) {
|
||||||
|
const [rows] = await db.query(`SELECT * FROM licenses WHERE user_id = ? ORDER BY created_at DESC`, [userId]);
|
||||||
|
return Promise.all(rows.map((row) => buildLicensePayload(row)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLicenseById(id) {
|
||||||
|
const [rows] = await db.query(`SELECT * FROM licenses WHERE id = ? LIMIT 1`, [id]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findLicenseByKey(key) {
|
||||||
|
const [rows] = await db.query(`SELECT * FROM licenses WHERE license_key = ? LIMIT 1`, [key]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLicense(userId, { label, note, repo }) {
|
||||||
|
const repoEntry = normalizeRepoInput(repo);
|
||||||
|
if (!repoEntry) {
|
||||||
|
const error = new Error("Ongeldige plugin referentie.");
|
||||||
|
error.meta = "INVALID_REPO";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let licenseId = null;
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const licenseKey = generateLicenseKey();
|
||||||
|
try {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO licenses (
|
||||||
|
id, user_id, license_key, label, note,
|
||||||
|
repo_provider, repo_name, repo_base_url,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
licenseKey,
|
||||||
|
label || repoEntry.repo,
|
||||||
|
note || null,
|
||||||
|
repoEntry.provider || "github",
|
||||||
|
repoEntry.repo,
|
||||||
|
repoEntry.baseUrl || (repoEntry.provider === "github" ? "https://github.com" : null)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
licenseId = id;
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ER_DUP_ENTRY") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!licenseId) {
|
||||||
|
throw new Error("Kon licentie niet opslaan.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildLicensePayload(await getLicenseById(licenseId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildLicensePayload(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
const repoEntry = normalizeRepoInput({
|
||||||
|
repo: row.repo_name,
|
||||||
|
provider: row.repo_provider,
|
||||||
|
baseUrl: row.repo_base_url
|
||||||
|
});
|
||||||
|
const [hostnameRows] = await db.query(
|
||||||
|
`SELECT hostname, normalized, first_seen_at, last_seen_at, hits
|
||||||
|
FROM license_hostnames WHERE license_id = ? ORDER BY first_seen_at ASC`,
|
||||||
|
[row.id]
|
||||||
|
);
|
||||||
|
const hostnames = hostnameRows.map((entry) => ({
|
||||||
|
hostname: entry.hostname,
|
||||||
|
normalized: entry.normalized,
|
||||||
|
firstSeenAt: toIso(entry.first_seen_at),
|
||||||
|
lastSeenAt: toIso(entry.last_seen_at),
|
||||||
|
hits: entry.hits
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!repoEntry) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
key: row.license_key,
|
||||||
|
label: row.label,
|
||||||
|
note: row.note,
|
||||||
|
hostnames,
|
||||||
|
createdAt: toIso(row.created_at),
|
||||||
|
updatedAt: toIso(row.updated_at),
|
||||||
|
lastVersionCheckAt: toIso(row.last_version_check_at),
|
||||||
|
primaryHostname: row.primary_hostname,
|
||||||
|
primaryHostnameNormalized: row.primary_hostname_normalized,
|
||||||
|
repoFullName: row.repo_name,
|
||||||
|
repoUrl: null,
|
||||||
|
pluginName: row.label,
|
||||||
|
pluginVersion: null,
|
||||||
|
repo: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await fetchRepo(repoEntry);
|
||||||
|
const manifest = await fetchManifest(repoEntry, info.defaultBranch).catch(() => null);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
key: row.license_key,
|
||||||
|
label: row.label,
|
||||||
|
note: row.note,
|
||||||
|
createdAt: toIso(row.created_at),
|
||||||
|
updatedAt: toIso(row.updated_at),
|
||||||
|
lastVersionCheckAt: toIso(row.last_version_check_at),
|
||||||
|
primaryHostname: row.primary_hostname,
|
||||||
|
primaryHostnameNormalized: row.primary_hostname_normalized,
|
||||||
|
repoFullName: info.fullName,
|
||||||
|
repoUrl: info.repoUrl,
|
||||||
|
pluginName: manifest?.plugin_name || info.name || row.label,
|
||||||
|
pluginVersion: manifest?.version || null,
|
||||||
|
repo: repoEntry,
|
||||||
|
hostnames
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
key: row.license_key,
|
||||||
|
label: row.label,
|
||||||
|
note: row.note,
|
||||||
|
createdAt: toIso(row.created_at),
|
||||||
|
updatedAt: toIso(row.updated_at),
|
||||||
|
lastVersionCheckAt: toIso(row.last_version_check_at),
|
||||||
|
primaryHostname: row.primary_hostname,
|
||||||
|
primaryHostnameNormalized: row.primary_hostname_normalized,
|
||||||
|
repoFullName: row.repo_name,
|
||||||
|
repoUrl: repoEntry?.baseUrl ? `${repoEntry.baseUrl.replace(/\/$/, "")}/${row.repo_name}` : null,
|
||||||
|
pluginName: row.label,
|
||||||
|
pluginVersion: null,
|
||||||
|
repo: repoEntry,
|
||||||
|
hostnames
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function touchLicenseHostname(license, hostname) {
|
||||||
|
const normalizedHost = normalizeHostname(hostname);
|
||||||
|
if (!normalizedHost) {
|
||||||
|
return { ok: false, error: "Ongeldige hostname." };
|
||||||
|
}
|
||||||
|
const trimmed = hostname.trim();
|
||||||
|
if (!license.primary_hostname_normalized) {
|
||||||
|
await db.query(
|
||||||
|
`UPDATE licenses SET primary_hostname = ?, primary_hostname_normalized = ?, last_version_check_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = ?`,
|
||||||
|
[trimmed, normalizedHost, license.id]
|
||||||
|
);
|
||||||
|
} else if (license.primary_hostname_normalized !== normalizedHost) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
conflict: true,
|
||||||
|
error: `Licentie hoort bij ${license.primary_hostname || "een andere site"}.`
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
await db.query(`UPDATE licenses SET last_version_check_at = NOW(), updated_at = NOW() WHERE id = ?`, [license.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO license_hostnames (license_id, hostname, normalized, first_seen_at, last_seen_at, hits)
|
||||||
|
VALUES (?, ?, ?, NOW(), NOW(), 1)
|
||||||
|
ON DUPLICATE KEY UPDATE hostname = VALUES(hostname), last_seen_at = NOW(), hits = hits + 1`,
|
||||||
|
[license.id, trimmed, normalizedHost]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ok: true, boundNow: !license.primary_hostname_normalized, normalized: normalizedHost };
|
||||||
|
}
|
||||||
204
server/lib/pluginService.js
Normal file
204
server/lib/pluginService.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { readJsonFile } from "./storage.js";
|
||||||
|
import { getCached, setCached } from "./cache.js";
|
||||||
|
|
||||||
|
export function parseRepoEntry(entry) {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
return {
|
||||||
|
provider: "github",
|
||||||
|
ownerRepo: entry,
|
||||||
|
baseUrl: "https://github.com"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const provider = (entry?.provider || "github").toLowerCase();
|
||||||
|
const ownerRepo = entry?.repo || entry?.ownerRepo || "";
|
||||||
|
const baseUrl = entry?.baseUrl || (provider === "github" ? "https://github.com" : "");
|
||||||
|
return { provider, ownerRepo, baseUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRepoInput(input, extras = {}) {
|
||||||
|
if (typeof input === "string") {
|
||||||
|
return normalizeRepoInput({ repo: input }, extras);
|
||||||
|
}
|
||||||
|
const source = input && typeof input === "object" ? input : {};
|
||||||
|
const repo = source.repo || source.ownerRepo || source.fullName || extras.repo;
|
||||||
|
if (!repo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const provider = (source.provider || extras.provider || "github").toLowerCase();
|
||||||
|
const normalized = {
|
||||||
|
repo,
|
||||||
|
provider
|
||||||
|
};
|
||||||
|
const baseUrl = source.baseUrl || extras.baseUrl || (provider === "github" ? "https://github.com" : undefined);
|
||||||
|
if (baseUrl) {
|
||||||
|
normalized.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readRepos(reposFile) {
|
||||||
|
const parsed = await readJsonFile(reposFile, []);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url, cacheKey, opts = {}) {
|
||||||
|
const cached = getCached(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
const headers = {
|
||||||
|
"User-Agent": "siti-plugin-repo",
|
||||||
|
Accept: "application/json"
|
||||||
|
};
|
||||||
|
if (!opts.provider || opts.provider === "github") {
|
||||||
|
headers.Accept = "application/vnd.github+json";
|
||||||
|
}
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${opts.provider || "git"} request failed for ${url}`);
|
||||||
|
}
|
||||||
|
const data = await response.json().catch(async () => {
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setCached(cacheKey, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRepo(entry) {
|
||||||
|
const { provider, ownerRepo, baseUrl } = parseRepoEntry(entry);
|
||||||
|
const cacheKey = `repo:${provider}:${ownerRepo}`;
|
||||||
|
const cached = getCached(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
let data;
|
||||||
|
if (provider === "gitea") {
|
||||||
|
const url = `${baseUrl.replace(/\/$/, "")}/api/v1/repos/${ownerRepo}`;
|
||||||
|
data = await fetchJson(url, `repo-raw:${provider}:${ownerRepo}`, { provider });
|
||||||
|
const mapped = {
|
||||||
|
fullName: data.full_name || `${ownerRepo}`,
|
||||||
|
name: data.name || ownerRepo.split("/")[1] || ownerRepo,
|
||||||
|
description: data.description || null,
|
||||||
|
repoUrl: `${baseUrl.replace(/\/$/, "")}/${ownerRepo}`,
|
||||||
|
defaultBranch: data.default_branch || "main",
|
||||||
|
stars: data.stargazers_count || data.watchers || 0,
|
||||||
|
forks: data.forks_count || data.forks || 0,
|
||||||
|
issues: data.open_issues_count || 0,
|
||||||
|
updatedAt: data.updated_at || data.updated || null,
|
||||||
|
topics: data.topics || [],
|
||||||
|
provider,
|
||||||
|
ownerRepo,
|
||||||
|
baseUrl
|
||||||
|
};
|
||||||
|
setCached(cacheKey, mapped);
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
data = await fetchJson(`https://api.github.com/repos/${ownerRepo}`, `repo-raw:github:${ownerRepo}`, { provider: "github" });
|
||||||
|
const mapped = {
|
||||||
|
fullName: data.full_name,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
repoUrl: data.html_url,
|
||||||
|
defaultBranch: data.default_branch,
|
||||||
|
stars: data.stargazers_count,
|
||||||
|
forks: data.forks_count,
|
||||||
|
issues: data.open_issues_count,
|
||||||
|
updatedAt: data.updated_at,
|
||||||
|
topics: data.topics || [],
|
||||||
|
provider,
|
||||||
|
ownerRepo,
|
||||||
|
baseUrl: "https://github.com"
|
||||||
|
};
|
||||||
|
setCached(cacheKey, mapped);
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchManifest(entry, defaultBranch) {
|
||||||
|
const { provider, ownerRepo, baseUrl } = parseRepoEntry(entry);
|
||||||
|
const cacheKey = `manifest:${provider}:${ownerRepo}`;
|
||||||
|
const cached = getCached(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const branches = [defaultBranch, "main", "master"].filter(Boolean);
|
||||||
|
const [owner, repo] = ownerRepo.split("/");
|
||||||
|
for (const branch of branches) {
|
||||||
|
let url;
|
||||||
|
if (provider === "gitea") {
|
||||||
|
url = `${baseUrl.replace(/\/$/, "")}/repos/${owner}/${repo}/raw/${branch}/manifest.json`;
|
||||||
|
} else {
|
||||||
|
url = `https://raw.githubusercontent.com/${ownerRepo}/${branch}/manifest.json`;
|
||||||
|
}
|
||||||
|
const response = await fetch(url, { headers: { "User-Agent": "siti-plugin-repo" } });
|
||||||
|
if (response.ok) {
|
||||||
|
const manifest = await response.json().catch(() => null);
|
||||||
|
setCached(cacheKey, manifest);
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReleases(entry) {
|
||||||
|
const { provider, ownerRepo, baseUrl } = parseRepoEntry(entry);
|
||||||
|
if (provider === "gitea") {
|
||||||
|
const url = `${baseUrl.replace(/\/$/, "")}/api/v1/repos/${ownerRepo}/releases?limit=5`;
|
||||||
|
const data = await fetchJson(url, `releases:${provider}:${ownerRepo}`, { provider });
|
||||||
|
return Array.isArray(data)
|
||||||
|
? data.map((release) => ({
|
||||||
|
tag: release.tag_name || release.name,
|
||||||
|
name: release.name || release.tag_name,
|
||||||
|
url: release.html_url || `${baseUrl.replace(/\/$/, "")}/${ownerRepo}/releases`,
|
||||||
|
publishedAt: release.published_at || release.created_at
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fetchJson(
|
||||||
|
`https://api.github.com/repos/${ownerRepo}/releases?per_page=5`,
|
||||||
|
`releases:github:${ownerRepo}`,
|
||||||
|
{ provider: "github" }
|
||||||
|
);
|
||||||
|
return Array.isArray(data)
|
||||||
|
? data.map((release) => ({
|
||||||
|
tag: release.tag_name,
|
||||||
|
name: release.name || release.tag_name,
|
||||||
|
url: release.html_url,
|
||||||
|
publishedAt: release.published_at
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCommits(entry) {
|
||||||
|
const { provider, ownerRepo, baseUrl } = parseRepoEntry(entry);
|
||||||
|
if (provider === "gitea") {
|
||||||
|
const url = `${baseUrl.replace(/\/$/, "")}/api/v1/repos/${ownerRepo}/commits?limit=5`;
|
||||||
|
const data = await fetchJson(url, `commits:${provider}:${ownerRepo}`, { provider });
|
||||||
|
return Array.isArray(data)
|
||||||
|
? data.map((commit) => ({
|
||||||
|
sha: commit.sha,
|
||||||
|
message: commit.commit?.message || commit.message,
|
||||||
|
author: commit.commit?.author?.name || commit.author?.name,
|
||||||
|
date: commit.commit?.author?.date || commit.author?.date,
|
||||||
|
url: commit.html_url || `${baseUrl.replace(/\/$/, "")}/${ownerRepo}/commit/${commit.sha}`
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fetchJson(
|
||||||
|
`https://api.github.com/repos/${ownerRepo}/commits?per_page=5`,
|
||||||
|
`commits:github:${ownerRepo}`,
|
||||||
|
{ provider: "github" }
|
||||||
|
);
|
||||||
|
return Array.isArray(data)
|
||||||
|
? data.map((commit) => ({
|
||||||
|
sha: commit.sha,
|
||||||
|
message: commit.commit?.message,
|
||||||
|
author: commit.commit?.author?.name,
|
||||||
|
date: commit.commit?.author?.date,
|
||||||
|
url: commit.html_url
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
57
server/lib/schema.js
Normal file
57
server/lib/schema.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import db from "./db.js";
|
||||||
|
|
||||||
|
async function createUsersTable() {
|
||||||
|
await db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
email VARCHAR(120) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createLicensesTable() {
|
||||||
|
await db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS licenses (
|
||||||
|
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
license_key VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
label VARCHAR(255),
|
||||||
|
note TEXT,
|
||||||
|
repo_provider VARCHAR(32) NOT NULL,
|
||||||
|
repo_name VARCHAR(255) NOT NULL,
|
||||||
|
repo_base_url VARCHAR(255),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
last_version_check_at DATETIME NULL,
|
||||||
|
primary_hostname VARCHAR(255) NULL,
|
||||||
|
primary_hostname_normalized VARCHAR(255) NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createLicenseHostnamesTable() {
|
||||||
|
await db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS license_hostnames (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
license_id CHAR(36) NOT NULL,
|
||||||
|
hostname VARCHAR(255) NOT NULL,
|
||||||
|
normalized VARCHAR(255) NOT NULL,
|
||||||
|
first_seen_at DATETIME NOT NULL,
|
||||||
|
last_seen_at DATETIME NOT NULL,
|
||||||
|
hits INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
UNIQUE KEY unique_license_host (license_id, normalized),
|
||||||
|
FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureSchema() {
|
||||||
|
await createUsersTable();
|
||||||
|
await createLicensesTable();
|
||||||
|
await createLicenseHostnamesTable();
|
||||||
|
}
|
||||||
19
server/lib/storage.js
Normal file
19
server/lib/storage.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import fs from "fs/promises";
|
||||||
|
|
||||||
|
export async function readJsonFile(filePath, fallback = []) {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, "utf-8");
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
return parsed ?? fallback;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "ENOENT") {
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(fallback, null, 2));
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeJsonFile(filePath, data) {
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
69
server/lib/userService.js
Normal file
69
server/lib/userService.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import db from "./db.js";
|
||||||
|
import { JWT_SECRET, JWT_EXPIRES_IN } from "./config.js";
|
||||||
|
|
||||||
|
function serializeUser(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
name: row.name,
|
||||||
|
email: row.email,
|
||||||
|
createdAt: row.created_at ? new Date(row.created_at).toISOString() : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function signToken(userId) {
|
||||||
|
return jwt.sign({ sub: userId }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerUser({ username, name, email, password }) {
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
try {
|
||||||
|
const [result] = await db.query(
|
||||||
|
`INSERT INTO users (username, name, email, password_hash) VALUES (?, ?, ?, ?)`,
|
||||||
|
[username, name, email, passwordHash]
|
||||||
|
);
|
||||||
|
const user = await getUserById(result.insertId);
|
||||||
|
const token = signToken(user.id);
|
||||||
|
return { user, token };
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ER_DUP_ENTRY") {
|
||||||
|
const message = error.sqlMessage || "Duplicate";
|
||||||
|
if (message.includes("username")) {
|
||||||
|
error.meta = "USERNAME";
|
||||||
|
} else if (message.includes("email")) {
|
||||||
|
error.meta = "EMAIL";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticateUser(identifier, password) {
|
||||||
|
const [rows] = await db.query(
|
||||||
|
`SELECT * FROM users WHERE username = ? OR email = ? LIMIT 1`,
|
||||||
|
[identifier, identifier]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
const err = new Error("Onjuiste inloggegevens.");
|
||||||
|
err.meta = "INVALID_CREDENTIALS";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const row = rows[0];
|
||||||
|
const ok = await bcrypt.compare(password, row.password_hash);
|
||||||
|
if (!ok) {
|
||||||
|
const err = new Error("Onjuiste inloggegevens.");
|
||||||
|
err.meta = "INVALID_CREDENTIALS";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const user = serializeUser(row);
|
||||||
|
const token = signToken(user.id);
|
||||||
|
return { user, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(id) {
|
||||||
|
const [rows] = await db.query(`SELECT id, username, name, email, created_at FROM users WHERE id = ? LIMIT 1`, [id]);
|
||||||
|
return serializeUser(rows[0]);
|
||||||
|
}
|
||||||
23
server/middleware/auth.js
Normal file
23
server/middleware/auth.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { JWT_SECRET } from "../lib/config.js";
|
||||||
|
import { getUserById } from "../lib/userService.js";
|
||||||
|
|
||||||
|
export async function requireAuth(req, res, next) {
|
||||||
|
const authHeader = req.headers.authorization || "";
|
||||||
|
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : null;
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ error: "Inloggen vereist." });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, JWT_SECRET);
|
||||||
|
const user = await getUserById(payload.sub);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: "Gebruiker niet gevonden." });
|
||||||
|
}
|
||||||
|
req.user = user;
|
||||||
|
req.token = token;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(401).json({ error: "Ongeldige of verlopen token." });
|
||||||
|
}
|
||||||
|
}
|
||||||
303
src/App.css
303
src/App.css
@@ -10,6 +10,59 @@
|
|||||||
padding: 48px 8vw 64px;
|
padding: 48px 8vw 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
font-weight: 700;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user-guest {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #4338ca;
|
||||||
|
border-color: #c7d2fe;
|
||||||
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -46,6 +99,12 @@
|
|||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
.cta {
|
.cta {
|
||||||
background: #4f46e5;
|
background: #4f46e5;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -55,6 +114,11 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
box-shadow: 0 8px 24px rgba(79, 70, 229, 0.2);
|
box-shadow: 0 8px 24px rgba(79, 70, 229, 0.2);
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta:hover {
|
.cta:hover {
|
||||||
@@ -62,6 +126,26 @@
|
|||||||
box-shadow: 0 12px 30px rgba(79, 70, 229, 0.3);
|
box-shadow: 0 12px 30px rgba(79, 70, 229, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
border: 1px solid #c7d2fe;
|
||||||
|
color: #4338ca;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-small {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
@@ -139,15 +223,6 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost {
|
|
||||||
border: 1px solid #c7d2fe;
|
|
||||||
color: #4338ca;
|
|
||||||
padding: 8px 14px;
|
|
||||||
border-radius: 999px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state {
|
.state {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -161,6 +236,16 @@
|
|||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.state.success {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state.inline {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
margin-top: 48px;
|
margin-top: 48px;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
@@ -225,6 +310,144 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.license-meta-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-forms {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select,
|
||||||
|
.field textarea {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-subtitle {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-note {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-detail-list {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-list ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-list li strong {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-pill {
|
||||||
|
border: 1px solid #cbd5f5;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab.active {
|
||||||
|
background: #eef2ff;
|
||||||
|
border-color: #c7d2fe;
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.app {
|
.app {
|
||||||
padding: 40px 6vw 56px;
|
padding: 40px 6vw 56px;
|
||||||
@@ -241,6 +464,28 @@
|
|||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
border-color: rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: #cbd5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
background: rgba(79, 70, 229, 0.2);
|
||||||
|
border-color: rgba(79, 70, 229, 0.4);
|
||||||
|
color: #faf5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user {
|
||||||
|
color: #cbd5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user-guest {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: #cbd5f5;
|
color: #cbd5f5;
|
||||||
}
|
}
|
||||||
@@ -277,6 +522,36 @@
|
|||||||
color: #e0e7ff;
|
color: #e0e7ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-tab {
|
||||||
|
border-color: #312e81;
|
||||||
|
background: #1e1b4b;
|
||||||
|
color: #cbd5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab.active {
|
||||||
|
background: rgba(79, 70, 229, 0.2);
|
||||||
|
border-color: rgba(79, 70, 229, 0.6);
|
||||||
|
color: #faf5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint,
|
||||||
|
.license-meta-bar,
|
||||||
|
.license-note {
|
||||||
|
color: #cbd5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select,
|
||||||
|
.field textarea {
|
||||||
|
background: #1e1b4b;
|
||||||
|
border-color: #312e81;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-list li {
|
||||||
|
color: #cbd5f5;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-list div {
|
.detail-list div {
|
||||||
color: #cbd5f5;
|
color: #cbd5f5;
|
||||||
}
|
}
|
||||||
@@ -300,6 +575,16 @@
|
|||||||
color: #fee2e2;
|
color: #fee2e2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.state.success {
|
||||||
|
background: #14532d;
|
||||||
|
color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-pill {
|
||||||
|
border-color: #4f46e5;
|
||||||
|
color: #cbd5f5;
|
||||||
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|||||||
217
src/App.jsx
217
src/App.jsx
@@ -1,221 +1,18 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { Route, Routes } from "react-router-dom";
|
||||||
import { Link, Route, Routes, useParams } from "react-router-dom";
|
import SiteNav from "./components/SiteNav.jsx";
|
||||||
|
import Home from "./pages/Home.jsx";
|
||||||
|
import PluginDetail from "./pages/PluginDetail.jsx";
|
||||||
|
import LicenseManager from "./pages/LicenseManager.jsx";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
function Home() {
|
|
||||||
const [plugins, setPlugins] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [lastSync, setLastSync] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadPlugins() {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/plugins");
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Kon plugins niet laden");
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
setPlugins(data.items || []);
|
|
||||||
setLastSync(data.updatedAt);
|
|
||||||
} catch (err) {
|
|
||||||
setError("Laden van GitHub data is mislukt.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadPlugins();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<header className="hero">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">WordPress plugin overzicht</p>
|
|
||||||
<h1>Siti Plugin Repo</h1>
|
|
||||||
<p className="subtitle">Al je publieke WordPress plugins op één plek.</p>
|
|
||||||
</div>
|
|
||||||
<a className="cta" href="https://github.com/SitiWeb" target="_blank" rel="noreferrer">
|
|
||||||
GitHub SitiWeb
|
|
||||||
</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="grid">
|
|
||||||
{loading && <div className="state">Bezig met laden…</div>}
|
|
||||||
{error && <div className="state error">{error}</div>}
|
|
||||||
{!loading && !error && plugins.length === 0 && (
|
|
||||||
<div className="state">Geen repositories gevonden.</div>
|
|
||||||
)}
|
|
||||||
{plugins.map((plugin) => {
|
|
||||||
const displayName = plugin.manifest?.plugin_name || plugin.name;
|
|
||||||
const description = plugin.manifest?.description || plugin.description;
|
|
||||||
return (
|
|
||||||
<article className="card" key={plugin.fullName}>
|
|
||||||
<div className="card-header">
|
|
||||||
<h2>{displayName}</h2>
|
|
||||||
<span className="pill">{plugin.fullName}</span>
|
|
||||||
</div>
|
|
||||||
<p>{description}</p>
|
|
||||||
<div className="meta">
|
|
||||||
<span>★ {plugin.stars}</span>
|
|
||||||
<span>Forks {plugin.forks}</span>
|
|
||||||
<span>Issues {plugin.issues}</span>
|
|
||||||
</div>
|
|
||||||
{plugin.topics.length > 0 && (
|
|
||||||
<div className="topics">
|
|
||||||
{plugin.topics.slice(0, 4).map((topic) => (
|
|
||||||
<span className="topic" key={topic}>{topic}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="actions">
|
|
||||||
<Link className="link" to={`/plugin/${plugin.fullName}`}>
|
|
||||||
Bekijk details →
|
|
||||||
</Link>
|
|
||||||
<a className="ghost" href={plugin.repoUrl} target="_blank" rel="noreferrer">
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer className="footer">
|
|
||||||
<span>
|
|
||||||
Laatste sync: {lastSync ? new Date(lastSync).toLocaleString("nl-NL") : "-"}
|
|
||||||
</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PluginDetail() {
|
|
||||||
const { owner, repo } = useParams();
|
|
||||||
const [data, setData] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadDetail() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/plugins/${owner}/${repo}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Kon details niet laden");
|
|
||||||
}
|
|
||||||
const detail = await response.json();
|
|
||||||
setData(detail);
|
|
||||||
} catch (err) {
|
|
||||||
setError("Laden van plugin details is mislukt.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadDetail();
|
|
||||||
}, [owner, repo]);
|
|
||||||
|
|
||||||
const manifest = data?.manifest;
|
|
||||||
const displayName = manifest?.plugin_name || data?.name || repo;
|
|
||||||
const description = manifest?.description || data?.description;
|
|
||||||
const author = manifest?.author || "-";
|
|
||||||
const version = manifest?.version || "-";
|
|
||||||
|
|
||||||
const releases = useMemo(() => data?.releases || [], [data]);
|
|
||||||
const commits = useMemo(() => data?.commits || [], [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<header className="detail-hero">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Plugin details</p>
|
|
||||||
<h1>{displayName}</h1>
|
|
||||||
<p className="subtitle">{description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="detail-actions">
|
|
||||||
<Link className="ghost" to="/">← Terug</Link>
|
|
||||||
{data?.repoUrl && (
|
|
||||||
<a className="cta" href={data.repoUrl} target="_blank" rel="noreferrer">
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{loading && <div className="state">Bezig met laden…</div>}
|
|
||||||
{error && <div className="state error">{error}</div>}
|
|
||||||
|
|
||||||
{!loading && !error && data && (
|
|
||||||
<section className="detail-grid">
|
|
||||||
<div className="card">
|
|
||||||
<h2>Manifest</h2>
|
|
||||||
<div className="detail-list">
|
|
||||||
<div>
|
|
||||||
<span>Naam</span>
|
|
||||||
<strong>{displayName}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Versie</span>
|
|
||||||
<strong>{version}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Auteur</span>
|
|
||||||
<strong>{author}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Repository</span>
|
|
||||||
<strong>{data.fullName}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{manifest?.author_url && (
|
|
||||||
<a className="link" href={manifest.author_url} target="_blank" rel="noreferrer">
|
|
||||||
Auteur website →
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
<h2>Releases</h2>
|
|
||||||
{releases.length === 0 && <p>Geen releases gevonden.</p>}
|
|
||||||
<ul className="list">
|
|
||||||
{releases.map((release) => (
|
|
||||||
<li key={release.tag}>
|
|
||||||
<a href={release.url} target="_blank" rel="noreferrer">
|
|
||||||
{release.name}
|
|
||||||
</a>
|
|
||||||
<span>{release.publishedAt ? new Date(release.publishedAt).toLocaleDateString("nl-NL") : "-"}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
<h2>Recente commits</h2>
|
|
||||||
{commits.length === 0 && <p>Geen commits gevonden.</p>}
|
|
||||||
<ul className="list">
|
|
||||||
{commits.map((commit) => (
|
|
||||||
<li key={commit.sha}>
|
|
||||||
<a href={commit.url} target="_blank" rel="noreferrer">
|
|
||||||
{commit.message?.split("\n")[0] || commit.sha.slice(0, 7)}
|
|
||||||
</a>
|
|
||||||
<span>{commit.author || "-"}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
|
<SiteNav />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/plugin/:owner/:repo" element={<PluginDetail />} />
|
<Route path="/plugin/:owner/:repo" element={<PluginDetail />} />
|
||||||
|
<Route path="/licenses" element={<LicenseManager />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
65
src/components/LicenseCard.jsx
Normal file
65
src/components/LicenseCard.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { formatDate, formatDateTime } from "../utils/dates.js";
|
||||||
|
|
||||||
|
export default function LicenseCard({ license }) {
|
||||||
|
const hostnames = license.hostnames || [];
|
||||||
|
const primaryHostname = license.primaryHostname || "Nog niet gekoppeld";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="card license-card">
|
||||||
|
<div className="license-card-header">
|
||||||
|
<div>
|
||||||
|
<h3>{license.pluginName || license.label || "Licentie"}</h3>
|
||||||
|
<p className="license-subtitle">{license.repoFullName || "-"}</p>
|
||||||
|
</div>
|
||||||
|
<span className="pill">{license.key}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-list license-detail-list">
|
||||||
|
<div>
|
||||||
|
<span>Versie</span>
|
||||||
|
<strong>{license.pluginVersion || "-"}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Hostname</span>
|
||||||
|
<strong>{primaryHostname}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Aangemaakt</span>
|
||||||
|
<strong>{formatDate(license.createdAt)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Laatste check</span>
|
||||||
|
<strong>{formatDateTime(license.lastVersionCheckAt)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{license.note && <p className="license-note">Notitie: {license.note}</p>}
|
||||||
|
|
||||||
|
{hostnames.length > 0 && (
|
||||||
|
<div className="host-list">
|
||||||
|
<p className="hint">Hostnames</p>
|
||||||
|
<ul>
|
||||||
|
{hostnames.map((host) => (
|
||||||
|
<li key={`${host.hostname}-${host.firstSeenAt || host.lastSeenAt}`}>
|
||||||
|
<div>
|
||||||
|
<strong>{host.hostname}</strong>
|
||||||
|
<span>{host.hits || 0} checks</span>
|
||||||
|
</div>
|
||||||
|
<span>{formatDateTime(host.lastSeenAt)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="license-links">
|
||||||
|
{license.repoUrl && (
|
||||||
|
<a className="link" href={license.repoUrl} target="_blank" rel="noreferrer">
|
||||||
|
Repository →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{license.pluginName && <span className="ghost-pill">{license.pluginName}</span>}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/components/SiteNav.jsx
Normal file
29
src/components/SiteNav.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Link, NavLink } from "react-router-dom";
|
||||||
|
import { useAuth } from "../context/AuthContext.jsx";
|
||||||
|
|
||||||
|
export default function SiteNav() {
|
||||||
|
const linkClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="site-nav">
|
||||||
|
<Link className="nav-logo" to="/">Siti Plugin Repo</Link>
|
||||||
|
<div className="nav-links">
|
||||||
|
<NavLink to="/" end className={linkClass}>Plugins</NavLink>
|
||||||
|
<NavLink to="/licenses" className={linkClass}>Licenties</NavLink>
|
||||||
|
</div>
|
||||||
|
<div className="nav-user">
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<span className="nav-user-name">Hallo, {user.name}</span>
|
||||||
|
<button className="ghost ghost-small" type="button" onClick={logout}>
|
||||||
|
Uitloggen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="nav-user-guest">Niet ingelogd</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/context/AuthContext.jsx
Normal file
140
src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
function getStoredToken() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem("authToken") || "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistToken(token) {
|
||||||
|
try {
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem("authToken", token);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("authToken");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore storage issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [token, setToken] = useState(getStoredToken);
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(Boolean(getStoredToken()));
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setUser(null);
|
||||||
|
setLoading(false);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
fetch("/api/auth/me", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || "Kon sessie niet verifiëren.");
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setUser(data.user || null);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setUser(null);
|
||||||
|
setToken("");
|
||||||
|
persistToken("");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
async function login(credentials) {
|
||||||
|
setError(null);
|
||||||
|
const response = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(credentials)
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || "Login mislukt.");
|
||||||
|
}
|
||||||
|
setToken(data.token);
|
||||||
|
persistToken(data.token);
|
||||||
|
setUser(data.user);
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(credentials) {
|
||||||
|
setError(null);
|
||||||
|
const response = await fetch("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(credentials)
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || "Registratie mislukt.");
|
||||||
|
}
|
||||||
|
setToken(data.token);
|
||||||
|
persistToken(data.token);
|
||||||
|
setUser(data.user);
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
setToken("");
|
||||||
|
persistToken("");
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authFetch = useMemo(() => {
|
||||||
|
return (url, options = {}) => {
|
||||||
|
const headers = {
|
||||||
|
...(options.headers || {}),
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
|
};
|
||||||
|
return fetch(url, { ...options, headers });
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
authFetch
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAuth moet binnen een AuthProvider gebruikt worden");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -2,12 +2,15 @@ import React from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "./App.jsx";
|
import App from "./App.jsx";
|
||||||
|
import { AuthProvider } from "./context/AuthContext.jsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")).render(
|
createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
89
src/pages/Home.jsx
Normal file
89
src/pages/Home.jsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [plugins, setPlugins] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [lastSync, setLastSync] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadPlugins() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/plugins");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Kon plugins niet laden");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setPlugins(data.items || []);
|
||||||
|
setLastSync(data.updatedAt);
|
||||||
|
} catch (err) {
|
||||||
|
setError("Laden van GitHub data is mislukt.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPlugins();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">WordPress plugin overzicht</p>
|
||||||
|
<h1>Siti Plugin Repo</h1>
|
||||||
|
<p className="subtitle">Al je publieke WordPress plugins op één plek.</p>
|
||||||
|
</div>
|
||||||
|
<a className="cta" href="https://github.com/SitiWeb" target="_blank" rel="noreferrer">
|
||||||
|
GitHub SitiWeb
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid">
|
||||||
|
{loading && <div className="state">Bezig met laden…</div>}
|
||||||
|
{error && <div className="state error">{error}</div>}
|
||||||
|
{!loading && !error && plugins.length === 0 && (
|
||||||
|
<div className="state">Geen repositories gevonden.</div>
|
||||||
|
)}
|
||||||
|
{plugins.map((plugin) => {
|
||||||
|
const displayName = plugin.manifest?.plugin_name || plugin.name;
|
||||||
|
const description = plugin.manifest?.description || plugin.description;
|
||||||
|
return (
|
||||||
|
<article className="card" key={plugin.fullName}>
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>{displayName}</h2>
|
||||||
|
<span className="pill">{plugin.fullName}</span>
|
||||||
|
</div>
|
||||||
|
<p>{description}</p>
|
||||||
|
<div className="meta">
|
||||||
|
<span>★ {plugin.stars}</span>
|
||||||
|
<span>Forks {plugin.forks}</span>
|
||||||
|
<span>Issues {plugin.issues}</span>
|
||||||
|
</div>
|
||||||
|
{plugin.topics.length > 0 && (
|
||||||
|
<div className="topics">
|
||||||
|
{plugin.topics.slice(0, 4).map((topic) => (
|
||||||
|
<span className="topic" key={topic}>{topic}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="actions">
|
||||||
|
<Link className="link" to={`/plugin/${plugin.fullName}`}>
|
||||||
|
Bekijk details →
|
||||||
|
</Link>
|
||||||
|
<a className="ghost" href={plugin.repoUrl} target="_blank" rel="noreferrer">
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="footer">
|
||||||
|
<span>Laatste sync: {lastSync ? new Date(lastSync).toLocaleString("nl-NL") : "-"}</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
476
src/pages/LicenseManager.jsx
Normal file
476
src/pages/LicenseManager.jsx
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import LicenseCard from "../components/LicenseCard.jsx";
|
||||||
|
import { formatDateTime } from "../utils/dates.js";
|
||||||
|
import { useAuth } from "../context/AuthContext.jsx";
|
||||||
|
|
||||||
|
export default function LicenseManager() {
|
||||||
|
const { user, token, authFetch, login, register: registerUser, loading: authLoading } = useAuth();
|
||||||
|
const [licenses, setLicenses] = useState([]);
|
||||||
|
const [plugins, setPlugins] = useState([]);
|
||||||
|
const [selectedPluginId, setSelectedPluginId] = useState("");
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [lastSync, setLastSync] = useState(null);
|
||||||
|
const [formStatus, setFormStatus] = useState(null);
|
||||||
|
const [verifyStatus, setVerifyStatus] = useState(null);
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
const [verifyKey, setVerifyKey] = useState("");
|
||||||
|
const [verifyHostname, setVerifyHostname] = useState("");
|
||||||
|
|
||||||
|
const isAuthenticated = Boolean(user && token);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadPlugins() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/plugins");
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || "Kon plugins niet laden.");
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
setPlugins(data.items || []);
|
||||||
|
const firstPlugin = data.items?.[0];
|
||||||
|
if (firstPlugin) {
|
||||||
|
const defaultId = firstPlugin.ownerRepo || firstPlugin.fullName;
|
||||||
|
setSelectedPluginId((prev) => prev || defaultId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err.message || "Kon plugins niet laden.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadPlugins();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshLicenses = useCallback(
|
||||||
|
async (showStatus = true) => {
|
||||||
|
if (!token) {
|
||||||
|
setLicenses([]);
|
||||||
|
setLastSync(null);
|
||||||
|
if (showStatus) {
|
||||||
|
setFormStatus({ variant: "error", message: "Log in om licenties te beheren." });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (showStatus) {
|
||||||
|
setFormStatus(null);
|
||||||
|
}
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
const response = await authFetch("/api/licenses");
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error("Sessie verlopen, log opnieuw in.");
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || "Kon licenties niet laden.");
|
||||||
|
}
|
||||||
|
setLicenses(data.items || []);
|
||||||
|
setLastSync(data.updatedAt);
|
||||||
|
} catch (err) {
|
||||||
|
if (showStatus) {
|
||||||
|
setFormStatus({ variant: "error", message: err.message });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authFetch, token]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshLicenses(false);
|
||||||
|
}, [refreshLicenses]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedPluginId && plugins.length > 0) {
|
||||||
|
const fallback = plugins[0].ownerRepo || plugins[0].fullName;
|
||||||
|
setSelectedPluginId(fallback);
|
||||||
|
}
|
||||||
|
}, [plugins, selectedPluginId]);
|
||||||
|
|
||||||
|
const selectedPlugin = useMemo(
|
||||||
|
() => plugins.find((plugin) => (plugin.ownerRepo || plugin.fullName) === selectedPluginId) || null,
|
||||||
|
[plugins, selectedPluginId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedLicenses = useMemo(() => {
|
||||||
|
const getTime = (value) => (value ? new Date(value).getTime() : 0);
|
||||||
|
return [...licenses].sort((a, b) => getTime(b.createdAt) - getTime(a.createdAt));
|
||||||
|
}, [licenses]);
|
||||||
|
|
||||||
|
async function handleCreateLicense(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
setFormStatus(null);
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setFormStatus({ variant: "error", message: "Log in om een licentie aan te maken." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedPlugin) {
|
||||||
|
setFormStatus({ variant: "error", message: "Selecteer een plugin." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
label:
|
||||||
|
label.trim() ||
|
||||||
|
selectedPlugin.manifest?.plugin_name ||
|
||||||
|
selectedPlugin.name ||
|
||||||
|
selectedPlugin.fullName,
|
||||||
|
note: note.trim() || undefined,
|
||||||
|
repo: {
|
||||||
|
repo: selectedPlugin.ownerRepo || selectedPlugin.fullName,
|
||||||
|
provider: selectedPlugin.provider || "github",
|
||||||
|
baseUrl: selectedPlugin.baseUrl
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const response = await authFetch("/api/licenses", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error("Sessie verlopen, log opnieuw in.");
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || "Licentie aanmaken mislukt.");
|
||||||
|
}
|
||||||
|
setLicenses((prev) => [data, ...prev]);
|
||||||
|
setFormStatus({ variant: "success", message: "Licentie aangemaakt." });
|
||||||
|
setLabel("");
|
||||||
|
setNote("");
|
||||||
|
} catch (err) {
|
||||||
|
setFormStatus({ variant: "error", message: err.message });
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerifyLicense(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
setVerifyStatus(null);
|
||||||
|
if (!verifyKey.trim() || !verifyHostname.trim()) {
|
||||||
|
setVerifyStatus({ ok: false, message: "Vul zowel licentiecode als hostname in." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVerifying(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/licenses/verify", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
key: verifyKey.trim(),
|
||||||
|
hostname: verifyHostname.trim()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || "Controle mislukt.");
|
||||||
|
}
|
||||||
|
setVerifyStatus({ ok: true, data });
|
||||||
|
if (data.license) {
|
||||||
|
setLicenses((prev) => prev.map((license) => (license.key === data.license.key ? data.license : license)));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setVerifyStatus({ ok: false, message: err.message });
|
||||||
|
} finally {
|
||||||
|
setVerifying(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogin = useCallback(
|
||||||
|
async (credentials) => {
|
||||||
|
await login(credentials);
|
||||||
|
await refreshLicenses(false);
|
||||||
|
},
|
||||||
|
[login, refreshLicenses]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRegister = useCallback(
|
||||||
|
async (payload) => {
|
||||||
|
await registerUser(payload);
|
||||||
|
await refreshLicenses(false);
|
||||||
|
},
|
||||||
|
[registerUser, refreshLicenses]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoadingState = loading || (authLoading && Boolean(token));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Licentiebeheer</p>
|
||||||
|
<h1>Licenties</h1>
|
||||||
|
<p className="subtitle">
|
||||||
|
Maak licenties voor iedere plugin en beheer welke hostname de licentie daadwerkelijk gebruikt.
|
||||||
|
</p>
|
||||||
|
<p className="hint">
|
||||||
|
Een licentie is geldig voor één hostname. De eerste hostname die controleert wordt automatisch gekoppeld als
|
||||||
|
licentiehouder.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="ghost" type="button" onClick={() => refreshLicenses()} disabled={refreshing || !isAuthenticated}>
|
||||||
|
{refreshing ? "Vernieuwen…" : "Vernieuw lijst"}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="license-meta-bar">
|
||||||
|
<span>Actieve licenties: {licenses.length}</span>
|
||||||
|
<span>Laatste update: {formatDateTime(lastSync)}</span>
|
||||||
|
{user && <span>Ingelogd als: {user.email}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingState && <div className="state">Bezig met laden…</div>}
|
||||||
|
{error && <div className="state error">{error}</div>}
|
||||||
|
|
||||||
|
{!isLoadingState && !error && (
|
||||||
|
<>
|
||||||
|
<section className="license-forms">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<article className="card">
|
||||||
|
<h2>Nieuwe licentie</h2>
|
||||||
|
<p className="hint">Kies een plugin en genereer direct een licentiesleutel.</p>
|
||||||
|
<form className="form-grid" onSubmit={handleCreateLicense}>
|
||||||
|
<label className="field">
|
||||||
|
<span>Plugin</span>
|
||||||
|
<select
|
||||||
|
value={selectedPluginId}
|
||||||
|
onChange={(event) => setSelectedPluginId(event.target.value)}
|
||||||
|
disabled={plugins.length === 0}
|
||||||
|
>
|
||||||
|
{plugins.map((plugin) => {
|
||||||
|
const id = plugin.ownerRepo || plugin.fullName;
|
||||||
|
return (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{plugin.manifest?.plugin_name || plugin.name} ({plugin.fullName})
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Label (optioneel)</span>
|
||||||
|
<input
|
||||||
|
value={label}
|
||||||
|
onChange={(event) => setLabel(event.target.value)}
|
||||||
|
placeholder="Naam of klant"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Notitie</span>
|
||||||
|
<textarea
|
||||||
|
value={note}
|
||||||
|
onChange={(event) => setNote(event.target.value)}
|
||||||
|
placeholder="Bijv. contactpersoon of extra info"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="cta" type="submit" disabled={creating || !selectedPlugin}>
|
||||||
|
{creating ? "Aanmaken…" : "Licentie aanmaken"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{formStatus && (
|
||||||
|
<div className={`state inline ${formStatus.variant === "error" ? "error" : "success"}`}>
|
||||||
|
{formStatus.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
) : (
|
||||||
|
<AuthForms onLogin={handleLogin} onRegister={handleRegister} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<article className="card">
|
||||||
|
<h2>Test of valideer</h2>
|
||||||
|
<p className="hint">
|
||||||
|
Gebruik dit formulier zoals de plugin dat zou doen om de huidige versie en hostname te controleren.
|
||||||
|
</p>
|
||||||
|
<form className="form-grid" onSubmit={handleVerifyLicense}>
|
||||||
|
<label className="field">
|
||||||
|
<span>Licentiecode</span>
|
||||||
|
<input
|
||||||
|
value={verifyKey}
|
||||||
|
onChange={(event) => setVerifyKey(event.target.value)}
|
||||||
|
placeholder="SITI-XXXX-XXXX"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Hostname</span>
|
||||||
|
<input
|
||||||
|
value={verifyHostname}
|
||||||
|
onChange={(event) => setVerifyHostname(event.target.value)}
|
||||||
|
placeholder="voorbeeld.nl"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="ghost" type="submit" disabled={verifying}>
|
||||||
|
{verifying ? "Controleren…" : "Controleer licentie"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{verifyStatus && verifyStatus.ok && verifyStatus.data?.license && (
|
||||||
|
<div className="state success inline">
|
||||||
|
<strong>Licentie geldig</strong>
|
||||||
|
<p>
|
||||||
|
{verifyStatus.data.license.pluginName || "Plugin"} — versie {verifyStatus.data.license.pluginVersion || "-"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Gekoppeld aan: <strong>{verifyStatus.data.license.primaryHostname || "Nog niet gekoppeld"}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{verifyStatus && !verifyStatus.ok && (
|
||||||
|
<div className="state error inline">{verifyStatus.message}</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<section className="license-grid">
|
||||||
|
{sortedLicenses.length === 0 ? (
|
||||||
|
<div className="state">Nog geen licenties aangemaakt.</div>
|
||||||
|
) : (
|
||||||
|
sortedLicenses.map((license) => <LicenseCard key={license.id || license.key} license={license} />)
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<div className="state">Log in of registreer om licenties te bekijken en te beheren.</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthForms({ onLogin, onRegister }) {
|
||||||
|
const [mode, setMode] = useState("login");
|
||||||
|
const [loginForm, setLoginForm] = useState({ identifier: "", password: "" });
|
||||||
|
const [registerForm, setRegisterForm] = useState({ username: "", name: "", email: "", password: "" });
|
||||||
|
const [status, setStatus] = useState(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
setStatus(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (mode === "login") {
|
||||||
|
await onLogin(loginForm);
|
||||||
|
setStatus({ variant: "success", message: "Succesvol ingelogd." });
|
||||||
|
} else {
|
||||||
|
await onRegister(registerForm);
|
||||||
|
setStatus({ variant: "success", message: "Account aangemaakt en ingelogd." });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatus({ variant: "error", message: error.message || "Actie mislukt." });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="card auth-card">
|
||||||
|
<div className="auth-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={mode === "login" ? "auth-tab active" : "auth-tab"}
|
||||||
|
onClick={() => setMode("login")}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Inloggen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={mode === "register" ? "auth-tab active" : "auth-tab"}
|
||||||
|
onClick={() => setMode("register")}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Registreren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form className="form-grid" onSubmit={handleSubmit}>
|
||||||
|
{mode === "login" ? (
|
||||||
|
<>
|
||||||
|
<label className="field">
|
||||||
|
<span>Gebruikersnaam of e-mail</span>
|
||||||
|
<input
|
||||||
|
value={loginForm.identifier}
|
||||||
|
onChange={(event) => setLoginForm((prev) => ({ ...prev, identifier: event.target.value }))}
|
||||||
|
placeholder="jouwnaam of mail"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Wachtwoord</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={loginForm.password}
|
||||||
|
onChange={(event) => setLoginForm((prev) => ({ ...prev, password: event.target.value }))}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="field">
|
||||||
|
<span>Gebruikersnaam</span>
|
||||||
|
<input
|
||||||
|
value={registerForm.username}
|
||||||
|
onChange={(event) => setRegisterForm((prev) => ({ ...prev, username: event.target.value }))}
|
||||||
|
placeholder="gebruikersnaam"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Naam</span>
|
||||||
|
<input
|
||||||
|
value={registerForm.name}
|
||||||
|
onChange={(event) => setRegisterForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||||
|
placeholder="Volledige naam"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>E-mailadres</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={registerForm.email}
|
||||||
|
onChange={(event) => setRegisterForm((prev) => ({ ...prev, email: event.target.value }))}
|
||||||
|
placeholder="naam@bedrijf.nl"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Wachtwoord</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={registerForm.password}
|
||||||
|
onChange={(event) => setRegisterForm((prev) => ({ ...prev, password: event.target.value }))}
|
||||||
|
placeholder="Minimaal 8 karakters"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button className="cta" type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Verwerken…" : mode === "login" ? "Inloggen" : "Registreren"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{status && (
|
||||||
|
<div className={`state inline ${status.variant === "error" ? "error" : "success"}`}>
|
||||||
|
{status.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/pages/PluginDetail.jsx
Normal file
125
src/pages/PluginDetail.jsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function PluginDetail() {
|
||||||
|
const { owner, repo } = useParams();
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadDetail() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/plugins/${owner}/${repo}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Kon details niet laden");
|
||||||
|
}
|
||||||
|
const detail = await response.json();
|
||||||
|
setData(detail);
|
||||||
|
} catch (err) {
|
||||||
|
setError("Laden van plugin details is mislukt.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDetail();
|
||||||
|
}, [owner, repo]);
|
||||||
|
|
||||||
|
const manifest = data?.manifest;
|
||||||
|
const displayName = manifest?.plugin_name || data?.name || repo;
|
||||||
|
const description = manifest?.description || data?.description;
|
||||||
|
const author = manifest?.author || "-";
|
||||||
|
const version = manifest?.version || "-";
|
||||||
|
|
||||||
|
const releases = useMemo(() => data?.releases || [], [data]);
|
||||||
|
const commits = useMemo(() => data?.commits || [], [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="detail-hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Plugin details</p>
|
||||||
|
<h1>{displayName}</h1>
|
||||||
|
<p className="subtitle">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="detail-actions">
|
||||||
|
<Link className="ghost" to="/">← Terug</Link>
|
||||||
|
{data?.repoUrl && (
|
||||||
|
<a className="cta" href={data.repoUrl} target="_blank" rel="noreferrer">
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading && <div className="state">Bezig met laden…</div>}
|
||||||
|
{error && <div className="state error">{error}</div>}
|
||||||
|
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<section className="detail-grid">
|
||||||
|
<div className="card">
|
||||||
|
<h2>Manifest</h2>
|
||||||
|
<div className="detail-list">
|
||||||
|
<div>
|
||||||
|
<span>Naam</span>
|
||||||
|
<strong>{displayName}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Versie</span>
|
||||||
|
<strong>{version}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Auteur</span>
|
||||||
|
<strong>{author}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Repository</span>
|
||||||
|
<strong>{data.fullName}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{manifest?.author_url && (
|
||||||
|
<a className="link" href={manifest.author_url} target="_blank" rel="noreferrer">
|
||||||
|
Auteur website →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>Releases</h2>
|
||||||
|
{releases.length === 0 && <p>Geen releases gevonden.</p>}
|
||||||
|
<ul className="list">
|
||||||
|
{releases.map((release) => (
|
||||||
|
<li key={release.tag}>
|
||||||
|
<a href={release.url} target="_blank" rel="noreferrer">
|
||||||
|
{release.name}
|
||||||
|
</a>
|
||||||
|
<span>
|
||||||
|
{release.publishedAt
|
||||||
|
? new Date(release.publishedAt).toLocaleDateString("nl-NL")
|
||||||
|
: "-"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>Recente commits</h2>
|
||||||
|
{commits.length === 0 && <p>Geen commits gevonden.</p>}
|
||||||
|
<ul className="list">
|
||||||
|
{commits.map((commit) => (
|
||||||
|
<li key={commit.sha}>
|
||||||
|
<a href={commit.url} target="_blank" rel="noreferrer">
|
||||||
|
{commit.message?.split("\n")[0] || commit.sha.slice(0, 7)}
|
||||||
|
</a>
|
||||||
|
<span>{commit.author || "-"}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/utils/dates.js
Normal file
9
src/utils/dates.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export const DEFAULT_LOCALE = "nl-NL";
|
||||||
|
|
||||||
|
export function formatDateTime(value, locale = DEFAULT_LOCALE) {
|
||||||
|
return value ? new Date(value).toLocaleString(locale) : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value, locale = DEFAULT_LOCALE) {
|
||||||
|
return value ? new Date(value).toLocaleDateString(locale) : "-";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user