From 22548217b39ea399ea6de1c9fb6db948db5738f1 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 1 Apr 2026 02:02:14 +1100 Subject: [PATCH 01/19] feat(sign-in and application tracking): added sign in and sign up with google and email and password added application tracking. local applications are stored in the browser's localStorage until they sign in where they are synced to the database. added a new page for my applications where they can view and manage their applications. --- frontend/.env.example | 11 + frontend/package-lock.json | 346 +++++++++++++++++- frontend/package.json | 3 + .../src/app/api/auth/[...nextauth]/route.ts | 7 + frontend/src/app/layout.tsx | 35 +- frontend/src/app/my-applications/actions.ts | 90 +++++ frontend/src/app/my-applications/page.tsx | 13 + frontend/src/app/sign-in/page.tsx | 88 +++++ frontend/src/app/sign-up/actions.ts | 43 +++ frontend/src/app/sign-up/page.tsx | 85 +++++ .../applications/my-applications-client.tsx | 191 ++++++++++ .../src/components/auth/session-provider.tsx | 9 + frontend/src/components/jobs/job-details.tsx | 43 ++- .../src/components/layout/nav-bar-mobile.tsx | 1 + frontend/src/components/layout/nav-links.tsx | 6 + frontend/src/lib/auth.ts | 73 ++++ frontend/src/lib/local-applications.ts | 55 +++ frontend/src/lib/mongodb.ts | 30 ++ frontend/src/types/application.ts | 31 ++ package-lock.json | 6 + 20 files changed, 1134 insertions(+), 32 deletions(-) create mode 100644 frontend/.env.example create mode 100644 frontend/src/app/api/auth/[...nextauth]/route.ts create mode 100644 frontend/src/app/my-applications/actions.ts create mode 100644 frontend/src/app/my-applications/page.tsx create mode 100644 frontend/src/app/sign-in/page.tsx create mode 100644 frontend/src/app/sign-up/actions.ts create mode 100644 frontend/src/app/sign-up/page.tsx create mode 100644 frontend/src/components/applications/my-applications-client.tsx create mode 100644 frontend/src/components/auth/session-provider.tsx create mode 100644 frontend/src/lib/auth.ts create mode 100644 frontend/src/lib/local-applications.ts create mode 100644 frontend/src/lib/mongodb.ts create mode 100644 frontend/src/types/application.ts create mode 100644 package-lock.json diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..6403b41 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,11 @@ +MONGODB_URI= +MONGODB_DATABASE=default + +# Auth.js (NextAuth) +NEXTAUTH_SECRET= +NEXTAUTH_URL=http://localhost:3000 + +# OAuth providers +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dbf255c..9fd7831 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@auth/mongodb-adapter": "^3.11.1", "@mantine/core": "^7.17.0", "@mantine/hooks": "^7.16.1", "@mantine/notifications": "^7.17.1", @@ -16,13 +17,14 @@ "@tailwindcss/typography": "^0.5.16", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", + "bcryptjs": "^3.0.3", "dompurify": "^3.2.3", "isomorphic-dompurify": "^2.22.0", "jsdom": "^26.0.0", "lru-cache": "^11.2.2", - "mongodb": "^6.14.2", "next": "15.1.7", + "next-auth": "^4.24.13", "pino": "^9.11.0", "pino-pretty": "^13.1.1", "react": "^19.0.0", @@ -76,6 +78,155 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/@auth/core": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.3.tgz", + "integrity": "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "@panva/hkdf": "^1.1.1", + "@types/cookie": "0.6.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.10.4", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/core/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@auth/core/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/core/node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@auth/core/node_modules/preact-render-to-string": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/@auth/mongodb-adapter": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@auth/mongodb-adapter/-/mongodb-adapter-3.11.1.tgz", + "integrity": "sha512-xY+VUkC3CNXct8UwQgBAQqXASqolSlIARg6oAm1378CtRN2650tQUCOEnGLNLmroVefUeP73M6t+TpGXq72vwQ==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.1" + }, + "peerDependencies": { + "mongodb": "^6" + } + }, + "node_modules/@auth/mongodb-adapter/node_modules/@auth/core": { + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz", + "integrity": "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/mongodb-adapter/node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/mongodb-adapter/node_modules/oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/mongodb-adapter/node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/@babel/runtime": { "version": "7.26.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", @@ -1155,6 +1306,15 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1248,6 +1408,14 @@ "node": ">=4" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1979,6 +2147,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2268,6 +2445,15 @@ "dev": true, "license": "MIT" }, + "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/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2593,18 +2779,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/es-abstract": { "version": "1.23.9", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", @@ -4369,6 +4543,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -4846,6 +5029,38 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.13", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz", + "integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -4889,6 +5104,23 @@ "integrity": "sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==", "license": "MIT" }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, + "node_modules/oauth4webapi": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz", + "integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5019,6 +5251,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -5037,6 +5278,42 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5499,6 +5776,28 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5525,6 +5824,12 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -7052,6 +7357,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -7350,6 +7664,12 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yaml": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 13f22ab..d3fb225 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "format": "npx prettier . --write" }, "dependencies": { + "@auth/mongodb-adapter": "^3.11.1", "@mantine/core": "^7.17.0", "@mantine/hooks": "^7.16.1", "@mantine/notifications": "^7.17.1", @@ -18,12 +19,14 @@ "@tailwindcss/typography": "^0.5.16", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", + "bcryptjs": "^3.0.3", "dompurify": "^3.2.3", "isomorphic-dompurify": "^2.22.0", "jsdom": "^26.0.0", "lru-cache": "^11.2.2", "mongodb": "^6.14.2", "next": "15.1.7", + "next-auth": "^4.24.13", "pino": "^9.11.0", "pino-pretty": "^13.1.1", "react": "^19.0.0", diff --git a/frontend/src/app/api/auth/[...nextauth]/route.ts b/frontend/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..6d4ddf6 --- /dev/null +++ b/frontend/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,7 @@ +import NextAuth from "next-auth"; +import { authOptions } from "@/lib/auth"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; + diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index dbaf664..eec2cb2 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -22,6 +22,7 @@ import FeedbackButton from "@/components/ui/feedback-button"; import { Notifications } from "@mantine/notifications"; import FirstVisitNotification from "@/components/ui/first-visit-notification"; +import AuthSessionProvider from "@/components/auth/session-provider"; export const metadata: Metadata = { title: { @@ -53,22 +54,24 @@ export default function RootLayout({ children }: PropsWithChildren) { - - -
- - -
- {children} - - - - - -
-
-
-
+ + + +
+ + +
+ {children} + + + + + +
+
+
+
+
diff --git a/frontend/src/app/my-applications/actions.ts b/frontend/src/app/my-applications/actions.ts new file mode 100644 index 0000000..f09b17a --- /dev/null +++ b/frontend/src/app/my-applications/actions.ts @@ -0,0 +1,90 @@ +"use server"; + +import clientPromise from "@/lib/mongodb"; +import { authOptions } from "@/lib/auth"; +import { getServerSession } from "next-auth"; +import { ObjectId } from "mongodb"; +import { ApplicationStatus, DbApplication, LocalApplication } from "@/types/application"; + +function requireUserId(session: Awaited>) { + const id = (session?.user as unknown as { id?: string } | undefined)?.id; + if (!id) throw new Error("Not authenticated"); + return id; +} + +export async function syncLocalApplications(apps: LocalApplication[]) { + const session = await getServerSession(authOptions); + const userId = requireUserId(session); + + if (!apps.length) return { ok: true, upserted: 0 }; + + const client = await clientPromise; + const db = client.db(process.env.MONGODB_DATABASE || "default"); + const collection = db.collection("applications"); + + let upserted = 0; + + for (const app of apps) { + await collection.updateOne( + { userId: new ObjectId(userId), jobId: app.jobId }, + { + $setOnInsert: { + startedAt: new Date(app.startedAt), + }, + $set: { + updatedAt: new Date(app.updatedAt), + status: app.status, + jobSnapshot: app.jobSnapshot, + }, + }, + { upsert: true }, + ); + upserted += 1; + } + + return { ok: true, upserted }; +} + +export async function listApplications(): Promise { + const session = await getServerSession(authOptions); + const userId = requireUserId(session); + + const client = await clientPromise; + const db = client.db(process.env.MONGODB_DATABASE || "default"); + + const docs = await db + .collection("applications") + .find({ userId: new ObjectId(userId) }) + .sort({ updatedAt: -1 }) + .limit(500) + .toArray(); + + return docs.map((d) => ({ + _id: d._id.toString(), + jobId: d.jobId, + status: d.status, + startedAt: new Date(d.startedAt).toISOString(), + updatedAt: new Date(d.updatedAt).toISOString(), + jobSnapshot: d.jobSnapshot, + })) as DbApplication[]; +} + +export async function updateApplicationStatus(jobId: string, status: ApplicationStatus) { + const session = await getServerSession(authOptions); + const userId = requireUserId(session); + + const client = await clientPromise; + const db = client.db(process.env.MONGODB_DATABASE || "default"); + + await db.collection("applications").updateOne( + { userId: new ObjectId(userId), jobId }, + { + $set: { status, updatedAt: new Date() }, + $setOnInsert: { startedAt: new Date() }, + }, + { upsert: true }, + ); + + return { ok: true }; +} + diff --git a/frontend/src/app/my-applications/page.tsx b/frontend/src/app/my-applications/page.tsx new file mode 100644 index 0000000..460493c --- /dev/null +++ b/frontend/src/app/my-applications/page.tsx @@ -0,0 +1,13 @@ +import MyApplicationsClient from "@/components/applications/my-applications-client"; +import { listApplications } from "./actions"; + +export default async function MyApplicationsPage() { + const apps = await listApplications().catch(() => []); + + return ( +
+ +
+ ); +} + diff --git a/frontend/src/app/sign-in/page.tsx b/frontend/src/app/sign-in/page.tsx new file mode 100644 index 0000000..91a0837 --- /dev/null +++ b/frontend/src/app/sign-in/page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { Alert, Button, Card, PasswordInput, TextInput } from "@mantine/core"; +import Link from "next/link"; +import { signIn } from "next-auth/react"; +import { useSearchParams } from "next/navigation"; +import { useState } from "react"; + +export default function SignInPage() { + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get("callbackUrl") || "/my-applications"; + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsLoading(true); + + const res = await signIn("credentials", { + redirect: false, + email, + password, + callbackUrl, + }); + + if (res?.error) { + setError("Invalid email or password"); + setIsLoading(false); + return; + } + + if (res?.url) { + window.location.href = res.url; + } else { + window.location.href = callbackUrl; + } + }; + + return ( +
+ +

Sign in

+

+ Don’t have an account?{" "} + + Sign up + +

+ + {error && ( + + {error} + + )} + +
+ setEmail(e.currentTarget.value)} + required + /> + setPassword(e.currentTarget.value)} + required + /> + + + +
+
+ ); +} + diff --git a/frontend/src/app/sign-up/actions.ts b/frontend/src/app/sign-up/actions.ts new file mode 100644 index 0000000..c588052 --- /dev/null +++ b/frontend/src/app/sign-up/actions.ts @@ -0,0 +1,43 @@ +"use server"; + +import clientPromise from "@/lib/mongodb"; +import { hash } from "bcryptjs"; + +export async function registerUser(input: { + email: string; + password: string; + name?: string; +}) { + const email = input.email.toLowerCase().trim(); + const password = input.password; + const name = input.name?.trim(); + + if (!email || !password) { + throw new Error("Email and password are required"); + } + + if (password.length < 8) { + throw new Error("Password must be at least 8 characters"); + } + + const client = await clientPromise; + const db = client.db(process.env.MONGODB_DATABASE || "default"); + + const existing = await db.collection("users").findOne({ email }); + if (existing) { + throw new Error("An account with that email already exists"); + } + + const passwordHash = await hash(password, 12); + + await db.collection("users").insertOne({ + email, + passwordHash, + name: name || null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + return { ok: true }; +} + diff --git a/frontend/src/app/sign-up/page.tsx b/frontend/src/app/sign-up/page.tsx new file mode 100644 index 0000000..ab85c20 --- /dev/null +++ b/frontend/src/app/sign-up/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { Alert, Button, Card, PasswordInput, TextInput } from "@mantine/core"; +import Link from "next/link"; +import { useState } from "react"; +import { registerUser } from "./actions"; +import { signIn } from "next-auth/react"; + +export default function SignUpPage() { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsLoading(true); + try { + await registerUser({ name, email, password }); + await signIn("credentials", { + email, + password, + callbackUrl: "/my-applications", + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Sign up failed"); + setIsLoading(false); + } + }; + + return ( +
+ +

Create account

+

+ Already have an account?{" "} + + Sign in + +

+ + {error && ( + + {error} + + )} + +
+ setName(e.currentTarget.value)} + placeholder="Optional" + /> + setEmail(e.currentTarget.value)} + required + /> + setPassword(e.currentTarget.value)} + required + description="At least 8 characters" + /> + + + +
+
+ ); +} + diff --git a/frontend/src/components/applications/my-applications-client.tsx b/frontend/src/components/applications/my-applications-client.tsx new file mode 100644 index 0000000..5f0b297 --- /dev/null +++ b/frontend/src/components/applications/my-applications-client.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useSession } from "next-auth/react"; +import { + Alert, + Badge, + Card, + Group, + Select, + Table, + Text, + Title, +} from "@mantine/core"; +import Link from "next/link"; +import { + APPLICATION_STATUSES, + ApplicationStatus, + DbApplication, +} from "@/types/application"; +import { + clearLocalApplications, + getLocalApplications, +} from "@/lib/local-applications"; +import { syncLocalApplications, updateApplicationStatus } from "@/app/my-applications/actions"; + +function statusColor(status: ApplicationStatus) { + switch (status) { + case "STARTED": + return "gray"; + case "APPLIED": + return "blue"; + case "INTERVIEW": + return "yellow"; + case "OFFER": + return "teal"; + case "ACCEPTED": + return "green"; + case "REJECTED": + return "red"; + case "WITHDREW": + return "orange"; + default: + return "gray"; + } +} + +export default function MyApplicationsClient({ initial }: { initial: DbApplication[] }) { + const { data: session, status: sessionStatus } = useSession(); + const [apps, setApps] = useState(initial); + const [syncMessage, setSyncMessage] = useState(null); + + useEffect(() => { + if (sessionStatus !== "authenticated") return; + const local = getLocalApplications(); + if (!local.length) return; + + (async () => { + try { + const res = await syncLocalApplications(local); + clearLocalApplications(); + setSyncMessage(`Synced ${res.upserted} application${res.upserted === 1 ? "" : "s"} from this device.`); + } catch { + setSyncMessage("Couldn’t sync local applications yet. Try refreshing."); + } + })(); + }, [sessionStatus]); + + const summary = useMemo(() => { + const counts = Object.fromEntries(APPLICATION_STATUSES.map((s) => [s, 0])) as Record< + ApplicationStatus, + number + >; + for (const a of apps) counts[a.status] += 1; + const total = apps.length; + const mostRecent = apps[0]?.updatedAt; + return { counts, total, mostRecent }; + }, [apps]); + + if (sessionStatus === "unauthenticated") { + return ( + + You’re not signed in.{" "} + + Sign in + {" "} + to view and manage your applications across devices. + + ); + } + + return ( +
+ {syncMessage && {syncMessage}} + + + +
+ Summary + + Total: {summary.total} + {summary.mostRecent ? ` · Updated ${new Date(summary.mostRecent).toLocaleString()}` : ""} + +
+ + {APPLICATION_STATUSES.map((s) => ( + + {s}: {summary.counts[s]} + + ))} + +
+
+ + + + My Applications + {session?.user ? ( + + Signed in as {session.user.email} + + ) : null} + + + + + + Role + Company + Status + Started + Updated + Link + + + + {apps.map((a) => ( + + {a.jobSnapshot.title} + {a.jobSnapshot.companyName} + +
+
+
+ ); +} + diff --git a/frontend/src/components/auth/session-provider.tsx b/frontend/src/components/auth/session-provider.tsx new file mode 100644 index 0000000..8d766c3 --- /dev/null +++ b/frontend/src/components/auth/session-provider.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { SessionProvider } from "next-auth/react"; +import { PropsWithChildren } from "react"; + +export default function AuthSessionProvider({ children }: PropsWithChildren) { + return {children}; +} + diff --git a/frontend/src/components/jobs/job-details.tsx b/frontend/src/components/jobs/job-details.tsx index 55aaa78..999d19d 100644 --- a/frontend/src/components/jobs/job-details.tsx +++ b/frontend/src/components/jobs/job-details.tsx @@ -1,19 +1,24 @@ // frontend/src/components/jobs/job-details.tsx "use client"; import { useEffect, useRef, useState } from "react"; -import { ActionIcon, Button, Card, ScrollArea } from "@mantine/core"; +import { ActionIcon, Button, Card, Modal, ScrollArea, Text } from "@mantine/core"; import { IconCheck, IconCopy, IconExternalLink } from "@tabler/icons-react"; import { useFilterContext } from "@/context/filter/filter-context"; import JobDescription from "@/components/jobs/job-description"; import JobHeader from "@/components/jobs/job-header"; import JobDetailsLoading from "@/components/layout/job-details-loading"; import JobSummary from "@/components/jobs/job-summary"; +import { upsertLocalStartedApplication } from "@/lib/local-applications"; +import Link from "next/link"; +import { useSession } from "next-auth/react"; export default function JobDetails() { const { selectedJob, isLoading } = useFilterContext(); const scrollRef = useRef(null); const [isCopied, setIsCopied] = useState(false); const timeoutRef = useRef(null); + const [showSigninModal, setShowSigninModal] = useState(false); + const { data: session } = useSession(); // Scroll to top whenever a new job is selected useEffect(() => { @@ -35,6 +40,10 @@ export default function JobDetails() { } const handleApplyClick = () => { + if (!session?.user) { + upsertLocalStartedApplication(selectedJob); + setShowSigninModal(true); + } window.open(selectedJob.application_url, "_blank"); }; @@ -57,7 +66,34 @@ export default function JobDetails() { }; return ( - + <> + setShowSigninModal(false)} + title="Track your applications" + centered + > + + Sign in to track all your applications across devices and manage + statuses like Applied, Rejected, Accepted, etc. + +
+ + +
+
+ + - +
+ ); } diff --git a/frontend/src/components/layout/nav-bar-mobile.tsx b/frontend/src/components/layout/nav-bar-mobile.tsx index ae0fcbc..a8eb5ac 100644 --- a/frontend/src/components/layout/nav-bar-mobile.tsx +++ b/frontend/src/components/layout/nav-bar-mobile.tsx @@ -14,6 +14,7 @@ export const NavBarMobile = () => { const menuItems = [ { href: "/", label: "Home" }, { href: "/jobs", label: "Jobs" }, + { href: "/sign-in", label: "Sign in" }, ]; return ( diff --git a/frontend/src/components/layout/nav-links.tsx b/frontend/src/components/layout/nav-links.tsx index e3b11c0..876826f 100644 --- a/frontend/src/components/layout/nav-links.tsx +++ b/frontend/src/components/layout/nav-links.tsx @@ -19,6 +19,12 @@ export default function NavLinks() { > Jobs + + Sign in + ); } diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 0000000..9f80950 --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -0,0 +1,73 @@ +import type { NextAuthOptions } from "next-auth"; +import GoogleProvider from "next-auth/providers/google"; +import CredentialsProvider from "next-auth/providers/credentials"; +import { MongoDBAdapter } from "@auth/mongodb-adapter"; +import clientPromise from "@/lib/mongodb"; +import { compare } from "bcryptjs"; +import { ObjectId } from "mongodb"; + +type DbUser = { + _id: ObjectId; + email?: string; + passwordHash?: string; + name?: string | null; +}; + +export const authOptions: NextAuthOptions = { + adapter: MongoDBAdapter(clientPromise, { + databaseName: process.env.MONGODB_DATABASE || "default", + }), + session: { + strategy: "jwt", + }, + providers: [ + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID || "", + clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", + }), + CredentialsProvider({ + name: "Email and password", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + const email = (credentials?.email || "").toLowerCase().trim(); + const password = credentials?.password || ""; + + if (!email || !password) return null; + + const client = await clientPromise; + const db = client.db(process.env.MONGODB_DATABASE || "default"); + const user = (await db.collection("users").findOne({ email })) as DbUser | null; + if (!user?.passwordHash) return null; + + const ok = await compare(password, user.passwordHash); + if (!ok) return null; + + return { + id: user._id.toString(), + email: user.email, + name: user.name || undefined, + }; + }, + }), + ], + callbacks: { + async jwt({ token, user }) { + if (user?.id) token.id = user.id; + return token; + }, + async session({ session, token }) { + if (session.user && token?.id) { + // @ts-expect-error - add id onto session user + session.user.id = token.id; + } + return session; + }, + }, + pages: { + signIn: "/sign-in", + }, +}; + diff --git a/frontend/src/lib/local-applications.ts b/frontend/src/lib/local-applications.ts new file mode 100644 index 0000000..3badee4 --- /dev/null +++ b/frontend/src/lib/local-applications.ts @@ -0,0 +1,55 @@ +import { LocalApplication } from "@/types/application"; +import { Job } from "@/types/job"; + +const STORAGE_KEY = "mploy_applications_v1"; + +function safeParse(json: string | null): unknown { + if (!json) return null; + try { + return JSON.parse(json); + } catch { + return null; + } +} + +export function getLocalApplications(): LocalApplication[] { + if (typeof window === "undefined") return []; + const raw = window.localStorage.getItem(STORAGE_KEY); + const parsed = safeParse(raw); + if (!Array.isArray(parsed)) return []; + return parsed as LocalApplication[]; +} + +export function setLocalApplications(apps: LocalApplication[]) { + if (typeof window === "undefined") return; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(apps)); +} + +export function upsertLocalStartedApplication(job: Job): LocalApplication { + const now = new Date().toISOString(); + const apps = getLocalApplications(); + const existing = apps.find((a) => a.jobId === job.id); + + const next: LocalApplication = { + jobId: job.id, + status: existing?.status || "STARTED", + startedAt: existing?.startedAt || now, + updatedAt: now, + jobSnapshot: { + jobId: job.id, + title: job.title, + companyName: job.company?.name || "Unknown", + applicationUrl: job.application_url, + }, + }; + + const merged = [next, ...apps.filter((a) => a.jobId !== job.id)]; + setLocalApplications(merged); + return next; +} + +export function clearLocalApplications() { + if (typeof window === "undefined") return; + window.localStorage.removeItem(STORAGE_KEY); +} + diff --git a/frontend/src/lib/mongodb.ts b/frontend/src/lib/mongodb.ts new file mode 100644 index 0000000..fd13e4e --- /dev/null +++ b/frontend/src/lib/mongodb.ts @@ -0,0 +1,30 @@ +import { MongoClient } from "mongodb"; + +declare global { + // eslint-disable-next-line no-var + var __mongoClientPromise: Promise | undefined; +} + +const uri = process.env.MONGODB_URI; +if (!uri) { + throw new Error("Missing MONGODB_URI environment variable"); +} + +const options = {}; + +let client: MongoClient; +let clientPromise: Promise; + +if (process.env.NODE_ENV === "development") { + if (!global.__mongoClientPromise) { + client = new MongoClient(uri, options); + global.__mongoClientPromise = client.connect(); + } + clientPromise = global.__mongoClientPromise; +} else { + client = new MongoClient(uri, options); + clientPromise = client.connect(); +} + +export default clientPromise; + diff --git a/frontend/src/types/application.ts b/frontend/src/types/application.ts new file mode 100644 index 0000000..a588a3d --- /dev/null +++ b/frontend/src/types/application.ts @@ -0,0 +1,31 @@ +export const APPLICATION_STATUSES = [ + "STARTED", + "APPLIED", + "REJECTED", + "ACCEPTED", + "INTERVIEW", + "OFFER", + "WITHDREW", +] as const; + +export type ApplicationStatus = (typeof APPLICATION_STATUSES)[number]; + +export type ApplicationJobSnapshot = { + jobId: string; + title: string; + companyName: string; + applicationUrl?: string; +}; + +export type LocalApplication = { + jobId: string; + status: ApplicationStatus; + startedAt: string; + updatedAt: string; + jobSnapshot: ApplicationJobSnapshot; +}; + +export type DbApplication = LocalApplication & { + _id: string; +}; + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ec8ce84 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "mploy-app", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From 297735689ee42b0dd460916ae15acb69a11f1d77 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 2 Apr 2026 13:05:06 +1100 Subject: [PATCH 02/19] feat(adding job): add a new job to the applications page --- frontend/src/app/my-applications/actions.ts | 20 ++++++ frontend/src/components/jobs/job-details.tsx | 13 +++- .../src/components/layout/nav-bar-mobile.tsx | 24 ++++++- frontend/src/components/layout/nav-links.tsx | 69 +++++++++++++++---- 4 files changed, 108 insertions(+), 18 deletions(-) diff --git a/frontend/src/app/my-applications/actions.ts b/frontend/src/app/my-applications/actions.ts index f09b17a..e661d88 100644 --- a/frontend/src/app/my-applications/actions.ts +++ b/frontend/src/app/my-applications/actions.ts @@ -69,6 +69,26 @@ export async function listApplications(): Promise { })) as DbApplication[]; } +export async function addApplication(jobId: string, jobSnapshot: import("@/types/application").ApplicationJobSnapshot) { + const session = await getServerSession(authOptions); + const userId = requireUserId(session); + + const client = await clientPromise; + const db = client.db(process.env.MONGODB_DATABASE || "default"); + const now = new Date(); + + await db.collection("applications").updateOne( + { userId: new ObjectId(userId), jobId }, + { + $set: { updatedAt: now, jobSnapshot }, + $setOnInsert: { startedAt: now, status: "STARTED" }, + }, + { upsert: true }, + ); + + return { ok: true }; +} + export async function updateApplicationStatus(jobId: string, status: ApplicationStatus) { const session = await getServerSession(authOptions); const userId = requireUserId(session); diff --git a/frontend/src/components/jobs/job-details.tsx b/frontend/src/components/jobs/job-details.tsx index 999d19d..0639506 100644 --- a/frontend/src/components/jobs/job-details.tsx +++ b/frontend/src/components/jobs/job-details.tsx @@ -9,6 +9,7 @@ import JobHeader from "@/components/jobs/job-header"; import JobDetailsLoading from "@/components/layout/job-details-loading"; import JobSummary from "@/components/jobs/job-summary"; import { upsertLocalStartedApplication } from "@/lib/local-applications"; +import { addApplication } from "@/app/my-applications/actions"; import Link from "next/link"; import { useSession } from "next-auth/react"; @@ -40,11 +41,19 @@ export default function JobDetails() { } const handleApplyClick = () => { - if (!session?.user) { + window.open(selectedJob.application_url, "_blank"); + + if (session?.user) { + addApplication(selectedJob.id, { + jobId: selectedJob.id, + title: selectedJob.title, + companyName: selectedJob.company?.name || "Unknown", + applicationUrl: selectedJob.application_url, + }); + } else { upsertLocalStartedApplication(selectedJob); setShowSigninModal(true); } - window.open(selectedJob.application_url, "_blank"); }; const handleCopyLink = () => { diff --git a/frontend/src/components/layout/nav-bar-mobile.tsx b/frontend/src/components/layout/nav-bar-mobile.tsx index a8eb5ac..db9abe1 100644 --- a/frontend/src/components/layout/nav-bar-mobile.tsx +++ b/frontend/src/components/layout/nav-bar-mobile.tsx @@ -1,20 +1,24 @@ "use client"; import Link from "next/link"; import { Button, Menu } from "@mantine/core"; -import { IconMenu2, IconSearch } from "@tabler/icons-react"; +import { IconMenu2, IconSearch, IconLogout, IconClipboardList } from "@tabler/icons-react"; import Logo from "@/components/layout/logo"; import SearchBar from "@/components/search/search-bar"; import { useState } from "react"; import { usePathname } from "next/navigation"; +import { useSession, signOut } from "next-auth/react"; export const NavBarMobile = () => { const [showSearch, setShowSearch] = useState(false); const pathname = usePathname(); + const { data: session, status } = useSession(); const menuItems = [ { href: "/", label: "Home" }, { href: "/jobs", label: "Jobs" }, - { href: "/sign-in", label: "Sign in" }, + ...(status === "authenticated" + ? [{ href: "/my-applications", label: "My Applications" }] + : [{ href: "/sign-in", label: "Sign in" }]), ]; return ( @@ -50,16 +54,32 @@ export const NavBarMobile = () => { + {session?.user?.email && ( + {session.user.email} + )} {menuItems.map((item) => ( : undefined} > {item.label} ))} + {status === "authenticated" && ( + <> + + } + onClick={() => signOut({ callbackUrl: "/" })} + > + Sign out + + + )} diff --git a/frontend/src/components/layout/nav-links.tsx b/frontend/src/components/layout/nav-links.tsx index 876826f..5cc6a6f 100644 --- a/frontend/src/components/layout/nav-links.tsx +++ b/frontend/src/components/layout/nav-links.tsx @@ -1,30 +1,71 @@ "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useSession, signOut } from "next-auth/react"; +import { Avatar, Menu } from "@mantine/core"; +import { IconUser, IconLogout, IconClipboardList } from "@tabler/icons-react"; export default function NavLinks() { const pathname = usePathname(); + const { data: session, status } = useSession(); + + const linkClass = (href: string) => + `text-lg ${pathname === href ? "font-bold underline-fancy" : ""}`; return ( <> - + Home - + Jobs - - Sign in - + + {status === "authenticated" ? ( + + + + {session.user?.name + ? session.user.name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2) + : } + + + + {session.user?.email} + } + > + My Applications + + + } + onClick={() => signOut({ callbackUrl: "/" })} + > + Sign out + + + + ) : ( + + Sign in + + )} ); } From 5cd22240316f6c03da431a77385ce6dac01b0710 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 2 Apr 2026 13:39:20 +1100 Subject: [PATCH 03/19] feat(quickadd and delete): add quick add and delete buttons to job details page --- frontend/src/app/globals.css | 3 +- frontend/src/app/my-applications/actions.ts | 15 + frontend/src/app/my-applications/page.tsx | 6 +- .../applications/my-applications-client.tsx | 296 +++++++++++------- .../src/components/layout/nav-bar-mobile.tsx | 2 +- frontend/src/components/layout/nav-links.tsx | 17 +- frontend/src/types/application.ts | 1 - 7 files changed, 206 insertions(+), 134 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index c40b579..fa79548 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -39,8 +39,7 @@ } html, -body, -main { +body { height: 100svh; margin: 0; padding: 0; diff --git a/frontend/src/app/my-applications/actions.ts b/frontend/src/app/my-applications/actions.ts index e661d88..9c3ebd8 100644 --- a/frontend/src/app/my-applications/actions.ts +++ b/frontend/src/app/my-applications/actions.ts @@ -89,6 +89,21 @@ export async function addApplication(jobId: string, jobSnapshot: import("@/types return { ok: true }; } +export async function deleteApplication(jobId: string) { + const session = await getServerSession(authOptions); + const userId = requireUserId(session); + + const client = await clientPromise; + const db = client.db(process.env.MONGODB_DATABASE || "default"); + + await db.collection("applications").deleteOne({ + userId: new ObjectId(userId), + jobId, + }); + + return { ok: true }; +} + export async function updateApplicationStatus(jobId: string, status: ApplicationStatus) { const session = await getServerSession(authOptions); const userId = requireUserId(session); diff --git a/frontend/src/app/my-applications/page.tsx b/frontend/src/app/my-applications/page.tsx index 460493c..57bac1b 100644 --- a/frontend/src/app/my-applications/page.tsx +++ b/frontend/src/app/my-applications/page.tsx @@ -5,8 +5,10 @@ export default async function MyApplicationsPage() { const apps = await listApplications().catch(() => []); return ( -
- +
+
+ +
); } diff --git a/frontend/src/components/applications/my-applications-client.tsx b/frontend/src/components/applications/my-applications-client.tsx index 5f0b297..0cc448d 100644 --- a/frontend/src/components/applications/my-applications-client.tsx +++ b/frontend/src/components/applications/my-applications-client.tsx @@ -3,15 +3,19 @@ import { useEffect, useMemo, useState } from "react"; import { useSession } from "next-auth/react"; import { + ActionIcon, Alert, Badge, + Button, Card, Group, + MultiSelect, Select, Table, Text, Title, } from "@mantine/core"; +import { IconTrash } from "@tabler/icons-react"; import Link from "next/link"; import { APPLICATION_STATUSES, @@ -22,33 +26,42 @@ import { clearLocalApplications, getLocalApplications, } from "@/lib/local-applications"; -import { syncLocalApplications, updateApplicationStatus } from "@/app/my-applications/actions"; +import { + deleteApplication, + syncLocalApplications, + updateApplicationStatus, +} from "@/app/my-applications/actions"; + +const STATUS_ORDER: ApplicationStatus[] = [ + "STARTED", + "INTERVIEW", + "APPLIED", + "OFFER", + "ACCEPTED", + "REJECTED", +]; function statusColor(status: ApplicationStatus) { switch (status) { - case "STARTED": - return "gray"; - case "APPLIED": - return "blue"; - case "INTERVIEW": - return "yellow"; - case "OFFER": - return "teal"; - case "ACCEPTED": - return "green"; - case "REJECTED": - return "red"; - case "WITHDREW": - return "orange"; - default: - return "gray"; + case "STARTED": return "gray"; + case "APPLIED": return "blue"; + case "INTERVIEW": return "yellow"; + case "OFFER": return "teal"; + case "ACCEPTED": return "green"; + case "REJECTED": return "red"; + default: return "gray"; } } +function capitalize(s: string) { + return s.charAt(0) + s.slice(1).toLowerCase(); +} + export default function MyApplicationsClient({ initial }: { initial: DbApplication[] }) { const { data: session, status: sessionStatus } = useSession(); const [apps, setApps] = useState(initial); const [syncMessage, setSyncMessage] = useState(null); + const [selectedStatuses, setSelectedStatuses] = useState(STATUS_ORDER as string[]); useEffect(() => { if (sessionStatus !== "authenticated") return; @@ -61,26 +74,51 @@ export default function MyApplicationsClient({ initial }: { initial: DbApplicati clearLocalApplications(); setSyncMessage(`Synced ${res.upserted} application${res.upserted === 1 ? "" : "s"} from this device.`); } catch { - setSyncMessage("Couldn’t sync local applications yet. Try refreshing."); + setSyncMessage("Couldn't sync local applications yet. Try refreshing."); } })(); }, [sessionStatus]); - const summary = useMemo(() => { - const counts = Object.fromEntries(APPLICATION_STATUSES.map((s) => [s, 0])) as Record< - ApplicationStatus, - number - >; - for (const a of apps) counts[a.status] += 1; - const total = apps.length; - const mostRecent = apps[0]?.updatedAt; - return { counts, total, mostRecent }; + const grouped = useMemo(() => { + const map = new Map(); + for (const s of STATUS_ORDER) map.set(s, []); + for (const a of apps) { + const group = map.get(a.status); + if (group) group.push(a); + } + return map; }, [apps]); + const total = apps.length; + + async function handleStatusChange( + appId: string, + jobId: string, + oldStatus: ApplicationStatus, + next: ApplicationStatus, + ) { + setApps((prev) => prev.map((p) => (p._id === appId ? { ...p, status: next } : p))); + try { + await updateApplicationStatus(jobId, next); + } catch { + setApps((prev) => prev.map((p) => (p._id === appId ? { ...p, status: oldStatus } : p))); + } + } + + async function handleDelete(appId: string, jobId: string) { + const removed = apps.find((a) => a._id === appId); + setApps((prev) => prev.filter((a) => a._id !== appId)); + try { + await deleteApplication(jobId); + } catch { + if (removed) setApps((prev) => [removed, ...prev]); + } + } + if (sessionStatus === "unauthenticated") { return ( - You’re not signed in.{" "} + You're not signed in.{" "} Sign in {" "} @@ -93,99 +131,119 @@ export default function MyApplicationsClient({ initial }: { initial: DbApplicati
{syncMessage && {syncMessage}} - - -
- Summary - - Total: {summary.total} - {summary.mostRecent ? ` · Updated ${new Date(summary.mostRecent).toLocaleString()}` : ""} - -
- - {APPLICATION_STATUSES.map((s) => ( - - {s}: {summary.counts[s]} + {/* Summary + filter row */} +
+ + + {total} total{session?.user?.email ? ` · ${session.user.email}` : ""} + +
+ {STATUS_ORDER.map((s) => ( + + {capitalize(s)}: {grouped.get(s)?.length ?? 0} ))} - - - - - - - My Applications - {session?.user ? ( - - Signed in as {session.user.email} - - ) : null} - - - - - - Role - Company - Status - Started - Updated - Link - - - - {apps.map((a) => ( - - {a.jobSnapshot.title} - {a.jobSnapshot.companyName} - -
-
+
+
+ + + ({ value: s, label: capitalize(s) }))} + value={selectedStatuses} + onChange={setSelectedStatuses} + w={220} + size="sm" + /> + +
+ + {/* Per-status sections */} + {STATUS_ORDER.filter((s) => selectedStatuses.includes(s)).map((status) => { + const statusApps = grouped.get(status) ?? []; + return ( + + + {capitalize(status)} + + {statusApps.length} + + + + + + + Role + Company + Status + Updated + + + + + {statusApps.map((a) => { + const url = a.jobSnapshot.applicationUrl; + return ( + url && window.open(url, "_blank", "noreferrer")} + > + {a.jobSnapshot.title} + {a.jobSnapshot.companyName} + e.stopPropagation()}> +
+
+ ); + })}
); } - diff --git a/frontend/src/components/layout/nav-bar-mobile.tsx b/frontend/src/components/layout/nav-bar-mobile.tsx index db9abe1..61d70d5 100644 --- a/frontend/src/components/layout/nav-bar-mobile.tsx +++ b/frontend/src/components/layout/nav-bar-mobile.tsx @@ -47,7 +47,7 @@ export const NavBarMobile = () => { > - + + + + + + {STATUS_ORDER.map((s) => ( + + + {capitalize(s)} + + } + /> + ))} + + + +
{/* Per-status sections */} @@ -195,11 +242,18 @@ export default function MyApplicationsClient({ initial }: { initial: DbApplicati ({ value: s, label: capitalize(s) }))} - value={a.status} - leftSection={} - renderOption={({ option }) => ( - - - {option.label} - - )} - onChange={async (value) => { - if (!value) return; - await handleStatusChange(a._id, a.jobId, a.status, value as ApplicationStatus); - }} - w={155} - /> - - {new Date(a.updatedAt).toLocaleDateString()} - e.stopPropagation()}> - - {status === "STARTED" && ( -