From 2ea90554ef90fd3e28a17c7d4108cae286eb7154 Mon Sep 17 00:00:00 2001 From: 0x1d Date: Sat, 15 Nov 2025 21:43:29 +0100 Subject: [PATCH] feat: auth --- .dockerignore | 1 + .env.example | 39 +++++ package-lock.json | 289 ++++++++++++++++++++++++++++++- package.json | 4 + public/app.js | 432 ++++++++++++++++++++++++++++++++++++++++++---- public/index.html | 80 ++++++++- public/styles.css | 390 ++++++++++++++++++++++++++++++++++++----- public/sw.js | 2 +- server.js | 217 +++++++++++++++++++++-- 9 files changed, 1356 insertions(+), 98 deletions(-) create mode 100644 .env.example diff --git a/.dockerignore b/.dockerignore index ee9f91b..3b1855c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ node_modules npm-debug.log data .env +.env.example .git .gitignore *.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b8b6cf0 --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# LDAP Configuration +LDAP_ADDRESS=ldaps://ldap-server:636 +LDAP_IMPLEMENTATION=custom +LDAP_TIMEOUT=5000 +LDAP_START_TLS=false +LDAP_TLS_SERVER_NAME=ldap-server +LDAP_TLS_SKIP_VERIFY=true +LDAP_TLS_MINIMUM_VERSION=TLS1.2 +LDAP_BASE_DN=dc=dcentral,dc=systems +LDAP_ADDITIONAL_USERS_DN=cn=users +LDAP_USERS_FILTER=(&({username_attribute}={input})) +LDAP_ADDITIONAL_GROUPS_DN=cn=groups +LDAP_GROUPS_FILTER=(cn=users) +LDAP_USER=uid=root,cn=users,dc=dcentral,dc=systems +LDAP_PASSWORD=super-secret +LDAP_ATTRIBUTE_DISTINGUISHED_NAME=distinguishedName +LDAP_ATTRIBUTE_USERNAME=uid +LDAP_ATTRIBUTE_MAIL=mail +LDAP_ATTRIBUTE_MEMBER_OF=memberOf +LDAP_ATTRIBUTE_GROUP_NAME=cn + +# Session Configuration +SESSION_SECRET=your-secret-key-change-this-in-production + +# Server Configuration +PORT=3000 + +# Session Configuration +SESSION_SECRET=your-secret-key-change-this-in-production +SESSION_NAME=connect.sid +TRUST_PROXY=true +COOKIE_SECURE=true +COOKIE_SAMESITE=none +COOKIE_DOMAIN= +COOKIE_PATH=/ + +# Server Configuration +PORT=3000 +NODE_ENV=production diff --git a/package-lock.json b/package-lock.json index 6674cd5..7c1634e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,11 @@ "axios": "^1.6.0", "cheerio": "^1.0.0-rc.12", "cors": "^2.8.5", + "dotenv": "^17.2.3", "express": "^4.18.2", + "express-session": "^1.18.2", + "passport": "^0.7.0", + "passport-ldapauth": "^3.0.1", "puppeteer-core": "^22.15.0" }, "devDependencies": { @@ -25,12 +29,20 @@ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "license": "MIT" }, + "node_modules/@types/ldapjs": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-2.2.5.tgz", + "integrity": "sha512-Lv/nD6QDCmcT+V1vaTRnEKE8UgOilVv5pHcQuzkU1LcRe4mbHHuUo/KHi0LKrpdHhQY8FJzryF38fcVdeUIrzg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "24.10.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", "license": "MIT", - "optional": true, "dependencies": { "undici-types": "~7.16.0" } @@ -45,6 +57,12 @@ "@types/node": "*" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -111,6 +129,24 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -154,6 +190,18 @@ } } }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "license": "MIT", + "dependencies": { + "precond": "0.2" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -281,6 +329,12 @@ "node": ">=10.0.0" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -599,6 +653,12 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -761,6 +821,18 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1006,6 +1078,40 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -1049,6 +1155,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -1582,6 +1697,53 @@ "node": ">=0.12.0" } }, + "node_modules/ldap-filter": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.3.3.tgz", + "integrity": "sha512-/tFkx5WIn4HuO+6w9lsfxq4FN3O+fDZeO9Mek8dCD8rTUpqzRa766BOBO7BcGkn3X86m5+cBm1/2S/Shzz7gMg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/ldapauth-fork": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/ldapauth-fork/-/ldapauth-fork-5.0.5.tgz", + "integrity": "sha512-LWUk76+V4AOZbny/3HIPQtGPWZyA3SW2tRhsWIBi9imP22WJktKLHV1ofd8Jo/wY7Ve6vAT7FCI5mEn3blZTjw==", + "license": "MIT", + "dependencies": { + "@types/ldapjs": "^2.2.2", + "bcryptjs": "^2.4.0", + "ldapjs": "^2.2.1", + "lru-cache": "^7.10.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ldapjs": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-2.3.3.tgz", + "integrity": "sha512-75QiiLJV/PQqtpH+HGls44dXweviFwQ6SiIK27EqzKQ5jU/7UFrl2E5nLdQ3IYRBzJ/AVFJI66u0MZ0uofKYwg==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "abstract-logging": "^2.0.0", + "asn1": "^0.2.4", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "ldap-filter": "^0.3.3", + "once": "^1.4.0", + "vasync": "^2.2.0", + "verror": "^1.8.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", @@ -1812,6 +1974,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1934,12 +2105,56 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-ldapauth": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/passport-ldapauth/-/passport-ldapauth-3.0.1.tgz", + "integrity": "sha512-TRRx3BHi8GC8MfCT9wmghjde/EGeKjll7zqHRRfGRxXbLcaDce2OftbQrFG7/AWaeFhR6zpZHtBQ/IkINdLVjQ==", + "license": "MIT", + "dependencies": { + "ldapauth-fork": "^5.0.1", + "passport-strategy": "^1.0.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -1959,6 +2174,14 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -2113,6 +2336,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2561,6 +2793,18 @@ "node": ">= 0.6" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unbzip2-stream": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", @@ -2591,8 +2835,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", @@ -2627,6 +2870,46 @@ "node": ">= 0.8" } }, + "node_modules/vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "verror": "1.10.0" + } + }, + "node_modules/vasync/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", diff --git a/package.json b/package.json index 02e94db..10d23cb 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,11 @@ "axios": "^1.6.0", "cheerio": "^1.0.0-rc.12", "cors": "^2.8.5", + "dotenv": "^17.2.3", "express": "^4.18.2", + "express-session": "^1.18.2", + "passport": "^0.7.0", + "passport-ldapauth": "^3.0.1", "puppeteer-core": "^22.15.0" }, "devDependencies": { diff --git a/public/app.js b/public/app.js index 26e89d0..135413a 100644 --- a/public/app.js +++ b/public/app.js @@ -1,5 +1,6 @@ const API_BASE = '/api/links'; const LISTS_API_BASE = '/api/lists'; +const AUTH_API_BASE = '/api/auth'; // DOM elements const linkForm = document.getElementById('linkForm'); @@ -29,6 +30,17 @@ const listFilterWrapper = document.getElementById('listFilterWrapper'); const listFilterChips = document.getElementById('listFilterChips'); const clearListFilters = document.getElementById('clearListFilters'); const editListsBtn = document.getElementById('editListsBtn'); +const loginToggle = document.getElementById('loginToggle'); +const loginModal = document.getElementById('loginModal'); +const closeLoginModal = document.getElementById('closeLoginModal'); +const loginForm = document.getElementById('loginForm'); +const loginUsername = document.getElementById('loginUsername'); +const loginPassword = document.getElementById('loginPassword'); +const loginSubmitBtn = document.getElementById('loginSubmitBtn'); +const loginError = document.getElementById('loginError'); +const userInfo = document.getElementById('userInfo'); +const usernameDisplay = document.getElementById('usernameDisplay'); +const logoutBtn = document.getElementById('logoutBtn'); // State let allLinks = []; @@ -38,15 +50,20 @@ let showArchived = false; let currentLayout = localStorage.getItem('linkdingLayout') || 'masonry'; // Load from localStorage or default let selectedListFilters = []; let currentLinkForListSelection = null; +let isAuthenticated = false; +let currentUser = null; // Initialize app document.addEventListener('DOMContentLoaded', () => { - loadLinks(); - loadLists().then(() => { - // After lists are loaded, check URL for list filter - checkUrlForListFilter(); + checkAuthStatus().then(() => { + loadLinks(); + loadLists().then(() => { + // After lists are loaded, check URL for list filter + checkUrlForListFilter(); + }); }); setupEventListeners(); + setupAuthentication(); setupScrollToTop(); setupMobileSearch(); setupMobileAddLink(); @@ -54,10 +71,35 @@ document.addEventListener('DOMContentLoaded', () => { setupListsManagement(); setupListFiltering(); setupUrlNavigation(); + setupLogoClick(); + setupMobileButtonBlur(); applyLayout(currentLayout); registerServiceWorker(); }); +// Setup button blur on mobile after click +function setupMobileButtonBlur() { + // Check if device is mobile/touch device + const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || + ('ontouchstart' in window) || + (navigator.maxTouchPoints > 0); + + if (isMobile) { + // Add blur handler to all buttons after click + document.addEventListener('click', (e) => { + if (e.target.tagName === 'BUTTON' || e.target.closest('button')) { + const button = e.target.tagName === 'BUTTON' ? e.target : e.target.closest('button'); + // Use setTimeout to ensure blur happens after any click handlers + setTimeout(() => { + if (button && document.activeElement === button) { + button.blur(); + } + }, 100); + } + }, true); // Use capture phase to catch all button clicks + } +} + // Register service worker for PWA function registerServiceWorker() { if ('serviceWorker' in navigator) { @@ -73,11 +115,212 @@ function registerServiceWorker() { } } +// Authentication functions +async function checkAuthStatus() { + try { + const response = await fetch(AUTH_API_BASE + '/status', { + credentials: 'include' + }); + if (!response.ok) throw new Error('Failed to check auth status'); + const data = await response.json(); + const wasAuthenticated = isAuthenticated; + isAuthenticated = data.authenticated; + currentUser = data.user; + updateUIBasedOnAuth(); + + // If authentication status changed, reload lists to get the correct set + if (wasAuthenticated !== isAuthenticated) { + await loadLists(); + } + } catch (error) { + console.error('Error checking auth status:', error); + isAuthenticated = false; + currentUser = null; + updateUIBasedOnAuth(); + } +} + +function updateUIBasedOnAuth() { + // Show/hide login button and user info + if (isAuthenticated) { + loginToggle.style.display = 'none'; + userInfo.style.display = 'flex'; + usernameDisplay.textContent = currentUser?.username || 'User'; + } else { + loginToggle.style.display = 'flex'; + userInfo.style.display = 'none'; + } + + // Show/hide modify buttons (lists toggle is visible to all for filtering) + const modifyButtons = document.querySelectorAll('.add-link-toggle-btn, .delete-btn, .archive-btn, .add-to-list-btn, #createListBtn, .edit-lists-btn'); + modifyButtons.forEach(btn => { + if (btn) { + btn.style.display = isAuthenticated ? '' : 'none'; + } + }); + + // Lists toggle button is visible to all users for filtering + // But the edit button inside the list filter section should be hidden when not authenticated + const editListsBtn = document.getElementById('editListsBtn'); + if (editListsBtn) { + editListsBtn.style.display = isAuthenticated ? '' : 'none'; + } + + // Hide add link wrapper if not authenticated + if (!isAuthenticated && addLinkWrapper) { + addLinkWrapper.classList.remove('show'); + if (addLinkToggle) addLinkToggle.classList.remove('active'); + } +} + +function setupAuthentication() { + // Login toggle + loginToggle.addEventListener('click', () => { + loginModal.classList.add('show'); + loginUsername.focus(); + }); + + // Close login modal + closeLoginModal.addEventListener('click', () => { + loginModal.classList.remove('show'); + loginForm.reset(); + loginError.style.display = 'none'; + }); + + // Close on backdrop click + loginModal.addEventListener('click', (e) => { + if (e.target === loginModal) { + loginModal.classList.remove('show'); + loginForm.reset(); + loginError.style.display = 'none'; + } + }); + + // Login form submission + loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const username = loginUsername.value.trim(); + const password = loginPassword.value; + + if (!username || !password) { + showLoginError('Please enter both username and password'); + return; + } + + // Disable form + loginSubmitBtn.disabled = true; + const btnText = loginSubmitBtn.querySelector('.btn-text'); + const btnLoader = loginSubmitBtn.querySelector('.btn-loader'); + if (btnText) btnText.style.display = 'none'; + if (btnLoader) btnLoader.style.display = 'flex'; + loginError.style.display = 'none'; + + try { + const response = await fetch(AUTH_API_BASE + '/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify({ username, password }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Login failed'); + } + + isAuthenticated = true; + currentUser = data.user; + updateUIBasedOnAuth(); + + loginModal.classList.remove('show'); + loginForm.reset(); + showMessage('Login successful!', 'success'); + + // Reload lists to get all lists (not just public ones) + loadLists().then(() => { + // Update filter chips if visible + if (listFilterWrapper.classList.contains('show')) { + updateListFilterChips(); + } + }); + + // Refresh links to show all links (not just public ones) + loadLinks(); + } catch (error) { + showLoginError(error.message || 'Login failed. Please check your credentials.'); + console.error('Login error:', error); + } finally { + loginSubmitBtn.disabled = false; + if (btnText) btnText.style.display = 'inline'; + if (btnLoader) btnLoader.style.display = 'none'; + } + }); + + // Logout + logoutBtn.addEventListener('click', async () => { + try { + const response = await fetch(AUTH_API_BASE + '/logout', { + method: 'POST', + credentials: 'include' + }); + + if (!response.ok) throw new Error('Logout failed'); + + isAuthenticated = false; + currentUser = null; + updateUIBasedOnAuth(); + showMessage('Logged out successfully', 'success'); + + // Reload lists to get only public lists + loadLists().then(() => { + // Clear any selected filters that are no longer valid (private lists) + selectedListFilters = selectedListFilters.filter(listId => { + return allLists.some(list => list.id === listId && list.public === true); + }); + // Update filter chips if visible + if (listFilterWrapper.classList.contains('show')) { + updateListFilterChips(); + } + }); + + // Reset all filters and refresh links to show only public links + resetAllFilters(); + } catch (error) { + showMessage('Logout failed', 'error'); + console.error('Logout error:', error); + } + }); +} + +function showLoginError(message) { + loginError.textContent = message; + loginError.style.display = 'block'; +} + +// Helper function to handle API errors, especially authentication errors +async function handleApiError(response, defaultMessage) { + if (response.status === 401) { + // Authentication required + isAuthenticated = false; + currentUser = null; + updateUIBasedOnAuth(); + showMessage('Please login to perform this action', 'error'); + loginModal.classList.add('show'); + return true; // Indicates auth error was handled + } + const data = await response.json().catch(() => ({})); + throw new Error(data.error || defaultMessage); +} + // Event listeners function setupEventListeners() { linkForm.addEventListener('submit', handleAddLink); searchInput.addEventListener('input', handleSearch); - archiveToggle.addEventListener('click', handleToggleArchive); + archiveToggle.addEventListener('change', handleToggleArchive); } // Setup search toggle (works on both desktop and mobile) @@ -91,10 +334,20 @@ function setupMobileSearch() { // Hide search searchWrapper.classList.remove('show'); searchToggle.classList.remove('active'); + // Force remove active state on mobile - use requestAnimationFrame to ensure it happens after click processing + requestAnimationFrame(() => { + searchToggle.blur(); + if (document.activeElement === searchToggle) { + document.activeElement.blur(); + } + }); // Hide add link if visible if (addLinkWrapper && addLinkWrapper.classList.contains('show')) { addLinkWrapper.classList.remove('show'); - if (addLinkToggle) addLinkToggle.classList.remove('active'); + if (addLinkToggle) { + addLinkToggle.classList.remove('active'); + addLinkToggle.blur(); + } } // Clear search and reset if needed if (searchInput.value.trim()) { @@ -105,7 +358,10 @@ function setupMobileSearch() { // Hide add link if visible if (addLinkWrapper && addLinkWrapper.classList.contains('show')) { addLinkWrapper.classList.remove('show'); - if (addLinkToggle) addLinkToggle.classList.remove('active'); + if (addLinkToggle) { + addLinkToggle.classList.remove('active'); + addLinkToggle.blur(); + } } // Show search searchWrapper.classList.add('show'); @@ -129,6 +385,13 @@ function setupMobileAddLink() { // Hide add link addLinkWrapper.classList.remove('show'); addLinkToggle.classList.remove('active'); + // Force remove active state on mobile - use requestAnimationFrame to ensure it happens after click processing + requestAnimationFrame(() => { + addLinkToggle.blur(); + if (document.activeElement === addLinkToggle) { + document.activeElement.blur(); + } + }); // Clear input if needed if (linkInput.value.trim()) { linkInput.value = ''; @@ -137,7 +400,10 @@ function setupMobileAddLink() { // Hide search if visible if (searchWrapper && searchWrapper.classList.contains('show')) { searchWrapper.classList.remove('show'); - if (searchToggle) searchToggle.classList.remove('active'); + if (searchToggle) { + searchToggle.classList.remove('active'); + searchToggle.blur(); + } if (searchInput.value.trim()) { searchInput.value = ''; loadLinks(); @@ -161,8 +427,18 @@ function setupLayoutToggle() { // Toggle dropdown on button click layoutToggle.addEventListener('click', (e) => { e.stopPropagation(); + const wasVisible = layoutDropdown.classList.contains('show'); layoutDropdown.classList.toggle('show'); layoutToggle.classList.toggle('active'); + // Blur button when closing dropdown to remove focus/active state + if (wasVisible) { + requestAnimationFrame(() => { + layoutToggle.blur(); + if (document.activeElement === layoutToggle) { + document.activeElement.blur(); + } + }); + } }); // Close dropdown when clicking outside @@ -170,6 +446,13 @@ function setupLayoutToggle() { if (!layoutToggle.contains(e.target) && !layoutDropdown.contains(e.target)) { layoutDropdown.classList.remove('show'); layoutToggle.classList.remove('active'); + // Force remove active state on mobile - use requestAnimationFrame to ensure it happens after click processing + requestAnimationFrame(() => { + layoutToggle.blur(); + if (document.activeElement === layoutToggle) { + document.activeElement.blur(); + } + }); } }); @@ -276,15 +559,17 @@ async function handleAddLink(e) { headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ url }) }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || 'Failed to add link'); + const handled = await handleApiError(response, 'Failed to add link'); + if (handled) return; } + const data = await response.json(); + // Ensure archived and listIds properties exist data.archived = data.archived || false; data.listIds = data.listIds || []; @@ -297,6 +582,7 @@ async function handleAddLink(e) { headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ listIds: selectedListFilters }) }); @@ -369,8 +655,7 @@ function handleSearch(e) { // Handle archive toggle function handleToggleArchive() { - showArchived = !showArchived; - archiveToggle.classList.toggle('active', showArchived); + showArchived = archiveToggle.checked; // Re-filter and display links const query = searchInput.value.trim(); @@ -424,6 +709,9 @@ function displayLinks(links) { handleAddToLists(linkId); }); }); + + // Update UI based on auth status after rendering + updateUIBasedOnAuth(); } // Create link card HTML @@ -566,10 +854,14 @@ async function handleArchiveLink(id) { headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ archived: newArchivedStatus }) }); - if (!response.ok) throw new Error('Failed to archive link'); + if (!response.ok) { + const handled = await handleApiError(response, 'Failed to archive link'); + if (handled) return; + } // Update local array link.archived = newArchivedStatus; @@ -598,10 +890,14 @@ async function handleDeleteLink(id) { try { const response = await fetch(`${API_BASE}/${id}`, { - method: 'DELETE' + method: 'DELETE', + credentials: 'include' }); - if (!response.ok) throw new Error('Failed to delete link'); + if (!response.ok) { + const handled = await handleApiError(response, 'Failed to delete link'); + if (handled) return; + } // Remove from local array allLinks = allLinks.filter(link => link.id !== id); @@ -665,11 +961,27 @@ async function loadLists() { // Setup lists management function setupListsManagement() { // Toggle filter section visibility - listsToggle.addEventListener('click', () => { + listsToggle.addEventListener('click', (e) => { const isVisible = listFilterWrapper.classList.contains('show'); if (isVisible) { listFilterWrapper.classList.remove('show'); listsToggle.classList.remove('active'); + // Force remove active state on mobile - use double requestAnimationFrame for listsToggle + requestAnimationFrame(() => { + listsToggle.blur(); + if (document.activeElement === listsToggle) { + document.activeElement.blur(); + } + // Second frame to ensure it's fully cleared + requestAnimationFrame(() => { + listsToggle.blur(); + // Force style recalculation + listsToggle.style.pointerEvents = 'none'; + setTimeout(() => { + listsToggle.style.pointerEvents = ''; + }, 0); + }); + }); // Reset filters when hiding the section selectedListFilters = []; updateUrlForListFilter(); @@ -817,15 +1129,17 @@ async function handleCreateList() { headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ name }) }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || 'Failed to create list'); + const handled = await handleApiError(response, 'Failed to create list'); + if (handled) return; } + const data = await response.json(); + allLists.push(data); newListName.value = ''; renderListsList(); @@ -856,15 +1170,17 @@ async function handleUpdateList(listId) { headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ name }) }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || 'Failed to update list'); + const handled = await handleApiError(response, 'Failed to update list'); + if (handled) return; } + const data = await response.json(); + const listIndex = allLists.findIndex(l => l.id === listId); if (listIndex !== -1) { allLists[listIndex] = data; @@ -899,15 +1215,17 @@ async function handleTogglePublic(listId) { headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ public: newPublicStatus }) }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || 'Failed to update list public status'); + const handled = await handleApiError(response, 'Failed to update list public status'); + if (handled) return; } + const data = await response.json(); + // Update local array const listIndex = allLists.findIndex(l => l.id === listId); if (listIndex !== -1) { @@ -930,10 +1248,14 @@ async function handleDeleteList(listId) { try { const response = await fetch(`${LISTS_API_BASE}/${listId}`, { - method: 'DELETE' + method: 'DELETE', + credentials: 'include' }); - if (!response.ok) throw new Error('Failed to delete list'); + if (!response.ok) { + const handled = await handleApiError(response, 'Failed to delete list'); + if (handled) return; + } allLists = allLists.filter(list => list.id !== listId); selectedListFilters = selectedListFilters.filter(id => id !== listId); @@ -992,15 +1314,17 @@ async function handleCheckboxChange(linkId) { headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ listIds: selectedListIds }) }); - const data = await response.json(); - if (!response.ok) { - throw new Error('Failed to update link lists'); + const handled = await handleApiError(response, 'Failed to update link lists'); + if (handled) return; } + const data = await response.json(); + // Update local array const linkIndex = allLinks.findIndex(l => l.id === linkId); if (linkIndex !== -1) { @@ -1022,6 +1346,41 @@ async function handleCheckboxChange(linkId) { } } +// Reset all filters and load all links +function resetAllFilters() { + // Clear search + searchInput.value = ''; + + // Reset archive toggle + showArchived = false; + archiveToggle.checked = false; + + // Clear list filters + selectedListFilters = []; + updateUrlForListFilter(); + if (listFilterWrapper.classList.contains('show')) { + updateListFilterChips(); + } + + // Hide search and add link wrappers if visible + searchWrapper.classList.remove('show'); + searchToggle.classList.remove('active'); + addLinkWrapper.classList.remove('show'); + addLinkToggle.classList.remove('active'); + + // Load all links + loadLinks(); +} + +// Setup logo click to reset filters +function setupLogoClick() { + const appLogo = document.getElementById('appLogo'); + if (appLogo) { + appLogo.style.cursor = 'pointer'; + appLogo.addEventListener('click', resetAllFilters); + } +} + // Setup list filtering function setupListFiltering() { clearListFilters.addEventListener('click', () => { @@ -1081,7 +1440,14 @@ function updateUrlForListFilter() { // Update list filter chips function updateListFilterChips() { - if (allLists.length === 0) { + // Filter lists based on authentication status + let listsToShow = allLists; + if (!isAuthenticated) { + // Only show public lists when not logged in + listsToShow = allLists.filter(list => list.public === true); + } + + if (listsToShow.length === 0) { // Don't hide the wrapper if it's already shown, just show empty state listFilterChips.innerHTML = '

