From f7a83b18d5d353528526e21c481a27ad5534c675 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Tue, 3 Dec 2024 19:04:29 -0800 Subject: [PATCH] feat: Public org profile page (#2172) - Enables creating a public org profile page with description and website at `/profile/` - Updates current "Overview" page to be "Dashboard", found under `/dashboard` - Organizes org "General" settings tab by "General", "Profile", and "Developer Tools" - Adds sign up banner to log in page for consistent CTA banners - Updates copy and docs to support changes - Allows user to set collection to private, public, or unlisted - Adds route for public collection page with basic page layout - Refactors copy button to abstract clipboard functionality --------- Co-authored-by: Henry Wilkinson Co-authored-by: emma --- .../docs/docs/user-guide/crawl-workflows.md | 4 +- .../docs/docs/user-guide/getting-started.md | 2 +- frontend/docs/docs/user-guide/org-settings.md | 12 +- frontend/docs/docs/user-guide/overview.md | 2 +- frontend/src/components/index.ts | 1 + frontend/src/components/not-found.ts | 35 +- frontend/src/components/ui/index.ts | 2 + frontend/src/components/ui/link.ts | 50 ++ frontend/src/components/ui/url-input.ts | 75 +++ frontend/src/components/verified-badge.ts | 26 + frontend/src/controllers/navigate.ts | 3 +- .../crawl-workflows/workflow-editor.ts | 9 +- frontend/src/index.test.ts | 9 +- frontend/src/index.ts | 119 +++- frontend/src/layouts/columns.ts | 2 +- frontend/src/pages/account-settings.ts | 7 +- frontend/src/pages/admin.ts | 5 +- frontend/src/pages/invite/accept.test.ts | 8 +- frontend/src/pages/invite/accept.ts | 9 +- frontend/src/pages/invite/join.test.ts | 6 +- frontend/src/pages/invite/join.ts | 7 +- frontend/src/pages/log-in.ts | 52 +- frontend/src/pages/org/index.ts | 79 ++- frontend/src/pages/org/profile.ts | 328 ++++++++++ .../pages/org/settings/components/profile.ts | 158 +++++ frontend/src/pages/org/settings/settings.ts | 121 ++-- frontend/src/routes.ts | 33 +- frontend/src/theme.stylesheet.css | 8 +- frontend/src/types/org.ts | 3 + frontend/src/utils/APIRouter.ts | 17 +- frontend/src/utils/form.ts | 20 + frontend/src/utils/state.ts | 7 +- frontend/xliff/es.xlf | 595 ++++++++++++------ 33 files changed, 1416 insertions(+), 398 deletions(-) create mode 100644 frontend/src/components/ui/link.ts create mode 100644 frontend/src/components/ui/url-input.ts create mode 100644 frontend/src/components/verified-badge.ts create mode 100644 frontend/src/pages/org/profile.ts create mode 100644 frontend/src/pages/org/settings/components/profile.ts diff --git a/frontend/docs/docs/user-guide/crawl-workflows.md b/frontend/docs/docs/user-guide/crawl-workflows.md index f5396e020b..ecf039c0dc 100644 --- a/frontend/docs/docs/user-guide/crawl-workflows.md +++ b/frontend/docs/docs/user-guide/crawl-workflows.md @@ -8,7 +8,7 @@ You can create, view, search for, and run crawl workflows from the **Crawling** ## Create a Crawl Workflow -Create new crawl workflows from the **Crawling** page, or the _Create New ..._ shortcut from **Overview**. +Create new crawl workflows from the **Crawling** page, or the _Create New ..._ shortcut from **Dashboard**. ### Choose what to crawl @@ -38,4 +38,4 @@ Re-running a crawl workflow can be useful to capture a website as it changes ove ## Status -Finished crawl workflows inherit the [status of the last archived item they created](archived-items.md#status). Crawl workflows that are in progress maintain their [own statuses](./running-crawl.md#crawl-workflow-status). \ No newline at end of file +Finished crawl workflows inherit the [status of the last archived item they created](archived-items.md#status). Crawl workflows that are in progress maintain their [own statuses](./running-crawl.md#crawl-workflow-status). diff --git a/frontend/docs/docs/user-guide/getting-started.md b/frontend/docs/docs/user-guide/getting-started.md index c08d6bed55..d0b83a9ccb 100644 --- a/frontend/docs/docs/user-guide/getting-started.md +++ b/frontend/docs/docs/user-guide/getting-started.md @@ -12,7 +12,7 @@ To start crawling with hosted Browsertrix, you'll need a Browsertrix account. [S ## Starting the crawl -Once you've logged in you should see your org [overview](overview.md). If you land somewhere else, navigate to **Overview**. +Once you've logged in you should see your org [overview](overview.md). If you land somewhere else, navigate to **Dashboard**. 1. Tap the _Create New..._ shortcut and select **Crawl Workflow**. 2. Choose **Page List**. We'll get into the details of the options [later](./crawl-workflows.md), but this is a good starting point for a simple crawl. diff --git a/frontend/docs/docs/user-guide/org-settings.md b/frontend/docs/docs/user-guide/org-settings.md index caf842b5d6..5c5f50d37a 100644 --- a/frontend/docs/docs/user-guide/org-settings.md +++ b/frontend/docs/docs/user-guide/org-settings.md @@ -4,7 +4,17 @@ Settings that apply to the entire organization are found in the **Settings** pag ## General -Change your organization's name and URL identifier in this tab. Choose an org name that's unique and memorable, like the name of your company or organization. Org name and URLs are unique to each Browsertrix instance (for example, on browsertrix.com) and you may be asked to change the org name or URL identifier if either are already in use by another org. +### Name and URL + +Choose a display name for your org that's unique and memorable, like the name of your company, organization, or personal project. This name will be visible in the org's [public profile](#profile), if that page is enabled. + +The org URL is where you and other org members will go to view the dashboard, configure org settings, and manage all other org-related activities. Changing this URL will also update the URL of your org's public profile, if enabled. + +Org name and URLs are unique to each Browsertrix instance (for example, on `app.browsertrix.com`) and you may be prompted to change the org name or URL identifier if either are already in use by another org. + +### Profile + +Enable and configure a public profile page for your org. Once enabled, anyone on the internet with a link to your org's profile page will be able to view public information like the org name, description, and public collections. ## Billing diff --git a/frontend/docs/docs/user-guide/overview.md b/frontend/docs/docs/user-guide/overview.md index b67b5d6a08..63b55e7309 100644 --- a/frontend/docs/docs/user-guide/overview.md +++ b/frontend/docs/docs/user-guide/overview.md @@ -1,6 +1,6 @@ # View Usage Stats and Quotas -The **Overview** dashboard delivers key statistics about the org's resource usage. You can also create crawl workflows, upload archived items, create collections, and create browser profiles through the _Create New ..._ shortcut. +Your **Dashboard** delivers key statistics about the org's resource usage. You can also create crawl workflows, upload archived items, create collections, and create browser profiles through the _Create New ..._ shortcut. ## Storage diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index a3e01bfe52..40a362b8bc 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -7,3 +7,4 @@ import("./not-found"); import("./screencast"); import("./beta-badges"); import("./detail-page-title"); +import("./verified-badge"); diff --git a/frontend/src/components/not-found.ts b/frontend/src/components/not-found.ts index 6bb2fe74e2..7f64e8904e 100644 --- a/frontend/src/components/not-found.ts +++ b/frontend/src/components/not-found.ts @@ -1,17 +1,36 @@ import { localized, msg } from "@lit/localize"; -import { html, LitElement } from "lit"; +import { html } from "lit"; import { customElement } from "lit/decorators.js"; -@customElement("btrix-not-found") +import { BtrixElement } from "@/classes/BtrixElement"; + @localized() -export class NotFound extends LitElement { - createRenderRoot() { - return this; - } +@customElement("btrix-not-found") +export class NotFound extends BtrixElement { render() { return html` -
- ${msg("Page not found")} +
+

+ ${msg("Page not found")} +

+

+ ${msg("Did you click a link to get here?")} + +
+ ${msg("Or")} + + ${msg("Report a Broken Link")} + +

`; } diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 135af614b2..840c4a5845 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -5,9 +5,11 @@ import "./card"; import "./data-table"; import "./desc-list"; import "./dialog"; +import "./link"; import "./navigation"; import "./tab-group"; import "./tab-list"; +import "./url-input"; import("./code"); import("./combobox"); diff --git a/frontend/src/components/ui/link.ts b/frontend/src/components/ui/link.ts new file mode 100644 index 0000000000..c833207030 --- /dev/null +++ b/frontend/src/components/ui/link.ts @@ -0,0 +1,50 @@ +import clsx from "clsx"; +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { BtrixElement } from "@/classes/BtrixElement"; + +@customElement("btrix-link") +export class Link extends BtrixElement { + @property({ type: String }) + href?: HTMLAnchorElement["href"]; + + @property({ type: String }) + target?: HTMLAnchorElement["target"]; + + @property({ type: String }) + rel?: HTMLAnchorElement["rel"]; + + @property({ type: String }) + variant: "primary" | "neutral" = "neutral"; + + render() { + if (!this.href) return; + + return html` + {} + : this.navigate.link} + > + + + `; + } +} diff --git a/frontend/src/components/ui/url-input.ts b/frontend/src/components/ui/url-input.ts new file mode 100644 index 0000000000..90cfe18830 --- /dev/null +++ b/frontend/src/components/ui/url-input.ts @@ -0,0 +1,75 @@ +// import type { SlInputEvent } from "@shoelace-style/shoelace"; +import { msg } from "@lit/localize"; +import SlInput from "@shoelace-style/shoelace/dist/components/input/input.js"; +import { customElement, property } from "lit/decorators.js"; + +export function validURL(url: string) { + // adapted from: https://gist.github.com/dperini/729294 + return /^(?:https?:\/\/)?(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[/?#]\S*)?$/i.test( + url, + ); +} + +/** + * URL input field with validation. + * + * @TODO Use types from SlInput + * + * @attr {String} name + * @attr {String} size + * @attr {String} name + * @attr {String} label + * @attr {String} value + */ +@customElement("btrix-url-input") +export class Component extends SlInput { + @property({ type: Number, reflect: true }) + minlength = 4; + + @property({ type: String, reflect: true }) + placeholder = "https://example.com"; + + connectedCallback(): void { + this.inputmode = "url"; + + super.connectedCallback(); + + this.addEventListener("sl-input", this.onInput); + this.addEventListener("sl-blur", this.onBlur); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + + this.removeEventListener("sl-input", this.onInput); + this.removeEventListener("sl-blur", this.onBlur); + } + + private readonly onInput = async () => { + console.log("input 1"); + await this.updateComplete; + + if (!this.checkValidity() && validURL(this.value)) { + this.setCustomValidity(""); + this.helpText = ""; + } + }; + + private readonly onBlur = async () => { + await this.updateComplete; + + const value = this.value; + + if (value && !validURL(value)) { + const text = msg("Please enter a valid URL."); + this.helpText = text; + this.setCustomValidity(text); + } else if ( + value && + !value.startsWith("https://") && + !value.startsWith("http://") + ) { + this.value = `https://${value}`; + } + }; +} diff --git a/frontend/src/components/verified-badge.ts b/frontend/src/components/verified-badge.ts new file mode 100644 index 0000000000..99dc1cb9b7 --- /dev/null +++ b/frontend/src/components/verified-badge.ts @@ -0,0 +1,26 @@ +import { localized, msg } from "@lit/localize"; +import { html } from "lit"; +import { customElement } from "lit/decorators.js"; + +import { BtrixElement } from "@/classes/BtrixElement"; + +@localized() +@customElement("btrix-verified-badge") +export class Component extends BtrixElement { + render() { + return html` + + ${msg( + "Verified", + )} + + `; + } +} diff --git a/frontend/src/controllers/navigate.ts b/frontend/src/controllers/navigate.ts index 87bd0a1c35..6e94f56b45 100644 --- a/frontend/src/controllers/navigate.ts +++ b/frontend/src/controllers/navigate.ts @@ -1,5 +1,6 @@ import type { ReactiveController, ReactiveControllerHost } from "lit"; +import { RouteNamespace } from "@/routes"; import appState from "@/utils/state"; export type NavigateEventDetail = { @@ -31,7 +32,7 @@ export class NavigateController implements ReactiveController { get orgBasePath() { const slug = appState.orgSlug; if (slug) { - return `/orgs/${slug}`; + return `/${RouteNamespace.PrivateOrgs}/${slug}`; } return "/"; } diff --git a/frontend/src/features/crawl-workflows/workflow-editor.ts b/frontend/src/features/crawl-workflows/workflow-editor.ts index 06f83cc11a..5feb9b8fec 100644 --- a/frontend/src/features/crawl-workflows/workflow-editor.ts +++ b/frontend/src/features/crawl-workflows/workflow-editor.ts @@ -43,6 +43,7 @@ import type { SelectCrawlerProxyChangeEvent } from "@/components/ui/select-crawl import type { Tab } from "@/components/ui/tab-list"; import type { TagInputEvent, TagsChangeEvent } from "@/components/ui/tag-input"; import type { TimeInputChangeEvent } from "@/components/ui/time-input"; +import { validURL } from "@/components/ui/url-input"; import { type SelectBrowserProfileChangeEvent } from "@/features/browser-profiles/select-browser-profile"; import type { CollectionsChangeEvent } from "@/features/collections/collections-add"; import type { QueueExclusionTable } from "@/features/crawl-workflows/queue-exclusion-table"; @@ -161,13 +162,6 @@ function getLocalizedWeekDays() { ); } -function validURL(url: string) { - // adapted from: https://gist.github.com/dperini/729294 - return /^(?:https?:\/\/)?(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[/?#]\S*)?$/i.test( - url, - ); -} - const trimArray = flow(uniq, compact); const urlListToArray = flow( (str?: string) => (str?.length ? str.trim().split(/\s+/g) : []), @@ -786,6 +780,7 @@ export class WorkflowEditor extends BtrixElement { ${this.formState.scopeType === ScopeType.Page ? html` ${inputCol(html` + { AppStateService.resetAll(); AuthService.broadcastChannel = new BroadcastChannel(AuthService.storageKey); window.sessionStorage.clear(); + window.localStorage.clear(); stub(window.history, "pushState"); }); @@ -119,7 +120,7 @@ describe("browsertrix-app", () => { expect(el.appState.orgSlug).to.equal("test-org"); }); - it("sets org slug from path", async () => { + it("sets org slug from path if user is in org", async () => { const id = self.crypto.randomUUID(); const mockOrg = { id: id, @@ -127,13 +128,13 @@ describe("browsertrix-app", () => { slug: id, role: 10, }; - stub(App.prototype, "getLocationPathname").callsFake(() => `/orgs/${id}`); - stub(App.prototype, "getUserInfo").callsFake(async () => - Promise.resolve({ + AppStateService.updateUser( + formatAPIUser({ ...mockAPIUser, orgs: [...mockAPIUser.orgs, mockOrg], }), ); + stub(App.prototype, "getLocationPathname").callsFake(() => `/orgs/${id}`); stub(AuthService.prototype, "startFreshnessCheck").callsFake(() => {}); stub(AuthService, "initSessionStorage").callsFake(async () => Promise.resolve({ diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 8a9f2804b5..adc22dd53c 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -13,8 +13,7 @@ import "broadcastchannel-polyfill"; import "construct-style-sheets-polyfill"; import "./utils/polyfills"; -import type { OrgTab } from "./pages/org"; -import { ROUTES } from "./routes"; +import { OrgTab, RouteNamespace, ROUTES } from "./routes"; import type { UserInfo, UserOrg } from "./types/user"; import APIRouter, { type ViewState } from "./utils/APIRouter"; import AuthService, { @@ -99,6 +98,12 @@ export class App extends BtrixElement { @query("#userGuideDrawer") private readonly userGuideDrawer!: SlDrawer; + get isUserInCurrentOrg(): boolean { + const { slug } = this.viewState.params; + if (!this.userInfo || !slug) return false; + return Boolean(this.userInfo.orgs.some((org) => org.slug === slug)); + } + async connectedCallback() { let authState: AuthService["authState"] = null; try { @@ -110,7 +115,6 @@ export class App extends BtrixElement { if (authState) { this.authService.saveLogin(authState); } - this.syncViewState(); if (authState && !this.userInfo) { void this.fetchAndUpdateUserInfo(); } @@ -144,14 +148,30 @@ export class App extends BtrixElement { AppStateService.updateSettings(this.settings || null); } if (changedProperties.has("viewState")) { - if (this.viewState.route === "orgs") { + this.handleViewStateChange( + changedProperties.get("viewState") as undefined | ViewState, + ); + } + } + + private handleViewStateChange(prevValue?: ViewState) { + switch (this.viewState.route) { + case "orgs": + // Orgs index page don't exist right now this.routeTo(this.navigate.orgBasePath); - } else if ( - changedProperties.get("viewState") && - this.viewState.route === "org" - ) { - this.updateOrgSlugIfNeeded(); + break; + case "publicOrgs": + // Public index page don't exist right now + this.routeTo("/"); + break; + case "org": { + if (prevValue) { + this.updateOrgSlugIfNeeded(); + } + break; } + default: + break; } } @@ -190,7 +210,11 @@ export class App extends BtrixElement { private updateOrgSlugIfNeeded() { const slug = this.viewState.params.slug || null; - if (this.viewState.route === "org" && slug !== this.appState.orgSlug) { + if ( + this.isUserInCurrentOrg && + this.viewState.route === "org" && + slug !== this.appState.orgSlug + ) { AppStateService.updateOrgSlug(slug); } } @@ -256,7 +280,9 @@ export class App extends BtrixElement { return html`
${this.renderNavBar()} ${this.renderAlertBanner()} -
${this.renderPage()}
+
+ ${this.renderPage()} +
${this.renderFooter()}
@@ -329,8 +355,8 @@ export class App extends BtrixElement { private renderNavBar() { const isSuperAdmin = this.userInfo?.isSuperAdmin; let homeHref = "/"; - if (!isSuperAdmin && this.appState.orgSlug) { - homeHref = this.navigate.orgBasePath; + if (!isSuperAdmin && this.appState.orgSlug && this.authState) { + homeHref = `${this.navigate.orgBasePath}/${OrgTab.Dashboard}`; } const showFullLogo = @@ -392,7 +418,11 @@ export class App extends BtrixElement { `, )}
-
+
${this.authState ? html`${this.userInfo && !isSuperAdmin ? html` @@ -447,7 +477,18 @@ export class App extends BtrixElement { ` : html` - ${this.renderSignUpLink()} + ${this.viewState.route === "publicOrgProfile" + ? html` + + ${msg("Sign In")} + + ` + : nothing} ${(translatedLocales as unknown as string[]).length > 2 ? html` ${selectedOption.name.slice(0, orgNameLength)} @@ -555,7 +596,9 @@ export class App extends BtrixElement { @sl-select=${(e: CustomEvent<{ item: { value: string } }>) => { const { value } = e.detail.item; if (value) { - this.routeTo(`/orgs/${value}`); + this.routeTo( + `/${RouteNamespace.PrivateOrgs}/${value}/${OrgTab.Dashboard}`, + ); } else { if (this.userInfo) { this.clearSelectedOrg(); @@ -629,9 +672,9 @@ export class App extends BtrixElement { private renderFooter() { return html`