diff --git a/frontend/package.json b/frontend/package.json index e5a729d5..862e5afa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,7 +31,9 @@ "devDependencies": { "@esm-bundle/chai": "^4.3.4-fix.0", "@lit/localize-tools": "^0.5.0", + "@open-wc/testing": "^3.0.3", "@types/color": "^3.0.2", + "@types/sinon": "^10.0.6", "@typescript-eslint/eslint-plugin": "^5.4.0", "@typescript-eslint/parser": "^5.4.0", "@web/dev-server-esbuild": "^0.2.16", diff --git a/frontend/src/index.test.ts b/frontend/src/index.test.ts index 621f7a26..72572679 100644 --- a/frontend/src/index.test.ts +++ b/frontend/src/index.test.ts @@ -1,9 +1,27 @@ -import { expect } from "@esm-bundle/chai"; +import { spy, stub } from "sinon"; +import { fixture, expect } from "@open-wc/testing"; +// import { expect } from "@esm-bundle/chai"; import { App } from "./index"; -describe("App", () => { - it("should exist", () => { - expect(App).to.exist; +describe("browsertrix-app", () => { + it("is defined", async () => { + const el = await fixture(""); + expect(el).instanceOf(App); + }); + + it("gets auth state from local storage", async () => { + stub(window.localStorage, "getItem").callsFake((key) => { + if (key === "authState") + return JSON.stringify({ + username: "test@example.com", + }); + return null; + }); + const el = (await fixture("")) as App; + + expect(el.authState).to.eql({ + username: "test@example.com", + }); }); }); diff --git a/frontend/src/index.ts b/frontend/src/index.ts index a538e9d1..551e5891 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -1,4 +1,5 @@ import type { TemplateResult } from "lit"; +import { state } from "lit/decorators.js"; import { msg, updateWhenLocaleChanges } from "@lit/localize"; import "./shoelace"; @@ -13,10 +14,22 @@ import type { ViewState, NavigateEvent } from "./utils/APIRouter"; import type { AuthState } from "./types/auth"; import theme from "./theme"; +const ROUTES = { + home: "/", + login: "/log-in", + myAccount: "/my-account", + "archive-info": "/archive/:aid", + "archive-info-tab": "/archive/:aid/:tab", +} as const; + // =========================================================================== export class App extends LiteElement { - authState: AuthState | null; router: APIRouter; + + @state() + authState: AuthState | null = null; + + @state() viewState: ViewState & { aid?: string; // TODO common tab type @@ -31,31 +44,16 @@ export class App extends LiteElement { // history navigation. updateWhenLocaleChanges(this); - this.authState = null; - const authState = window.localStorage.getItem("authState"); if (authState) { this.authState = JSON.parse(authState); } - this.router = new APIRouter({ - home: "/", - login: "/log-in", - "my-account": "/my-account", - "archive-info": "/archive/:aid", - "archive-info-tab": "/archive/:aid/:tab", - }); + this.router = new APIRouter(ROUTES); this.viewState = this.router.match(window.location.pathname); } - static get properties() { - return { - viewState: { type: Object }, - authState: { type: Object }, - }; - } - firstUpdated() { window.addEventListener("popstate", (event) => { // if (event.state.view) { @@ -71,10 +69,14 @@ export class App extends LiteElement { if (newView.startsWith("http")) { newView = new URL(newView).pathname; } - this.viewState = this.router.match(newView); - if (this.viewState._route === "login") { - this.clearAuthState(); + + if (newView === "/log-in" && this.authState) { + // Redirect to logged in home page + this.viewState = this.router.match(ROUTES.myAccount); + } else { + this.viewState = this.router.match(newView); } + //console.log(this.view._route, window.location.href); window.history.pushState(this.viewState, "", this.viewState._path); } @@ -149,7 +151,7 @@ export class App extends LiteElement {
@@ -161,17 +163,23 @@ export class App extends LiteElement { case "login": return html``; case "home": return html`
- + ${msg("Log In")}
`; - case "my-account": + case "myAccount": return appLayout(html`) { this.clearAuthState(); - this.navigate("/log-in"); + + if (event?.detail?.api) { + // TODO refresh instead of redirect + } + this.navigate(ROUTES.login); } onNavigateTo(event: NavigateEvent) { diff --git a/frontend/src/pages/archive-info-tab.ts b/frontend/src/pages/archive-info-tab.ts index 15a7b905..ed4bbcd1 100644 --- a/frontend/src/pages/archive-info-tab.ts +++ b/frontend/src/pages/archive-info-tab.ts @@ -1,11 +1,12 @@ import LiteElement, { html } from "../utils/LiteElement"; +import { needLogin } from "../utils/auth"; import type { Archive, ArchiveConfig } from "../types/archives"; import type { AuthState } from "../types/auth"; +@needLogin export class ArchiveConfigsPage extends LiteElement { - archive!: Archive & { - authState: AuthState; - }; + archive!: Archive; + authState!: AuthState; configs: ArchiveConfig; static get properties() { @@ -16,14 +17,9 @@ export class ArchiveConfigsPage extends LiteElement { } async firstUpdated() { - if (!this.archive?.authState) { - // TODO - return; - } - const res = await this.apiFetch( `/archives/${this.archive.aid}/crawlconfigs`, - this.archive.authState + this.authState! ); this.configs = res.crawl_configs; } diff --git a/frontend/src/pages/archive-info.ts b/frontend/src/pages/archive-info.ts index 912a16cd..ff91d741 100644 --- a/frontend/src/pages/archive-info.ts +++ b/frontend/src/pages/archive-info.ts @@ -1,7 +1,9 @@ import LiteElement, { html } from "../utils/LiteElement"; +import { needLogin } from "../utils/auth"; import type { Archive } from "../types/archives"; import type { AuthState } from "../types/auth"; +@needLogin export class ArchivePage extends LiteElement { authState: AuthState = null; aid?: Archive["aid"]; @@ -48,8 +50,8 @@ export class ArchivePage extends LiteElement { ? html`` : ""}
diff --git a/frontend/src/pages/log-in.ts b/frontend/src/pages/log-in.ts index 0baffd30..e1179290 100644 --- a/frontend/src/pages/log-in.ts +++ b/frontend/src/pages/log-in.ts @@ -3,9 +3,6 @@ import LiteElement, { html } from "../utils/LiteElement"; import type { Auth } from "../types/auth"; export class LogInPage extends LiteElement { - @property({ type: Object }) - auth?: Auth; - @state() isLoggingIn: boolean = false; @@ -90,10 +87,6 @@ export class LogInPage extends LiteElement { console.error(e); } - if (!this.auth) { - this.loginError = "Unknown login response"; - } - this.isLoggingIn = false; } } diff --git a/frontend/src/pages/my-account.ts b/frontend/src/pages/my-account.ts index 27f699c4..b07851f7 100644 --- a/frontend/src/pages/my-account.ts +++ b/frontend/src/pages/my-account.ts @@ -1,7 +1,9 @@ import LiteElement, { html } from "../utils/LiteElement"; +import { needLogin } from "../utils/auth"; import type { Archive } from "../types/archives"; import type { AuthState } from "../types/auth"; +@needLogin export class MyAccountPage extends LiteElement { archiveList: Archive[] = []; authState: AuthState = null; @@ -15,15 +17,10 @@ export class MyAccountPage extends LiteElement { } async firstUpdated() { - if (!this.authState) { - this.dispatchEvent(new CustomEvent("need-login")); - return; - } - - const data = await this.apiFetch("/archives", this.authState); + const data = await this.apiFetch("/archives", this.authState!); this.archiveList = data.archives; - const data2 = await this.apiFetch("/users/me", this.authState); + const data2 = await this.apiFetch("/users/me", this.authState!); this.id = data2.id; } diff --git a/frontend/src/utils/LiteElement.ts b/frontend/src/utils/LiteElement.ts index 3a8d6aae..685cca79 100644 --- a/frontend/src/utils/LiteElement.ts +++ b/frontend/src/utils/LiteElement.ts @@ -27,10 +27,27 @@ export default class LiteElement extends LitElement { async apiFetch(path: string, auth: Auth) { const resp = await fetch("/api" + path, { headers: auth.headers }); + if (resp.status !== 200) { - this.navTo("/log-in"); - throw new Error("logged out"); + if (resp.status === 401) { + this.dispatchEvent( + new CustomEvent("need-login", { + detail: { api: true }, + }) + ); + } + + // TODO get error details + let errorMessage: string; + + try { + errorMessage = (await resp.json()).detail; + } catch { + errorMessage = "Unknown API error"; + } + throw new Error(errorMessage); } + return await resp.json(); } } diff --git a/frontend/src/utils/auth.test.ts b/frontend/src/utils/auth.test.ts new file mode 100644 index 00000000..4c34c85d --- /dev/null +++ b/frontend/src/utils/auth.test.ts @@ -0,0 +1,26 @@ +import { spy } from "sinon"; +import { expect } from "@esm-bundle/chai"; + +import * as auth from "./auth"; + +describe("auth", () => { + describe("needLogin", () => { + it("dispatches the correct event on need log in", () => { + const dispatchEventSpy = spy(); + class LiteElementMock { + dispatchEvent = dispatchEventSpy; + } + + const Element = auth.needLogin( + class extends LiteElementMock { + authState = null; + } as any + ); + + const element = new Element(); + element.connectedCallback(); + + expect(dispatchEventSpy.getCall(0).firstArg.type).to.equal("need-login"); + }); + }); +}); diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts new file mode 100644 index 00000000..e194fea2 --- /dev/null +++ b/frontend/src/utils/auth.ts @@ -0,0 +1,33 @@ +import LiteElement from "../utils/LiteElement"; +import type { AuthState } from "../types/auth"; + +/** + * Block rendering and dispatch event if user is not logged in + * + * Usage example: + * ```ts + * @needLogin + * MyComponent extends LiteElement {} + * ``` + */ +export function needLogin( + constructor: T +) { + return class extends constructor { + authState?: AuthState; + + static get properties() { + return { + authState: { type: Object }, + }; + } + + connectedCallback() { + if (this.authState) { + super.connectedCallback(); + } else { + this.dispatchEvent(new CustomEvent("need-login")); + } + } + }; +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index a2e9df72..6ac3aac4 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -59,6 +59,13 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@esm-bundle/chai@^4.3.4": + version "4.3.4" + resolved "https://registry.yarnpkg.com/@esm-bundle/chai/-/chai-4.3.4.tgz#74ed4a0794b3a9f9517ff235744ac6f4be0d34dc" + integrity sha512-6Tx35wWiNw7X0nLY9RMx8v3EL8SacCFW+eEZOE9Hc+XxmU5HFE2AFEg+GehUZpiyDGwVvPH75ckGlqC7coIPnA== + dependencies: + "@types/chai" "^4.2.12" + "@esm-bundle/chai@^4.3.4-fix.0": version "4.3.4-fix.0" resolved "https://registry.yarnpkg.com/@esm-bundle/chai/-/chai-4.3.4-fix.0.tgz#3084cff7eb46d741749f47f3a48dbbdcbaf30a92" @@ -176,6 +183,63 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@open-wc/chai-dom-equals@^0.12.36": + version "0.12.36" + resolved "https://registry.yarnpkg.com/@open-wc/chai-dom-equals/-/chai-dom-equals-0.12.36.tgz#ed0eb56b9e98c4d7f7280facce6215654aae9f4c" + integrity sha512-Gt1fa37h4rtWPQGETSU4n1L678NmMi9KwHM1sH+JCGcz45rs8DBPx7MUVeGZ+HxRlbEI5t9LU2RGGv6xT2OlyA== + dependencies: + "@open-wc/semantic-dom-diff" "^0.13.16" + "@types/chai" "^4.1.7" + +"@open-wc/dedupe-mixin@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@open-wc/dedupe-mixin/-/dedupe-mixin-1.3.0.tgz#0df5d438285fc3482838786ee81895318f0ff778" + integrity sha512-UfdK1MPnR6T7f3svzzYBfu3qBkkZ/KsPhcpc3JYhsUY4hbpwNF9wEQtD4Z+/mRqMTJrKg++YSxIxE0FBhY3RIw== + +"@open-wc/scoped-elements@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.0.1.tgz#6b1c3535f809bd90710574db80093a81e3a1fc2d" + integrity sha512-JS6ozxUFwFX3+Er91v9yQzNIaFn7OnE0iESKTbFvkkKdNwvAPtp1fpckBKIvWk8Ae9ZcoI9DYZuT2DDbMPcadA== + dependencies: + "@lit/reactive-element" "^1.0.0" + "@open-wc/dedupe-mixin" "^1.3.0" + "@webcomponents/scoped-custom-element-registry" "^0.0.3" + +"@open-wc/semantic-dom-diff@^0.13.16": + version "0.13.21" + resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.13.21.tgz#718b9ec5f9a98935fc775e577ad094ae8d8b7dea" + integrity sha512-BONpjHcGX2zFa9mfnwBCLEmlDsOHzT+j6Qt1yfK3MzFXFtAykfzFjAgaxPetu0YbBlCfXuMlfxI4vlRGCGMvFg== + +"@open-wc/semantic-dom-diff@^0.19.5": + version "0.19.5" + resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.19.5.tgz#8d3d7f69140b9ba477a4adf8099c79e0efe18955" + integrity sha512-Wi0Fuj3dzqlWClU0y+J4k/nqTcH0uwgOWxZXPyeyG3DdvuyyjgiT4L4I/s6iVShWQvvEsyXnj7yVvixAo3CZvg== + dependencies: + "@types/chai" "^4.2.11" + "@web/test-runner-commands" "^0.5.7" + +"@open-wc/testing-helpers@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.0.2.tgz#ca1833bf76036d9bdc03547415e79b6d502c78f6" + integrity sha512-wJlvDmWo+fIbgykRP21YSP9I9Pf/fo2+dZGaWG77Hw0sIuyB+7sNUDJDkL6kMkyyRecPV6dVRmbLt6HuOwvZ1w== + dependencies: + "@open-wc/scoped-elements" "^2.0.1" + lit "^2.0.0" + +"@open-wc/testing@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.0.3.tgz#9d122933ef69e06cc9cb3b0d4e425b90117bb9e5" + integrity sha512-xJYckO8X9yfWc+ltPlDZjHGTh4ldNmnYsnxNriuUUEEhV5ASdsc+5WEsIS2+9m4lQELj89rNQ7YvhYhawDorhg== + dependencies: + "@esm-bundle/chai" "^4.3.4" + "@open-wc/chai-dom-equals" "^0.12.36" + "@open-wc/semantic-dom-diff" "^0.19.5" + "@open-wc/testing-helpers" "^2.0.2" + "@types/chai" "^4.2.11" + "@types/chai-dom" "^0.0.9" + "@types/sinon-chai" "^3.2.3" + chai-a11y-axe "^1.3.2" + "@popperjs/core@^2.7.0": version "2.10.2" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.2.tgz#0798c03351f0dea1a5a4cabddf26a55a7cbee590" @@ -226,7 +290,7 @@ dependencies: type-detect "4.0.8" -"@sinonjs/fake-timers@^7.0.4": +"@sinonjs/fake-timers@^7.0.4", "@sinonjs/fake-timers@^7.1.0": version "7.1.2" resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz#2524eae70c4910edccf99b2f4e6efc5894aff7b5" integrity sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg== @@ -274,7 +338,14 @@ "@types/connect" "*" "@types/node" "*" -"@types/chai@^4.2.12": +"@types/chai-dom@^0.0.9": + version "0.0.9" + resolved "https://registry.yarnpkg.com/@types/chai-dom/-/chai-dom-0.0.9.tgz#77379354efec2568284ca355fff6a4f85f5a66f4" + integrity sha512-jj4F2NJog2/GBYsyJ8+NvhnWUBbPY4MUAKLdPJE6+568rw12GGXvj0ycUuP5nndVrnJgozmJAoMTvxvjJATXWw== + dependencies: + "@types/chai" "*" + +"@types/chai@*", "@types/chai@^4.1.7", "@types/chai@^4.2.11", "@types/chai@^4.2.12": version "4.2.22" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.22.tgz#47020d7e4cf19194d43b5202f35f75bd2ad35ce7" integrity sha512-tFfcE+DSTzWAgifkjik9AySNqIyNoYwmR+uecPwwD/XRNfvOjmC/FjCxpiUGDkDVDphPfCUecSQVFw+lN3M3kQ== @@ -518,6 +589,21 @@ "@types/mime" "^1" "@types/node" "*" +"@types/sinon-chai@^3.2.3": + version "3.2.5" + resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.5.tgz#df21ae57b10757da0b26f512145c065f2ad45c48" + integrity sha512-bKQqIpew7mmIGNRlxW6Zli/QVyc3zikpGzCa797B/tRnD9OtHvZ/ts8sYXV+Ilj9u3QRaUEM8xrjgd1gwm1BpQ== + dependencies: + "@types/chai" "*" + "@types/sinon" "*" + +"@types/sinon@*", "@types/sinon@^10.0.6": + version "10.0.6" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.6.tgz#bc3faff5154e6ecb69b797d311b7cf0c1b523a1d" + integrity sha512-6EF+wzMWvBNeGrfP3Nx60hhx+FfwSg1JJBLAAP/IdIUq0EYkqCYf70VT3PhuhPX9eLD+Dp+lNdpb/ZeHG8Yezg== + dependencies: + "@sinonjs/fake-timers" "^7.1.0" + "@types/trusted-types@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756" @@ -718,7 +804,7 @@ chrome-launcher "^0.15.0" puppeteer-core "^11.0.0" -"@web/test-runner-commands@^0.5.10": +"@web/test-runner-commands@^0.5.10", "@web/test-runner-commands@^0.5.7": version "0.5.13" resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.5.13.tgz#57ea472c00ee2ada99eb9bb5a0371200922707c2" integrity sha512-FXnpUU89ALbRlh9mgBd7CbSn5uzNtr8gvnQZPOvGLDAJ7twGvZdUJEAisPygYx2BLPSFl3/Mre8pH8zshJb8UQ== @@ -928,6 +1014,11 @@ "@webassemblyjs/ast" "1.11.1" "@xtuc/long" "4.2.2" +"@webcomponents/scoped-custom-element-registry@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@webcomponents/scoped-custom-element-registry/-/scoped-custom-element-registry-0.0.3.tgz#774591a886b0b0e4914717273ba53fd8d5657522" + integrity sha512-lpSzgDCGbM99dytb3+J3Suo4+Bk1E13MPnWB42JK8GwxSAxFz+tC7TTv2hhDSIE2IirGNKNKCf3m08ecu6eAsQ== + "@webpack-cli/configtest@^1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-1.0.4.tgz#f03ce6311c0883a83d04569e2c03c6238316d2aa" @@ -1145,6 +1236,11 @@ autoprefixer@^10.3.6: normalize-range "^0.1.2" postcss-value-parser "^4.1.0" +axe-core@^4.3.3: + version "4.3.5" + resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.3.5.tgz#78d6911ba317a8262bfee292aeafcc1e04b49cc5" + integrity sha512-WKTW1+xAzhMS5dJsxWkliixlO/PqC4VhmO9T4juNYcaTg9jzWiJsou6m5pxWYGfigWbwzJWeFY6z47a+4neRXA== + axios@^0.22.0: version "0.22.0" resolved "https://registry.yarnpkg.com/axios/-/axios-0.22.0.tgz#bf702c41fb50fbca4539589d839a077117b79b25" @@ -1314,6 +1410,13 @@ caniuse-lite@^1.0.30001260, caniuse-lite@^1.0.30001261: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001263.tgz#7ce7a6fb482a137585cbc908aaf38e90c53a16a4" integrity sha512-doiV5dft6yzWO1WwU19kt8Qz8R0/8DgEziz6/9n2FxUasteZNwNNYSmJO3GLBH8lCVE73AB1RPDPAeYbcO5Cvw== +chai-a11y-axe@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/chai-a11y-axe/-/chai-a11y-axe-1.3.2.tgz#77dc5f503901fed4f6097b5b0213ddb00cc891ea" + integrity sha512-/jYczmhGUoCfEcsrkJwjecy3PJ31T9FxFdu2BDlAwR/sX1nN3L2XmuPP3tw8iYk6LPqdF7K11wwFr3yUZMv5MA== + dependencies: + axe-core "^4.3.3" + chalk@^2.0.0, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"