No lists available. Click Edit to create lists.

'; return; @@ -1092,7 +1458,7 @@ function updateListFilterChips() { return; } - listFilterChips.innerHTML = allLists.map(list => { + listFilterChips.innerHTML = listsToShow.map(list => { const isSelected = selectedListFilters.includes(list.id); return ` -
+ +
@@ -126,6 +135,11 @@
Lists
+
+ + +
diff --git a/public/styles.css b/public/styles.css index d5bf225..f00caea 100644 --- a/public/styles.css +++ b/public/styles.css @@ -87,11 +87,20 @@ body { -webkit-text-fill-color: transparent; background-clip: text; letter-spacing: -0.02em; + transition: opacity 0.3s ease; +} + +.app-title:hover { + opacity: 0.8; } .title-icon { - font-size: 1.5rem; - filter: drop-shadow(0 2px 4px rgba(99, 102, 241, 0.3)); + width: 36px; + height: 36px; + border-radius: 8px; + object-fit: contain; + display: block; + flex-shrink: 0; } .title-text { @@ -110,40 +119,6 @@ body { gap: 0.75rem; } -/* Archive Toggle Button */ -.archive-toggle-btn { - background: transparent; - border: 1px solid var(--border); - color: var(--text-secondary); - padding: 0.5rem; - border-radius: 8px; - cursor: pointer; - transition: all 0.3s ease; - width: 36px; - height: 36px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; -} - -.archive-toggle-btn:hover { - background: var(--surface-light); - border-color: var(--secondary-color); - color: var(--secondary-color); -} - -.archive-toggle-btn.active { - background: rgba(139, 92, 246, 0.1); - border-color: var(--secondary-color); - color: var(--secondary-color); -} - -.archive-toggle-btn svg { - width: 20px; - height: 20px; -} - /* Layout Toggle Button */ .layout-toggle-wrapper { position: relative; @@ -439,6 +414,74 @@ body { height: 20px; } +/* Login Toggle Button */ +.login-toggle-btn { + display: flex; + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + padding: 0.5rem; + border-radius: 8px; + cursor: pointer; + transition: all 0.3s ease; + width: 36px; + height: 36px; + flex-shrink: 0; + align-items: center; + justify-content: center; +} + +.login-toggle-btn:hover { + background: var(--surface-light); + border-color: var(--primary-color); + color: var(--primary-color); +} + +.login-toggle-btn svg { + width: 20px; + height: 20px; +} + +/* User Info */ +.user-info { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + background: var(--surface-light); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-secondary); + font-size: 0.875rem; +} + +.user-info span { + color: var(--text-primary); +} + +.logout-btn { + display: flex; + background: transparent; + border: none; + color: var(--text-secondary); + padding: 0.25rem; + border-radius: 4px; + cursor: pointer; + transition: all 0.3s ease; + align-items: center; + justify-content: center; +} + +.logout-btn:hover { + background: var(--surface); + color: var(--error); +} + +.logout-btn svg { + width: 16px; + height: 16px; +} + /* Add Link Toggle Button */ .add-link-toggle-btn { display: flex; @@ -1043,6 +1086,60 @@ body { .header-right { flex-wrap: wrap; } + + /* Remove focus outline and active state persistence on mobile nav buttons */ + .search-toggle-btn, + .add-link-toggle-btn, + .lists-toggle-btn, + .layout-toggle-btn { + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + touch-action: manipulation; + } + + .search-toggle-btn:focus, + .add-link-toggle-btn:focus, + .lists-toggle-btn:focus, + .layout-toggle-btn:focus { + outline: none; + } + + /* Prevent active state from persisting when button is not in .active class */ + .search-toggle-btn:active:not(.active), + .add-link-toggle-btn:active:not(.active), + .lists-toggle-btn:active:not(.active), + .layout-toggle-btn:active:not(.active) { + background: transparent !important; + border-color: var(--border) !important; + color: var(--text-secondary) !important; + } + + /* Ensure buttons without .active class return to default state */ + .search-toggle-btn:not(.active), + .add-link-toggle-btn:not(.active), + .lists-toggle-btn:not(.active), + .layout-toggle-btn:not(.active) { + background: transparent !important; + border-color: var(--border) !important; + color: var(--text-secondary) !important; + } + + /* Extra specificity for lists-toggle-btn to ensure it resets */ + .lists-toggle-btn:not(.active):not(:hover) { + background: transparent !important; + border-color: var(--border) !important; + color: var(--text-secondary) !important; + } + + /* Hide username text on mobile, show only logout icon */ + .user-info span { + display: none; + } + + .user-info { + padding: 0.5rem; + gap: 0; + } .add-link-toggle-btn { display: flex; @@ -1168,11 +1265,6 @@ body { min-width: 100%; } - .archive-toggle-wrapper { - width: 100%; - justify-content: flex-start; - } - .scroll-to-top-btn { bottom: 1.5rem; right: 1.5rem; @@ -1252,6 +1344,70 @@ body { gap: 0.5rem; } +/* Archive Toggle Switch */ +.archive-toggle-switch { + position: relative; + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + flex-shrink: 0; +} + +.archive-toggle-label { + font-size: 0.875rem; + color: var(--text-secondary); + user-select: none; + white-space: nowrap; +} + +.archive-toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.archive-toggle-slider { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + cursor: pointer; + background-color: var(--border); + transition: 0.3s; + border-radius: 24px; + flex-shrink: 0; +} + +.archive-toggle-slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.3s; + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.archive-toggle-switch input:checked + .archive-toggle-slider { + background-color: var(--secondary-color); +} + +.archive-toggle-switch input:checked + .archive-toggle-slider:before { + transform: translateX(20px); +} + +.archive-toggle-switch:hover .archive-toggle-slider { + background-color: var(--text-muted); +} + +.archive-toggle-switch input:checked:hover + .archive-toggle-slider { + background-color: var(--primary-color); +} + .edit-lists-btn { background: transparent; border: 1px solid var(--border); @@ -1582,6 +1738,149 @@ body { padding: 2rem; } +/* Login Modal */ +.login-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; +} + +.login-modal.show { + opacity: 1; + visibility: visible; +} + +.login-modal-content { + background: var(--surface); + border-radius: 1rem; + width: 90%; + max-width: 400px; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px var(--shadow-lg); + transform: scale(0.9); + transition: transform 0.3s ease; +} + +.login-modal.show .login-modal-content { + transform: scale(1); +} + +.login-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.8rem; + border-bottom: 1px solid var(--border); +} + +.login-modal-header h2 { + margin: 0; + font-size: 1.5rem; + color: var(--text-primary); +} + +.login-modal-body { + padding: 1.5rem; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-group label { + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 500; +} + +.form-group input { + background: var(--background); + border: 1px solid var(--border); + color: var(--text-primary); + padding: 0.75rem; + border-radius: 0.5rem; + font-size: 1rem; + transition: border-color 0.3s ease; +} + +.form-group input:focus { + outline: none; + border-color: var(--primary-color); +} + +.login-error { + background: rgba(239, 68, 68, 0.1); + border: 1px solid var(--error); + color: var(--error); + padding: 0.75rem; + border-radius: 0.5rem; + font-size: 0.875rem; +} + +.btn-login { + background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3); + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.btn-login:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(99, 102, 241, 0.4); +} + +.btn-login:active:not(:disabled) { + transform: translateY(0); +} + +.btn-login:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-login .btn-loader { + display: none; + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + /* List Selection Overlay */ .list-selection-overlay { position: fixed; @@ -1626,7 +1925,7 @@ body { display: flex; align-items: center; justify-content: space-between; - padding: 1.5rem; + padding: 0.8rem; border-bottom: 1px solid var(--border); } @@ -1778,3 +2077,10 @@ body { } } +/* Hide page title on very small screens */ +@media (max-width: 357px) { + .app-title { + display: none; + } +} + diff --git a/public/sw.js b/public/sw.js index 7c9dcdf..49a9714 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'linkding-v2'; +const CACHE_NAME = 'linkding-v2.0.1'; const urlsToCache = [ '/', '/index.html', diff --git a/server.js b/server.js index b53d8bf..17c403c 100644 --- a/server.js +++ b/server.js @@ -1,5 +1,9 @@ +require('dotenv').config(); const express = require('express'); const cors = require('cors'); +const session = require('express-session'); +const passport = require('passport'); +const LdapStrategy = require('passport-ldapauth').Strategy; const fs = require('fs').promises; const path = require('path'); const axios = require('axios'); @@ -66,11 +70,126 @@ const PORT = process.env.PORT || 3000; const DATA_FILE = path.join(__dirname, 'data', 'links.json'); const LISTS_FILE = path.join(__dirname, 'data', 'lists.json'); +// Trust proxy - required when behind reverse proxy (Traefik) +// This allows Express to trust X-Forwarded-* headers +app.set('trust proxy', process.env.TRUST_PROXY !== 'false'); // Default to true, set to 'false' to disable + +// Session configuration +const isSecure = process.env.COOKIE_SECURE === 'true' || + (process.env.COOKIE_SECURE !== 'false' && process.env.NODE_ENV === 'production'); + +app.use(session({ + secret: process.env.SESSION_SECRET || 'your-secret-key-change-this-in-production', + resave: false, + saveUninitialized: false, + name: process.env.SESSION_NAME || 'connect.sid', // Custom session name to avoid conflicts + cookie: { + secure: isSecure, // Use secure cookies when behind HTTPS proxy + httpOnly: true, + maxAge: 24 * 60 * 60 * 1000, // 24 hours + sameSite: process.env.COOKIE_SAMESITE || (isSecure ? 'none' : 'lax'), // 'none' for cross-site, 'lax' for same-site + domain: process.env.COOKIE_DOMAIN || undefined, // Set if cookies need to be shared across subdomains + path: process.env.COOKIE_PATH || '/' // Cookie path + } +})); + +// Initialize Passport +app.use(passport.initialize()); +app.use(passport.session()); + +// Configure LDAP Strategy +// Combine base DN with additional users DN if provided +const baseDN = process.env.LDAP_BASE_DN; +const additionalUsersDN = process.env.LDAP_ADDITIONAL_USERS_DN || ''; +const searchBase = additionalUsersDN && baseDN + ? `${additionalUsersDN},${baseDN}` + : baseDN; + +const searchAttributes = []; +if (process.env.LDAP_ATTRIBUTE_USERNAME) { + searchAttributes.push(process.env.LDAP_ATTRIBUTE_USERNAME); +} +if (process.env.LDAP_ATTRIBUTE_MAIL) { + searchAttributes.push(process.env.LDAP_ATTRIBUTE_MAIL); +} +if (process.env.LDAP_ATTRIBUTE_DISTINGUISHED_NAME) { + searchAttributes.push(process.env.LDAP_ATTRIBUTE_DISTINGUISHED_NAME); +} +if (process.env.LDAP_ATTRIBUTE_MEMBER_OF) { + searchAttributes.push(process.env.LDAP_ATTRIBUTE_MEMBER_OF); +} + +const ldapOptions = { + server: { + url: process.env.LDAP_ADDRESS, + bindDN: process.env.LDAP_USER, + bindCredentials: process.env.LDAP_PASSWORD, + searchBase: searchBase, + searchFilter: process.env.LDAP_USERS_FILTER, + searchAttributes: searchAttributes.length > 0 ? searchAttributes : undefined, + timeout: process.env.LDAP_TIMEOUT ? parseInt(process.env.LDAP_TIMEOUT) : undefined, + connectTimeout: process.env.LDAP_TIMEOUT ? parseInt(process.env.LDAP_TIMEOUT) : undefined, + tlsOptions: { + rejectUnauthorized: process.env.LDAP_TLS_SKIP_VERIFY !== 'true', + servername: process.env.LDAP_TLS_SERVER_NAME || undefined + } + }, + usernameField: 'username', + passwordField: 'password' +}; + +// Replace {username} placeholder in search filter +if (ldapOptions.server.searchFilter && ldapOptions.server.searchFilter.includes('{{username}}')) { + // Keep as is, passport-ldapauth will replace it +} else if (ldapOptions.server.searchFilter && ldapOptions.server.searchFilter.includes('{username_attribute}')) { + // Replace with actual attribute name + const usernameAttr = process.env.LDAP_ATTRIBUTE_USERNAME; + if (usernameAttr) { + ldapOptions.server.searchFilter = ldapOptions.server.searchFilter.replace('{username_attribute}', usernameAttr); + ldapOptions.server.searchFilter = ldapOptions.server.searchFilter.replace('{input}', '{{username}}'); + } +} + +passport.use(new LdapStrategy(ldapOptions, (user, done) => { + // User object contains LDAP user data + const usernameAttr = process.env.LDAP_ATTRIBUTE_USERNAME; + const mailAttr = process.env.LDAP_ATTRIBUTE_MAIL; + const dnAttr = process.env.LDAP_ATTRIBUTE_DISTINGUISHED_NAME; + + return done(null, { + id: usernameAttr ? user[usernameAttr] : user.uid, + username: usernameAttr ? user[usernameAttr] : user.uid, + email: mailAttr ? user[mailAttr] : user.mail, + dn: dnAttr ? user[dnAttr] : user.dn + }); +})); + +// Serialize user for session +passport.serializeUser((user, done) => { + done(null, user); +}); + +// Deserialize user from session +passport.deserializeUser((user, done) => { + done(null, user); +}); + // Middleware -app.use(cors()); +app.use(cors({ + origin: true, + credentials: true +})); app.use(express.json()); app.use(express.static('public')); +// Authentication middleware +function isAuthenticated(req, res, next) { + if (req.isAuthenticated()) { + return next(); + } + res.status(401).json({ error: 'Authentication required' }); +} + // Ensure data directory exists async function ensureDataDir() { const dataDir = path.dirname(DATA_FILE); @@ -589,12 +708,70 @@ async function extractMetadata(url) { } } +// Authentication Routes + +// Check authentication status +app.get('/api/auth/status', (req, res) => { + res.json({ + authenticated: req.isAuthenticated(), + user: req.isAuthenticated() ? req.user : null + }); +}); + +// Login endpoint +app.post('/api/auth/login', (req, res, next) => { + passport.authenticate('ldapauth', (err, user, info) => { + if (err) { + console.error('LDAP authentication error:', err); + return res.status(500).json({ error: 'Authentication failed', details: err.message }); + } + if (!user) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + req.logIn(user, (loginErr) => { + if (loginErr) { + return res.status(500).json({ error: 'Login failed', details: loginErr.message }); + } + return res.json({ + authenticated: true, + user: user + }); + }); + })(req, res, next); +}); + +// Logout endpoint +app.post('/api/auth/logout', (req, res) => { + req.logout((err) => { + if (err) { + return res.status(500).json({ error: 'Logout failed' }); + } + res.json({ authenticated: false }); + }); +}); + // API Routes // Get all links app.get('/api/links', async (req, res) => { try { const links = await readLinks(); + + // If user is not authenticated, only show links in public lists + if (!req.isAuthenticated()) { + const lists = await readLists(); + const publicListIds = lists.filter(list => list.public === true).map(list => list.id); + + // Filter links to only those that are in at least one public list + const filteredLinks = links.filter(link => { + const linkListIds = link.listIds || []; + return linkListIds.some(listId => publicListIds.includes(listId)); + }); + + return res.json(filteredLinks); + } + + // Authenticated users see all links res.json(links); } catch (error) { res.status(500).json({ error: 'Failed to read links' }); @@ -605,7 +782,19 @@ app.get('/api/links', async (req, res) => { app.get('/api/links/search', async (req, res) => { try { const query = req.query.q?.toLowerCase() || ''; - const links = await readLinks(); + let links = await readLinks(); + + // If user is not authenticated, only show links in public lists + if (!req.isAuthenticated()) { + const lists = await readLists(); + const publicListIds = lists.filter(list => list.public === true).map(list => list.id); + + // Filter links to only those that are in at least one public list + links = links.filter(link => { + const linkListIds = link.listIds || []; + return linkListIds.some(listId => publicListIds.includes(listId)); + }); + } if (!query) { return res.json(links); @@ -625,7 +814,7 @@ app.get('/api/links/search', async (req, res) => { }); // Add a new link -app.post('/api/links', async (req, res) => { +app.post('/api/links', isAuthenticated, async (req, res) => { try { const { url } = req.body; @@ -664,7 +853,7 @@ app.post('/api/links', async (req, res) => { }); // Archive/Unarchive a link -app.patch('/api/links/:id/archive', async (req, res) => { +app.patch('/api/links/:id/archive', isAuthenticated, async (req, res) => { try { const { id } = req.params; const { archived } = req.body; @@ -690,7 +879,7 @@ app.patch('/api/links/:id/archive', async (req, res) => { }); // Delete a link -app.delete('/api/links/:id', async (req, res) => { +app.delete('/api/links/:id', isAuthenticated, async (req, res) => { try { const { id } = req.params; const links = await readLinks(); @@ -708,7 +897,7 @@ app.delete('/api/links/:id', async (req, res) => { }); // Update link's lists -app.patch('/api/links/:id/lists', async (req, res) => { +app.patch('/api/links/:id/lists', isAuthenticated, async (req, res) => { try { const { id } = req.params; const { listIds } = req.body; @@ -739,6 +928,14 @@ app.patch('/api/links/:id/lists', async (req, res) => { app.get('/api/lists', async (req, res) => { try { const lists = await readLists(); + + // If user is not authenticated, only return public lists + if (!req.isAuthenticated()) { + const publicLists = lists.filter(list => list.public === true); + return res.json(publicLists); + } + + // Authenticated users see all lists res.json(lists); } catch (error) { res.status(500).json({ error: 'Failed to read lists' }); @@ -746,7 +943,7 @@ app.get('/api/lists', async (req, res) => { }); // Create a new list -app.post('/api/lists', async (req, res) => { +app.post('/api/lists', isAuthenticated, async (req, res) => { try { const { name } = req.body; @@ -779,7 +976,7 @@ app.post('/api/lists', async (req, res) => { }); // Update a list -app.put('/api/lists/:id', async (req, res) => { +app.put('/api/lists/:id', isAuthenticated, async (req, res) => { try { const { id } = req.params; const { name } = req.body; @@ -811,7 +1008,7 @@ app.put('/api/lists/:id', async (req, res) => { }); // Toggle list public status -app.patch('/api/lists/:id/public', async (req, res) => { +app.patch('/api/lists/:id/public', isAuthenticated, async (req, res) => { try { const { id } = req.params; const { public: isPublic } = req.body; @@ -837,7 +1034,7 @@ app.patch('/api/lists/:id/public', async (req, res) => { }); // Delete a list -app.delete('/api/lists/:id', async (req, res) => { +app.delete('/api/lists/:id', isAuthenticated, async (req, res) => { try { const { id } = req.params; const lists = await readLists(); -- 2.49.1