diff --git a/internal/http/auth.go b/internal/http/auth.go index 0191a30..70bd3b7 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "net/http" + "time" "github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/pkg/errors" @@ -82,6 +83,7 @@ func (h authHandler) login(w http.ResponseWriter, r *http.Request) { // Set user as authenticated session.Values["authenticated"] = true + session.Values["created"] = time.Now().Unix() // Set cookie options session.Options.HttpOnly = true diff --git a/internal/http/middleware.go b/internal/http/middleware.go index 84fd6b6..2430101 100644 --- a/internal/http/middleware.go +++ b/internal/http/middleware.go @@ -57,6 +57,27 @@ func (s Server) IsAuthenticated(next http.Handler) http.Handler { return } + if created, ok := session.Values["created"].(int64); ok { + // created is a unix timestamp MaxAge is in seconds + maxAge := time.Duration(session.Options.MaxAge) * time.Second + expires := time.Unix(created, 0).Add(maxAge) + + if time.Until(expires) <= 7*24*time.Hour { // 7 days + s.log.Info().Msgf("Cookie is expiring in less than 7 days on %s - extending session", expires.Format("2006-01-02 15:04:05")) + + session.Values["created"] = time.Now().Unix() + + // Call session.Save as needed - since it writes a header (the Set-Cookie + // header), making sure you call it before writing out a body is important. + // https://github.com/gorilla/sessions/issues/178#issuecomment-447674812 + if err := session.Save(r, w); err != nil { + s.log.Error().Err(err).Msgf("could not store session: %s", r.RemoteAddr) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + } + ctx := context.WithValue(r.Context(), "session", session) r = r.WithContext(ctx) } diff --git a/web/package.json b/web/package.json index d35e3b4..ca7df1b 100644 --- a/web/package.json +++ b/web/package.json @@ -40,7 +40,7 @@ "@tailwindcss/forms": "^0.5.7", "@tanstack/react-query": "^5.29.2", "@tanstack/react-query-devtools": "^5.29.2", - "@tanstack/react-router": "^1.28.5", + "@tanstack/react-router": "^1.31.6", "@types/node": "^20.12.7", "@types/react": "^18.2.79", "@types/react-dom": "^18.2.25", @@ -58,7 +58,6 @@ "react": "^18.2.0", "react-debounce-input": "^3.3.0", "react-dom": "^18.2.0", - "react-error-boundary": "^4.0.13", "react-hook-form": "^7.51.3", "react-hot-toast": "^2.4.1", "react-multi-select-component": "^4.3.4", @@ -78,7 +77,7 @@ "devDependencies": { "@microsoft/eslint-formatter-sarif": "^3.1.0", "@rollup/wasm-node": "^4.14.3", - "@tanstack/router-devtools": "^1.28.5", + "@tanstack/router-devtools": "^1.31.6", "@types/node": "^20.12.2", "@types/react": "^18.2.73", "@types/react-dom": "^18.2.23", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index d8426b0..2963ce7 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -35,8 +35,8 @@ importers: specifier: ^5.29.2 version: 5.29.2(@tanstack/react-query@5.29.2(react@18.2.0))(react@18.2.0) '@tanstack/react-router': - specifier: ^1.28.5 - version: 1.28.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + specifier: ^1.31.6 + version: 1.31.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@types/node': specifier: ^20.12.7 version: 20.12.7 @@ -88,9 +88,6 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) - react-error-boundary: - specifier: ^4.0.13 - version: 4.0.13(react@18.2.0) react-hook-form: specifier: ^7.51.3 version: 7.51.3(react@18.2.0) @@ -144,8 +141,8 @@ importers: specifier: ^4.14.3 version: 4.14.3 '@tanstack/router-devtools': - specifier: ^1.28.5 - version: 1.28.5(csstype@3.1.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + specifier: ^1.31.6 + version: 1.31.6(csstype@3.1.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) eslint: specifier: ^8.57.0 version: 8.57.0 @@ -178,7 +175,7 @@ importers: version: 0.19.8(vite@5.2.9(@types/node@20.12.7)(terser@5.30.1))(workbox-build@7.0.0)(workbox-window@7.0.0) vite-plugin-svgr: specifier: ^4.2.0 - version: 4.2.0(@rollup/wasm-node@4.14.3)(typescript@5.4.5)(vite@5.2.9(@types/node@20.12.7)(terser@5.30.1)) + version: 4.2.0(@rollup/wasm-node@4.17.2)(typescript@5.4.5)(vite@5.2.9(@types/node@20.12.7)(terser@5.30.1)) packages: @@ -806,10 +803,6 @@ packages: resolution: {integrity: sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.24.0': - resolution: {integrity: sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.24.1': resolution: {integrity: sha512-+BIznRzyqBf+2wCTxcKE3wDjfGeCoVE61KSHGpkzqrLi8qxqFwBeUFyId2cxkTmm55fzDGnm0+yCxaxygrLUnQ==} engines: {node: '>=6.9.0'} @@ -1170,6 +1163,11 @@ packages: resolution: {integrity: sha512-UyFUQV/iAu/Wt6rY6uQMYBQlfTMsynzYVIz6i7s9ySwjoG9WDNgtkK1TrazCSrUFbmuPZi2gbJm6VWdJCVw2yA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} + '@rollup/wasm-node@4.17.2': + resolution: {integrity: sha512-4F6C3XaUn02XY/GJMQTXncWrLyCkRHdRZe4OyWuQUprWKmU2u+esISOtCYdr3Bp9AqCIo/X3So2Ik7N9dNDwow==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + '@surma/rollup-plugin-off-main-thread@2.2.3': resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} @@ -1321,8 +1319,8 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1' - '@tanstack/history@1.26.10': - resolution: {integrity: sha512-fHx8RQ3liEDhueIemUggBGmqYnK6vOxtxCduolW7r6ExBEQVwKdLEcaUobxp6BxcXLQ7z/qhXAptlOlYi4FFXg==} + '@tanstack/history@1.28.9': + resolution: {integrity: sha512-WgTFJhHaZnGZPyt0H11xFhGGDj1MtA1mrUmdAjB/nhVpmsAYXsSB5O+hkF9N66u7MjbNb405wTb9diBsztvI5w==} engines: {node: '>=12'} '@tanstack/query-core@5.29.0': @@ -1342,8 +1340,8 @@ packages: peerDependencies: react: ^18.2.0 - '@tanstack/react-router@1.28.5': - resolution: {integrity: sha512-d6DHZ2Uw9Gd3Ry3k1GVg4Opi9PYV09CO9zCJKDWWzt07ts5UXPLKgK/nIm8t4gv6DR/VrhUcZsiN0kZnkOQXwg==} + '@tanstack/react-router@1.31.6': + resolution: {integrity: sha512-Al8IwmZQk0km4p/8KKI8dO6bytZfAnw7ukmP2PMSZX0fEFA3sd9gPbnqBZTA/dHdl4qTLPnbdKWUTz8D4BLoyA==} engines: {node: '>=12'} peerDependencies: react: ^18.2.0 @@ -1361,8 +1359,8 @@ packages: react: ^18.2.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - '@tanstack/router-devtools@1.28.5': - resolution: {integrity: sha512-6TGaDW3+RY681so0AAtXkzwQa7hlGrxCRSJCJt+8F8UAxak0fm1Dj4PWJTfGsYx5r/R2DpfUqWilj9KCGsOZKA==} + '@tanstack/router-devtools@1.31.6': + resolution: {integrity: sha512-vtlEk8uu0eiEjGQVN7okE0ly9XJQ1HZO8e2CwTm4nymUn1N+RRHarbVJcSn9Sh3Hynn7MyqrVquo7VDFT6bzGQ==} engines: {node: '>=12'} peerDependencies: react: ^18.2.0 @@ -1374,17 +1372,6 @@ packages: '@tanstack/virtual-core@3.0.0': resolution: {integrity: sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==} - '@testing-library/dom@10.0.0': - resolution: {integrity: sha512-PmJPnogldqoVFf+EwbHvbBJ98MmqASV8kLrBYgsDNxQcFMeIS7JFL48sfyXvuMtgmWO/wMhh25odr+8VhDmn4g==} - engines: {node: '>=18'} - - '@testing-library/react@15.0.2': - resolution: {integrity: sha512-5mzIpuytB1ctpyywvyaY2TAAUQVCZIGqwiqFQf6u9lvj/SJQepGUzNV18Xpk+NLCaCE2j7CWrZE0tEf9xLZYiQ==} - engines: {node: '>=18'} - peerDependencies: - react: ^18.2.0 - react-dom: ^18.0.0 - '@tsconfig/node10@1.0.9': resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} @@ -1397,9 +1384,6 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@types/aria-query@5.0.4': - resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - '@types/estree@0.0.39': resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} @@ -1556,10 +1540,6 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - ansi-styles@6.2.1: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} @@ -1580,9 +1560,6 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - array-buffer-byte-length@1.0.1: resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} engines: {node: '>= 0.4'} @@ -1638,6 +1615,7 @@ packages: autoprefixer@10.4.19: resolution: {integrity: sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==} engines: {node: ^10 || ^12 || >=14} + hasBin: true peerDependencies: postcss: ^8.1.0 @@ -1868,10 +1846,6 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -1894,9 +1868,6 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} - dom-accessibility-api@0.5.16: - resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -2036,6 +2007,7 @@ packages: eslint-watch@8.0.0: resolution: {integrity: sha512-piws/uE4gkZdz1pwkaEFx+kSWvoGnVX8IegFRrE1NUvlXjtU0rg7KhT1QDj/NzhAwbiLEfdRHWz5q738R4zDKA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + hasBin: true peerDependencies: eslint: '>=8 <9.0.0' @@ -2599,9 +2571,6 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - lz-string@1.5.0: - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} - magic-string@0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} @@ -2849,10 +2818,6 @@ packages: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} - pretty-format@27.5.1: - resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - printable-characters@1.0.42: resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} @@ -2879,11 +2844,6 @@ packages: peerDependencies: react: ^18.2.0 - react-error-boundary@4.0.13: - resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==} - peerDependencies: - react: ^18.2.0 - react-fast-compare@2.0.4: resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==} @@ -2906,9 +2866,6 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-is@17.0.2: - resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react-multi-select-component@4.3.4: resolution: {integrity: sha512-Ui/bzCbROF4WfKq3OKWyQJHmy/bd1mW7CQM+L83TfiltuVvHElhKEyPM3JzO9urIcWplBUKv+kyxqmEnd9jPcA==} peerDependencies: @@ -3250,6 +3207,7 @@ packages: ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true peerDependencies: '@swc/core': '>=1.2.50' '@swc/wasm': '>=1.2.50' @@ -3335,6 +3293,7 @@ packages: update-browserslist-db@1.0.13: resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} + hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -3398,6 +3357,7 @@ packages: vite@5.2.9: resolution: {integrity: sha512-uOQWfuZBlc6Y3W/DTuQ1Sr+oIXWvqljLvS881SVmAj00d5RdgShLcuXWxseWPd4HXwiYBFW/vXHfKFeqj9uQnw==} engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true peerDependencies: '@types/node': ^18.0.0 || >=20.0.0 less: '*' @@ -4316,10 +4276,6 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 - '@babel/runtime@7.24.0': - dependencies: - regenerator-runtime: 0.14.1 - '@babel/runtime@7.24.1': dependencies: regenerator-runtime: 0.14.1 @@ -4636,43 +4592,43 @@ snapshots: '@popperjs/core@2.11.8': {} - '@rollup/plugin-babel@5.3.1(@babel/core@7.24.3)(@rollup/wasm-node@4.14.3)': + '@rollup/plugin-babel@5.3.1(@babel/core@7.24.3)(@rollup/wasm-node@4.17.2)': dependencies: '@babel/core': 7.24.3 '@babel/helper-module-imports': 7.24.3 - '@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.14.3) - rollup: '@rollup/wasm-node@4.14.3' + '@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.17.2) + rollup: '@rollup/wasm-node@4.17.2' - '@rollup/plugin-node-resolve@11.2.1(@rollup/wasm-node@4.14.3)': + '@rollup/plugin-node-resolve@11.2.1(@rollup/wasm-node@4.17.2)': dependencies: - '@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.14.3) + '@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.17.2) '@types/resolve': 1.17.1 builtin-modules: 3.3.0 deepmerge: 4.3.1 is-module: 1.0.0 resolve: 1.22.8 - rollup: '@rollup/wasm-node@4.14.3' + rollup: '@rollup/wasm-node@4.17.2' - '@rollup/plugin-replace@2.4.2(@rollup/wasm-node@4.14.3)': + '@rollup/plugin-replace@2.4.2(@rollup/wasm-node@4.17.2)': dependencies: - '@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.14.3) + '@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.17.2) magic-string: 0.25.9 - rollup: '@rollup/wasm-node@4.14.3' + rollup: '@rollup/wasm-node@4.17.2' - '@rollup/pluginutils@3.1.0(@rollup/wasm-node@4.14.3)': + '@rollup/pluginutils@3.1.0(@rollup/wasm-node@4.17.2)': dependencies: '@types/estree': 0.0.39 estree-walker: 1.0.1 picomatch: 2.3.1 - rollup: '@rollup/wasm-node@4.14.3' + rollup: '@rollup/wasm-node@4.17.2' - '@rollup/pluginutils@5.1.0(@rollup/wasm-node@4.14.3)': + '@rollup/pluginutils@5.1.0(@rollup/wasm-node@4.17.2)': dependencies: '@types/estree': 1.0.5 estree-walker: 2.0.2 picomatch: 2.3.1 optionalDependencies: - rollup: '@rollup/wasm-node@4.14.3' + rollup: '@rollup/wasm-node@4.17.2' '@rollup/wasm-node@4.14.3': dependencies: @@ -4680,6 +4636,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + '@rollup/wasm-node@4.17.2': + dependencies: + '@types/estree': 1.0.5 + optionalDependencies: + fsevents: 2.3.3 + '@surma/rollup-plugin-off-main-thread@2.2.3': dependencies: ejs: 3.1.9 @@ -4812,7 +4774,7 @@ snapshots: mini-svg-data-uri: 1.4.4 tailwindcss: 3.4.3(ts-node@10.9.2(@swc/core@1.4.2)(@types/node@20.12.7)(typescript@5.4.5)) - '@tanstack/history@1.26.10': {} + '@tanstack/history@1.28.9': {} '@tanstack/query-core@5.29.0': {} @@ -4829,11 +4791,10 @@ snapshots: '@tanstack/query-core': 5.29.0 react: 18.2.0 - '@tanstack/react-router@1.28.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + '@tanstack/react-router@1.31.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@tanstack/history': 1.26.10 + '@tanstack/history': 1.28.9 '@tanstack/react-store': 0.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@testing-library/react': 15.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) tiny-invariant: 1.3.3 @@ -4852,9 +4813,9 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@tanstack/router-devtools@1.28.5(csstype@3.1.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + '@tanstack/router-devtools@1.31.6(csstype@3.1.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@tanstack/react-router': 1.28.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@tanstack/react-router': 1.31.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) clsx: 2.1.0 date-fns: 2.30.0 goober: 2.1.14(csstype@3.1.2) @@ -4867,25 +4828,6 @@ snapshots: '@tanstack/virtual-core@3.0.0': {} - '@testing-library/dom@10.0.0': - dependencies: - '@babel/code-frame': 7.24.2 - '@babel/runtime': 7.24.1 - '@types/aria-query': 5.0.4 - aria-query: 5.3.0 - chalk: 4.1.2 - dom-accessibility-api: 0.5.16 - lz-string: 1.5.0 - pretty-format: 27.5.1 - - '@testing-library/react@15.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@babel/runtime': 7.24.1 - '@testing-library/dom': 10.0.0 - '@types/react-dom': 18.2.25 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - '@tsconfig/node10@1.0.9': {} '@tsconfig/node12@1.0.11': {} @@ -4894,8 +4836,6 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@types/aria-query@5.0.4': {} - '@types/estree@0.0.39': {} '@types/estree@1.0.5': {} @@ -5081,8 +5021,6 @@ snapshots: dependencies: color-convert: 2.0.1 - ansi-styles@5.2.0: {} - ansi-styles@6.2.1: {} any-promise@1.3.0: {} @@ -5098,10 +5036,6 @@ snapshots: argparse@2.0.1: {} - aria-query@5.3.0: - dependencies: - dequal: 2.0.3 - array-buffer-byte-length@1.0.1: dependencies: call-bind: 1.0.7 @@ -5438,8 +5372,6 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - dequal@2.0.3: {} - didyoumean@1.2.2: {} diff@4.0.2: {} @@ -5458,8 +5390,6 @@ snapshots: dependencies: esutils: 2.0.3 - dom-accessibility-api@0.5.16: {} - dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.23.4 @@ -6290,8 +6220,6 @@ snapshots: dependencies: yallist: 4.0.0 - lz-string@1.5.0: {} - magic-string@0.25.9: dependencies: sourcemap-codec: 1.4.8 @@ -6522,12 +6450,6 @@ snapshots: pretty-bytes@6.1.1: {} - pretty-format@27.5.1: - dependencies: - ansi-regex: 5.0.1 - ansi-styles: 5.2.0 - react-is: 17.0.2 - printable-characters@1.0.42: {} prop-types@15.8.1: @@ -6556,11 +6478,6 @@ snapshots: react: 18.2.0 scheduler: 0.23.0 - react-error-boundary@4.0.13(react@18.2.0): - dependencies: - '@babel/runtime': 7.24.0 - react: 18.2.0 - react-fast-compare@2.0.4: {} react-fast-compare@3.2.2: {} @@ -6579,8 +6496,6 @@ snapshots: react-is@16.13.1: {} - react-is@17.0.2: {} - react-multi-select-component@4.3.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: react: 18.2.0 @@ -6728,11 +6643,11 @@ snapshots: dependencies: glob: 7.2.3 - rollup-plugin-terser@7.0.2(@rollup/wasm-node@4.14.3): + rollup-plugin-terser@7.0.2(@rollup/wasm-node@4.17.2): dependencies: '@babel/code-frame': 7.24.2 jest-worker: 26.6.2 - rollup: '@rollup/wasm-node@4.14.3' + rollup: '@rollup/wasm-node@4.17.2' serialize-javascript: 4.0.0 terser: 5.30.1 @@ -7150,9 +7065,9 @@ snapshots: transitivePeerDependencies: - supports-color - vite-plugin-svgr@4.2.0(@rollup/wasm-node@4.14.3)(typescript@5.4.5)(vite@5.2.9(@types/node@20.12.7)(terser@5.30.1)): + vite-plugin-svgr@4.2.0(@rollup/wasm-node@4.17.2)(typescript@5.4.5)(vite@5.2.9(@types/node@20.12.7)(terser@5.30.1)): dependencies: - '@rollup/pluginutils': 5.1.0(@rollup/wasm-node@4.14.3) + '@rollup/pluginutils': 5.1.0(@rollup/wasm-node@4.17.2) '@svgr/core': 8.1.0(typescript@5.4.5) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.4.5)) vite: 5.2.9(@types/node@20.12.7)(terser@5.30.1) @@ -7165,7 +7080,7 @@ snapshots: dependencies: esbuild: 0.20.2 postcss: 8.4.38 - rollup: '@rollup/wasm-node@4.14.3' + rollup: '@rollup/wasm-node@4.17.2' optionalDependencies: '@types/node': 20.12.7 fsevents: 2.3.3 @@ -7240,9 +7155,9 @@ snapshots: '@babel/core': 7.24.3 '@babel/preset-env': 7.24.3(@babel/core@7.24.3) '@babel/runtime': 7.24.1 - '@rollup/plugin-babel': 5.3.1(@babel/core@7.24.3)(@rollup/wasm-node@4.14.3) - '@rollup/plugin-node-resolve': 11.2.1(@rollup/wasm-node@4.14.3) - '@rollup/plugin-replace': 2.4.2(@rollup/wasm-node@4.14.3) + '@rollup/plugin-babel': 5.3.1(@babel/core@7.24.3)(@rollup/wasm-node@4.17.2) + '@rollup/plugin-node-resolve': 11.2.1(@rollup/wasm-node@4.17.2) + '@rollup/plugin-replace': 2.4.2(@rollup/wasm-node@4.17.2) '@surma/rollup-plugin-off-main-thread': 2.2.3 ajv: 8.12.0 common-tags: 1.8.2 @@ -7251,8 +7166,8 @@ snapshots: glob: 7.2.3 lodash: 4.17.21 pretty-bytes: 5.6.0 - rollup: '@rollup/wasm-node@4.14.3' - rollup-plugin-terser: 7.0.2(@rollup/wasm-node@4.14.3) + rollup: '@rollup/wasm-node@4.17.2' + rollup-plugin-terser: 7.0.2(@rollup/wasm-node@4.17.2) source-map: 0.8.0-beta.0 stringify-object: 3.3.0 strip-comments: 2.0.1 diff --git a/web/src/App.tsx b/web/src/App.tsx index 1e4909a..ebae8d4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,7 +11,7 @@ import { Portal } from "react-portal"; import { Router } from "@app/routes"; import { routerBasePath } from "@utils"; import { queryClient } from "@api/QueryClient"; -import { AuthContext, SettingsContext } from "@utils/Context"; +import { SettingsContext } from "@utils/Context"; declare module '@tanstack/react-router' { interface Register { @@ -33,17 +33,14 @@ export function App() { }, [setSettings]); return ( - - - - - - + + + + + + ); -} \ No newline at end of file +} diff --git a/web/src/api/APIClient.ts b/web/src/api/APIClient.ts index fd4e774..3bb5f1a 100644 --- a/web/src/api/APIClient.ts +++ b/web/src/api/APIClient.ts @@ -5,17 +5,65 @@ import { baseUrl, sseBaseUrl } from "@utils"; import { GithubRelease } from "@app/types/Update"; +import { AuthContext } from "@utils/Context"; type RequestBody = BodyInit | object | Record | null; type Primitive = string | number | boolean | symbol | undefined; interface HttpConfig { + /** + * One of "GET", "POST", "PUT", "PATCH", "DELETE", etc. + * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods + */ method?: string; + /** + * JSON body for this request. Once this is set to an object, + * then `Content-Type` for this request is set to `application/json` + * automatically. + */ body?: RequestBody; + /** + * Helper to work with a query string/search param of a URL. + * E.g. ?a=1&b=2&c=3 + * + * Using this interface will automatically convert + * the object values into RFC-3986-compliant strings. + * + * Keys will *NOT* be sanitized, and any whitespace and + * invalid characters will remain. + * + * The only supported value types are: + * numbers, booleans, strings and flat 1-D arrays. + * + * Objects as values are not supported. + * + * The supported values are serialized as follows: + * - undefined values are ignored + * - empty strings are ignored + * - empty strings inside arrays are ignored + * - empty arrays are ignored + * - arrays append each time with the key and for each child + * e.g. `{ arr: [1, 2, 3] }` will yield `?arr=1&arr=2&arr=3` + * - array items with an undefined value (or which serialize to an empty string) are ignored, + * e.g. `{ arr: [1, undefined, undefined] }` will yield `?arr=1` + * (NaN, +Inf, -Inf, etc. will remain since they are valid serializations) + */ queryString?: Record; } -// See https://stackoverflow.com/a/62969380 +/** + * Encodes a string into a RFC-3986-compliant string. + * + * By default, encodeURIComponent will not encode + * any of the following characters: !'()* + * + * So a simple regex replace is done which will replace + * these characters with their hex-value representation. + * + * @param str Input string (dictionary value). + * @returns A RFC-3986-compliant string variation of the input string. + * @note See https://stackoverflow.com/a/62969380 + */ function encodeRFC3986URIComponent(str: string): string { return encodeURIComponent(str).replace( /[!'()*]/g, @@ -23,6 +71,29 @@ function encodeRFC3986URIComponent(str: string): string { ); } +/** + * Makes a request on the network and returns a promise. + * + * This function serves as both a request builder and a response interceptor. + * + * @param endpoint The endpoint path relative to the backend instance. + * @param config A dictionary which specifies what information this network + * request must relay during transport. See @ref HttpClient. + * @returns A promise for the *sent* network request which must * be await'ed or .then()-chained before it can be used. + * + * If the status code returned by the server is in the [200, 300) range, then this is considered a success. + * - This function resolves with an empty dictionary object, i.e. {}, if the status code is 204 No data + * - The parsed JSON body is returned by this method if the server returns `Content-Type: application/json`. + * - In all other scenarios, the raw Response object from window.fetch() is returned, + * which must be handled manually by awaiting on one of its methods. + * + * The following is done if the status code that the server returns is NOT successful, + * that is, if it falls outside of the [200, 300] range: + * - A unique Error object is returned if the user is logged in and the status code is 403 Forbidden. + * This Error object *should* be consumed by the @tanstack/query code, which indirectly calls HttpClient. + * The current user is then prompted to log in again after being logged out. + * - The `ErrorPage` screen appears in all other scenarios. + */ export async function HttpClient( endpoint: string, config: HttpConfig = {} @@ -81,51 +152,54 @@ export async function HttpClient( const response = await window.fetch(`${baseUrl()}${endpoint}`, init); - const isJson = response.headers.get("Content-Type")?.includes("application/json"); - const json = isJson ? await response.json() : null; - - switch (response.status) { - case 204: { - // 204 contains no data, but indicates success - return Promise.resolve({} as T); - } - case 401: { - return Promise.reject(json as T); - } - case 403: { - return Promise.reject(json as T); - } - case 404: { - return Promise.reject(json as T); - } - case 500: { - const health = await window.fetch(`${baseUrl()}api/healthz/liveness`); - if (!health.ok) { - return Promise.reject( - new Error(`[500] Offline (Internal server error): "${endpoint}"`) - ); - } - break; - } - case 503: { - // Show an error toast to notify the user what occurred - return Promise.reject(new Error(`[503] Service unavailable: "${endpoint}"`)); - } - default: - break; - } - - // Resolve on success if (response.status >= 200 && response.status < 300) { + // We received a successful response + if (response.status === 204) { + // 204 contains no data, but indicates success + return Promise.resolve({} as T); + } + + // If Content-Type is application/json, then parse response as JSON + // otherwise, just resolve the Response object returned by window.fetch + // and the consumer can call await response.text() if needed. + const isJson = response.headers.get("Content-Type")?.includes("application/json"); if (isJson) { - return Promise.resolve(json as T); + return Promise.resolve(await response.json() as T); } else { return Promise.resolve(response as T); } - } + } else { + // This is not a successful response. + // It is most likely an error. + switch (response.status) { + case 403: { + if (AuthContext.get().isLoggedIn) { + return Promise.reject(new Error("Cookie expired or invalid.")); + } + break; + } + case 500: { + const health = await window.fetch(`${baseUrl()}api/healthz/liveness`); + if (!health.ok) { + return Promise.reject( + new Error(`[500] Offline (Internal server error): "${endpoint}"`) + ); + } + break; + } + case 503: { + // Show an error toast to notify the user what occurred + return Promise.reject(new Error(`[503] Service unavailable: "${endpoint}"`)); + } + default: + break; + } - // Otherwise reject, this is most likely an error - return Promise.reject(json as T); + const defaultError = new Error( + `HTTP request to '${endpoint}' failed with code ${response.status} (${response.statusText})` + ); + return Promise.reject(defaultError); + } } const appClient = { diff --git a/web/src/api/QueryClient.tsx b/web/src/api/QueryClient.tsx index 1dd57b6..1ef3b68 100644 --- a/web/src/api/QueryClient.tsx +++ b/web/src/api/QueryClient.tsx @@ -6,26 +6,31 @@ import { QueryCache, QueryClient } from "@tanstack/react-query"; import { toast } from "react-hot-toast"; import Toast from "@components/notifications/Toast"; -import { baseUrl } from "@utils"; +import { AuthContext } from "@utils/Context"; +import { redirect } from "@tanstack/react-router"; +import { LoginRoute } from "@app/routes"; const MAX_RETRIES = 6; -const HTTP_STATUS_TO_NOT_RETRY = [400, 401, 403, 404]; export const queryClient = new QueryClient({ queryCache: new QueryCache({ - onError: (error ) => { - console.error("query client error: ", error); + onError: (error, query) => { + console.error(`Caught error for query '${query.queryKey}': `, error); - toast.custom((t) => ); - - // @ts-expect-error TS2339: Property status does not exist on type Error - if (error?.status === 401 || error?.status === 403) { - // @ts-expect-error TS2339: Property status does not exist on type Error - console.error("bad status, redirect to login", error?.status) - // Redirect to login page - window.location.href = baseUrl()+"login"; - - return + if (error.message === "Cookie expired or invalid.") { + AuthContext.reset(); + redirect({ + to: LoginRoute.to, + search: { + // Use the current location to power a redirect after login + // (Do not use `router.state.resolvedLocation` as it can + // potentially lag behind the actual current location) + redirect: location.href + }, + }); + return; + } else { + toast.custom((t) => ); } } }), @@ -35,8 +40,12 @@ export const queryClient = new QueryClient({ // See https://tanstack.com/query/v4/docs/guides/query-retries#retry-delay // delay = Math.min(1000 * 2 ** attemptIndex, 30000) // retry: false, - throwOnError: true, + throwOnError: (error) => { + return error.message !== "Cookie expired or invalid."; + + }, retry: (failureCount, error) => { + /* console.debug("retry count:", failureCount) console.error("retry err: ", error) @@ -46,7 +55,12 @@ export const queryClient = new QueryClient({ console.log(`retry: Aborting retry due to ${error.status} status`); return false; } + */ + if (error.message === "Cookie expired or invalid.") { + return false; + } + console.error(`Retrying query (N=${failureCount}): `, error); return failureCount <= MAX_RETRIES; }, }, @@ -54,8 +68,9 @@ export const queryClient = new QueryClient({ onError: (error) => { console.log("mutation error: ", error) + // TODO: Maybe unneeded with our little HttpClient refactor. if (error instanceof Response) { - return + return; } // Use a format string to convert the error object to a proper string without much hassle. @@ -68,4 +83,4 @@ export const queryClient = new QueryClient({ } } } -}); \ No newline at end of file +}); diff --git a/web/src/components/alerts/ErrorPage.tsx b/web/src/components/alerts/ErrorPage.tsx index c1f88af..0f6e951 100644 --- a/web/src/components/alerts/ErrorPage.tsx +++ b/web/src/components/alerts/ErrorPage.tsx @@ -4,36 +4,43 @@ */ import StackTracey from "stacktracey"; -import type { FallbackProps } from "react-error-boundary"; import { ArrowPathIcon } from "@heroicons/react/24/solid"; import { ExternalLink } from "@components/ExternalLink"; -export const ErrorPage = ({ error, resetErrorBoundary }: FallbackProps) => { - const stack = new StackTracey(error); - const summary = stack.clean().asTable({ - maxColumnWidths: { - callee: 48, - file: 48, - sourceLine: 384 - } - }); +type ErrorPageProps = { + error: unknown; + reset: () => void; +} - const parseTitle = () => { - switch (error?.cause) { - case "OFFLINE": { - return "Connection to Autobrr failed! Check the application state and verify your connectivity."; +export const ErrorPage = ({ error, reset }: ErrorPageProps) => { + let pageTitle = "We caught an unrecoverable error!"; + let errorLine: string, summary =""; + + if (error instanceof Error) { + const stack = new StackTracey(error); + summary = stack.clean().asTable({ + maxColumnWidths: { + callee: 48, + file: 48, + sourceLine: 384 + } + }); + + if (error.cause === "OFFLINE") { + pageTitle = "Connection to Autobrr failed! Check the application state and verify your connectivity."; } - default: { - return "We caught an unrecoverable error!"; - } - } - }; + + errorLine = error.toString(); + } else { + errorLine = String(error); + // Leave summary blank? + } return (

- {parseTitle()} + {pageTitle}

Please consider reporting this error to our @@ -60,14 +67,14 @@ export const ErrorPage = ({ error, resetErrorBoundary }: FallbackProps) => { >
+ xmlns="http://www.w3.org/2000/svg"> -

{error.toString()}

+

{errorLine}

{summary ? (
@@ -83,7 +90,7 @@ export const ErrorPage = ({ error, resetErrorBoundary }: FallbackProps) => {
             className="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 mr-2 text-center inline-flex items-center dark:bg-red-800 dark:hover:bg-red-900"
             onClick={(event) => {
               event.preventDefault();
-              resetErrorBoundary();
+              reset();
             }}
           >
             
diff --git a/web/src/components/header/Header.tsx b/web/src/components/header/Header.tsx
index 34b9d6a..7036fcb 100644
--- a/web/src/components/header/Header.tsx
+++ b/web/src/components/header/Header.tsx
@@ -16,13 +16,11 @@ import { LeftNav } from "./LeftNav";
 import { RightNav } from "./RightNav";
 import { MobileNav } from "./MobileNav";
 import { ExternalLink } from "@components/ExternalLink";
-
-import { AuthIndexRoute } from "@app/routes";
 import { ConfigQueryOptions, UpdatesQueryOptions } from "@api/queries";
+import { AuthContext } from "@utils/Context";
 
 export const Header = () => {
   const router = useRouter()
-  const { auth } = AuthIndexRoute.useRouteContext()
 
   const { isError:isConfigError, error: configError, data: config } = useQuery(ConfigQueryOptions(true));
   if (isConfigError) {
@@ -40,9 +38,8 @@ export const Header = () => {
       toast.custom((t) => (
         
       ));
-      auth.logout()
-
-      router.history.push("/")
+      AuthContext.reset();
+      router.history.push("/");
     },
     onError: (err) => {
       console.error("logout error", err)
@@ -60,7 +57,7 @@ export const Header = () => {
             
- +
{/* Mobile menu button */} @@ -92,7 +89,7 @@ export const Header = () => { )}
- + )} diff --git a/web/src/components/header/RightNav.tsx b/web/src/components/header/RightNav.tsx index 32a4fdb..0f3bccb 100644 --- a/web/src/components/header/RightNav.tsx +++ b/web/src/components/header/RightNav.tsx @@ -13,7 +13,7 @@ import { RightNavProps } from "./_shared"; import { Cog6ToothIcon, ArrowLeftOnRectangleIcon, MoonIcon, SunIcon } from "@heroicons/react/24/outline"; import { Link } from "@tanstack/react-router"; -import { SettingsContext } from "@utils/Context"; +import { AuthContext, SettingsContext } from "@utils/Context"; export const RightNav = (props: RightNavProps) => { const [settings, setSettings] = SettingsContext.use(); @@ -56,7 +56,7 @@ export const RightNav = (props: RightNavProps) => { Open user menu for{" "} - {props.auth.username} + {AuthContext.get().username} void; - auth: AuthCtx } export const NAV_ROUTES: Array = [ diff --git a/web/src/routes.tsx b/web/src/routes.tsx index 8c10ed9..4e141fa 100644 --- a/web/src/routes.tsx +++ b/web/src/routes.tsx @@ -7,11 +7,11 @@ import { createRootRouteWithContext, createRoute, createRouter, - ErrorComponent, + Navigate, notFound, Outlet, redirect, -} from "@tanstack/react-router"; + } from "@tanstack/react-router"; import { z } from "zod"; import { QueryClient } from "@tanstack/react-query"; @@ -46,11 +46,13 @@ import DownloadClientSettings from "@screens/settings/DownloadClient"; import FeedSettings from "@screens/settings/Feed"; import { Dashboard } from "@screens/Dashboard"; import AccountSettings from "@screens/settings/Account"; -import { AuthContext, AuthCtx, localStorageUserKey, SettingsContext } from "@utils/Context"; +import { AuthContext, SettingsContext } from "@utils/Context"; import { TanStackRouterDevtools } from "@tanstack/router-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { queryClient } from "@api/QueryClient"; +import { ErrorPage } from "@components/alerts"; + const DashboardRoute = createRoute({ getParentRoute: () => AuthIndexRoute, path: '/', @@ -133,16 +135,9 @@ export const FilterActionsRoute = createRoute({ component: Actions }); -const ReleasesRoute = createRoute({ +export const ReleasesRoute = createRoute({ getParentRoute: () => AuthIndexRoute, - path: 'releases' -}); - -// type ReleasesSearch = z.infer - -export const ReleasesIndexRoute = createRoute({ - getParentRoute: () => ReleasesRoute, - path: '/', + path: 'releases', component: Releases, validateSearch: (search) => z.object({ offset: z.number().optional(), @@ -260,7 +255,7 @@ export const LoginRoute = createRoute({ validateSearch: z.object({ redirect: z.string().optional(), }), - beforeLoad: ({ navigate}) => { + beforeLoad: ({ navigate }) => { // handle canOnboard APIClient.auth.canOnboard().then(() => { console.info("onboarding available, redirecting") @@ -277,44 +272,36 @@ export const AuthRoute = createRoute({ id: 'auth', // Before loading, authenticate the user via our auth context // This will also happen during prefetching (e.g. hovering over links, etc.) - beforeLoad: ({context, location}) => { + beforeLoad: ({ context, location }) => { // If the user is not logged in, check for item in localStorage - if (!context.auth.isLoggedIn) { - const storage = localStorage.getItem(localStorageUserKey); - if (storage) { - try { - const json = JSON.parse(storage); - if (json === null) { - console.warn(`JSON localStorage value for '${localStorageUserKey}' context state is null`); - } else { - context.auth.isLoggedIn = json.isLoggedIn - context.auth.username = json.username - } - } catch (e) { - console.error(`auth Failed to merge ${localStorageUserKey} context state: ${e}`); - } - } else { - // If the user is logged out, redirect them to the login page - throw redirect({ - to: LoginRoute.to, - search: { - // Use the current location to power a redirect after login - // (Do not use `router.state.resolvedLocation` as it can - // potentially lag behind the actual current location) - redirect: location.href, - }, - }) - } + if (!AuthContext.get().isLoggedIn) { + throw redirect({ + to: LoginRoute.to, + search: { + // Use the current location to power a redirect after login + // (Do not use `router.state.resolvedLocation` as it can + // potentially lag behind the actual current location) + redirect: location.href, + }, + }); } // Otherwise, return the user in context - return { - username: AuthContext.username, - } + return context; }, }) function AuthenticatedLayout() { + const isLoggedIn = AuthContext.useSelector((s) => s.isLoggedIn); + if (!isLoggedIn) { + const redirect = ( + location.pathname.length > 1 + ? { redirect: location.pathname } + : undefined + ); + return ; + } + return (
@@ -345,7 +332,6 @@ export const RootComponent = () => { } export const RootRoute = createRootRouteWithContext<{ - auth: AuthCtx, queryClient: QueryClient }>()({ component: RootComponent, @@ -354,7 +340,7 @@ export const RootRoute = createRootRouteWithContext<{ const filterRouteTree = FiltersRoute.addChildren([FilterIndexRoute, FilterGetByIdRoute.addChildren([FilterGeneralRoute, FilterMoviesTvRoute, FilterMusicRoute, FilterAdvancedRoute, FilterExternalRoute, FilterActionsRoute])]) const settingsRouteTree = SettingsRoute.addChildren([SettingsIndexRoute, SettingsLogRoute, SettingsIndexersRoute, SettingsIrcRoute, SettingsFeedsRoute, SettingsClientsRoute, SettingsNotificationsRoute, SettingsApiRoute, SettingsReleasesRoute, SettingsAccountRoute]) -const authenticatedTree = AuthRoute.addChildren([AuthIndexRoute.addChildren([DashboardRoute, filterRouteTree, ReleasesRoute.addChildren([ReleasesIndexRoute]), settingsRouteTree, LogsRoute])]) +const authenticatedTree = AuthRoute.addChildren([AuthIndexRoute.addChildren([DashboardRoute, filterRouteTree, ReleasesRoute, settingsRouteTree, LogsRoute])]) const routeTree = RootRoute.addChildren([ authenticatedTree, LoginRoute, @@ -368,9 +354,10 @@ export const Router = createRouter({
), - defaultErrorComponent: ({error}) => , + defaultErrorComponent: (ctx) => ( + + ), context: { - auth: undefined!, // We'll inject this when we render queryClient }, }); diff --git a/web/src/screens/Settings.tsx b/web/src/screens/Settings.tsx index 3542130..4252c7c 100644 --- a/web/src/screens/Settings.tsx +++ b/web/src/screens/Settings.tsx @@ -26,16 +26,16 @@ interface NavTabType { } const subNavigation: NavTabType[] = [ - { name: "Application", href: ".", icon: CogIcon, exact: true }, - { name: "Logs", href: "logs", icon: Square3Stack3DIcon }, - { name: "Indexers", href: "indexers", icon: KeyIcon }, - { name: "IRC", href: "irc", icon: ChatBubbleLeftRightIcon }, - { name: "Feeds", href: "feeds", icon: RssIcon }, - { name: "Clients", href: "clients", icon: FolderArrowDownIcon }, - { name: "Notifications", href: "notifications", icon: BellIcon }, - { name: "API keys", href: "api", icon: KeyIcon }, - { name: "Releases", href: "releases", icon: RectangleStackIcon }, - { name: "Account", href: "account", icon: UserCircleIcon } + { name: "Application", href: "/settings", icon: CogIcon, exact: true }, + { name: "Logs", href: "/settings/logs", icon: Square3Stack3DIcon }, + { name: "Indexers", href: "/settings/indexers", icon: KeyIcon }, + { name: "IRC", href: "/settings/irc", icon: ChatBubbleLeftRightIcon }, + { name: "Feeds", href: "/settings/feeds", icon: RssIcon }, + { name: "Clients", href: "/settings/clients", icon: FolderArrowDownIcon }, + { name: "Notifications", href: "/settings/notifications", icon: BellIcon }, + { name: "API keys", href: "/settings/api", icon: KeyIcon }, + { name: "Releases", href: "/settings/releases", icon: RectangleStackIcon }, + { name: "Account", href: "/settings/account", icon: UserCircleIcon } // {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false} // {name: 'Rules', href: 'rules', icon: ClipboardCheckIcon, current: false}, ]; diff --git a/web/src/screens/auth/Login.tsx b/web/src/screens/auth/Login.tsx index cc85893..e0cc5de 100644 --- a/web/src/screens/auth/Login.tsx +++ b/web/src/screens/auth/Login.tsx @@ -5,7 +5,7 @@ import React, { useEffect } from "react"; import { useForm } from "react-hook-form"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryErrorResetBoundary } from "@tanstack/react-query"; import { useRouter, useSearch } from "@tanstack/react-router"; import toast from "react-hot-toast"; @@ -18,15 +18,20 @@ import { PasswordInput, TextInput } from "@components/inputs/text"; import { LoginRoute } from "@app/routes"; import Logo from "@app/logo.svg?react"; +import { AuthContext } from "@utils/Context"; +// import { WarningAlert } from "@components/alerts"; type LoginFormFields = { username: string; password: string; }; -export const Login = () => { +export const Login = () => { + const [auth, setAuth] = AuthContext.use(); + + const queryErrorResetBoundary = useQueryErrorResetBoundary() + const router = useRouter() - const { auth } = LoginRoute.useRouteContext() const search = useSearch({ from: LoginRoute.id }) const { handleSubmit, register, formState } = useForm({ @@ -35,14 +40,19 @@ export const Login = () => { }); useEffect(() => { + queryErrorResetBoundary.reset() // remove user session when visiting login page - auth.logout() - }, []); + AuthContext.reset(); + }, [queryErrorResetBoundary]); const loginMutation = useMutation({ mutationFn: (data: LoginFormFields) => APIClient.auth.login(data.username, data.password), onSuccess: (_, variables: LoginFormFields) => { - auth.login(variables.username) + queryErrorResetBoundary.reset() + setAuth({ + isLoggedIn: true, + username: variables.username + }); router.invalidate() }, onError: (error) => { @@ -60,7 +70,7 @@ export const Login = () => { } else if (auth.isLoggedIn) { router.history.push("/") } - }, [auth.isLoggedIn, search.redirect]) + }, [auth.isLoggedIn, search.redirect]) // eslint-disable-line react-hooks/exhaustive-deps return (
diff --git a/web/src/screens/auth/Onboarding.tsx b/web/src/screens/auth/Onboarding.tsx index 0978174..49f63bd 100644 --- a/web/src/screens/auth/Onboarding.tsx +++ b/web/src/screens/auth/Onboarding.tsx @@ -43,7 +43,7 @@ export const Onboarding = () => { const mutation = useMutation({ mutationFn: (data: InputValues) => APIClient.auth.onboard(data.username, data.password1), - onSuccess: () => navigate({ to: "/" }) + onSuccess: () => navigate({ to: "/login" }) }); return ( diff --git a/web/src/screens/filters/Details.tsx b/web/src/screens/filters/Details.tsx index d71aa4a..16baf9a 100644 --- a/web/src/screens/filters/Details.tsx +++ b/web/src/screens/filters/Details.tsx @@ -33,12 +33,12 @@ interface tabType { } const tabs: tabType[] = [ - { name: "General", href: ".", exact: true }, - { name: "Movies and TV", href: "movies-tv" }, - { name: "Music", href: "music" }, - { name: "Advanced", href: "advanced" }, - { name: "External", href: "external" }, - { name: "Actions", href: "actions" } + { name: "General", href: "/filters/$filterId", exact: true }, + { name: "Movies and TV", href: "/filters/$filterId/movies-tv" }, + { name: "Music", href: "/filters/$filterId/music" }, + { name: "Advanced", href: "/filters/$filterId/advanced" }, + { name: "External", href: "/filters/$filterId/external" }, + { name: "Actions", href: "/filters/$filterId/actions" } ]; export interface NavLinkProps { diff --git a/web/src/screens/releases/ReleaseTable.tsx b/web/src/screens/releases/ReleaseTable.tsx index db482d3..9febb46 100644 --- a/web/src/screens/releases/ReleaseTable.tsx +++ b/web/src/screens/releases/ReleaseTable.tsx @@ -16,7 +16,7 @@ import { EyeSlashIcon } from "@heroicons/react/24/solid"; -import { ReleasesIndexRoute } from "@app/routes"; +import { ReleasesRoute } from "@app/routes"; import { ReleasesListQueryOptions } from "@api/queries"; import { RandomLinuxIsos } from "@utils"; @@ -94,7 +94,7 @@ const EmptyReleaseList = () => ( ); export const ReleaseTable = () => { - const search = ReleasesIndexRoute.useSearch() + const search = ReleasesRoute.useSearch() const columns = React.useMemo(() => [ { diff --git a/web/src/screens/settings/Account.tsx b/web/src/screens/settings/Account.tsx index 8f5f10b..1e72414 100644 --- a/web/src/screens/settings/Account.tsx +++ b/web/src/screens/settings/Account.tsx @@ -8,12 +8,11 @@ import { Form, Formik } from "formik"; import toast from "react-hot-toast"; import { UserIcon } from "@heroicons/react/24/solid"; -import { SettingsAccountRoute } from "@app/routes"; -import { AuthContext } from "@utils/Context"; import { APIClient } from "@api/APIClient"; import { Section } from "./_components"; import { PasswordField, TextField } from "@components/inputs"; import Toast from "@components/notifications/Toast"; +import { AuthContext } from "@utils/Context"; const AccountSettings = () => (
s.username); const validate = (values: InputValues) => { const errors: Record = {}; @@ -52,7 +51,7 @@ function Credentials() { const logoutMutation = useMutation({ mutationFn: APIClient.auth.logout, onSuccess: () => { - AuthContext.logout(); + AuthContext.reset(); toast.custom((t) => ( @@ -78,7 +77,7 @@ function Credentials() {
( ctxState.set(values); } +const AuthKey = "autobrr_user_auth"; const SettingsKey = "autobrr_settings"; const FilterListKey = "autobrr_filter_list"; export const InitializeGlobalContext = () => { - // ContextMerger(localStorageUserKey, AuthContextDefaults, AuthContextt); + ContextMerger(AuthKey, AuthContextDefaults, AuthContext); ContextMerger( SettingsKey, SettingsContextDefaults, @@ -101,9 +102,12 @@ function DefaultSetter(name: string, newState: T, prevState: T) { } } -// export const AuthContextt = newRidgeState(AuthContextDefaults, { -// onSet: (newState, prevState) => DefaultSetter(localStorageUserKey, newState, prevState) -// }); +export const AuthContext = newRidgeState( + AuthContextDefaults, + { + onSet: (newState, prevState) => DefaultSetter(AuthKey, newState, prevState) + } +); export const SettingsContext = newRidgeState( SettingsContextDefaults, @@ -121,29 +125,3 @@ export const FilterListContext = newRidgeState( onSet: (newState, prevState) => DefaultSetter(FilterListKey, newState, prevState) } ); - -export type AuthCtx = { - isLoggedIn: boolean - username?: string - login: (username: string) => void - logout: () => void -} - -export const localStorageUserKey = "autobrr_user_auth" - -export const AuthContext: AuthCtx = { - isLoggedIn: false, - username: undefined, - login: (username: string) => { - AuthContext.isLoggedIn = true - AuthContext.username = username - - localStorage.setItem(localStorageUserKey, JSON.stringify(AuthContext)); - }, - logout: () => { - AuthContext.isLoggedIn = false - AuthContext.username = undefined - - localStorage.removeItem(localStorageUserKey); - }, -} \ No newline at end of file