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 = '
'; 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 `