Frontend archives -> teams migration (#429)

This commit is contained in:
sua yoo 2023-01-03 15:37:32 -08:00 committed by GitHub
parent d33d5f7700
commit 5daf550cb8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 268 additions and 130 deletions

View File

@ -233,11 +233,18 @@ export class AccountSettings extends LiteElement {
} }
return html`<div class="grid gap-4"> return html`<div class="grid gap-4">
<h1 class="text-xl font-semibold">${msg("Account settings")}</h1> <h1 class="text-xl font-semibold">${msg("Account Settings")}</h1>
${successMessage} ${successMessage}
<section class="p-4 md:p-8 border rounded-lg grid gap-6"> <section class="p-4 md:p-8 border rounded-lg grid gap-6">
<div>
<div class="mb-1 text-gray-500">${msg("Name")}</div>
<div class="inline-flex items-center">
<span class="mr-3">${this.userInfo?.name}</span>
</div>
</div>
<div> <div>
<div class="mb-1 text-gray-500">${msg("Email")}</div> <div class="mb-1 text-gray-500">${msg("Email")}</div>
<div class="inline-flex items-center"> <div class="inline-flex items-center">

View File

@ -1,7 +1,7 @@
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { render } from "lit"; import { render } from "lit";
import { property, state, query } from "lit/decorators.js"; import { property, state, query } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js";
import { msg, localized } from "@lit/localize"; import { msg, localized } from "@lit/localize";
import type { SlDialog } from "@shoelace-style/shoelace"; import type { SlDialog } from "@shoelace-style/shoelace";
import "tailwindcss/tailwind.css"; import "tailwindcss/tailwind.css";
@ -15,6 +15,7 @@ import type { LoggedInEvent } from "./utils/AuthService";
import type { ViewState } from "./utils/APIRouter"; import type { ViewState } from "./utils/APIRouter";
import type { CurrentUser } from "./types/user"; import type { CurrentUser } from "./types/user";
import type { AuthStorageEventData } from "./utils/AuthService"; import type { AuthStorageEventData } from "./utils/AuthService";
import type { Archive, ArchiveData } from "./utils/archives";
import theme from "./theme"; import theme from "./theme";
import { ROUTES, DASHBOARD_ROUTE } from "./routes"; import { ROUTES, DASHBOARD_ROUTE } from "./routes";
import "./shoelace"; import "./shoelace";
@ -30,6 +31,14 @@ type DialogContent = {
noHeader?: boolean; noHeader?: boolean;
}; };
type APIUser = {
id: string;
email: string;
name: string;
is_verified: boolean;
is_superuser: boolean;
};
/** /**
* @event navigate * @event navigate
* @event notify * @event notify
@ -64,10 +73,19 @@ export class App extends LiteElement {
@state() @state()
private isRegistrationEnabled?: boolean; private isRegistrationEnabled?: boolean;
@state()
private teams?: ArchiveData[];
// Store selected team ID for when navigating from
// pages without associated team (e.g. user account)
@state()
private selectedTeamId?: string;
async connectedCallback() { async connectedCallback() {
const authState = await AuthService.initSessionStorage(); const authState = await AuthService.initSessionStorage();
if (authState) { if (authState) {
this.authService.saveLogin(authState); this.authService.saveLogin(authState);
this.updateUserInfo();
} }
this.syncViewState(); this.syncViewState();
super.connectedCallback(); super.connectedCallback();
@ -77,6 +95,19 @@ export class App extends LiteElement {
}); });
this.startSyncBrowserTabs(); this.startSyncBrowserTabs();
this.fetchAppSettings();
}
willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("userInfo") && this.userInfo) {
this.selectedTeamId = this.userInfo.defaultTeamId;
}
if (
changedProperties.get("viewState") &&
this.viewState.route === "archive"
) {
this.selectedTeamId = this.viewState.params.id;
}
} }
private syncViewState() { private syncViewState() {
@ -95,11 +126,7 @@ export class App extends LiteElement {
} }
} }
async firstUpdated() { private async fetchAppSettings() {
if (this.authService.authState) {
this.updateUserInfo();
}
const settings = await this.getAppSettings(); const settings = await this.getAppSettings();
if (settings) { if (settings) {
@ -111,22 +138,48 @@ export class App extends LiteElement {
private async updateUserInfo() { private async updateUserInfo() {
try { try {
const data = await this.getUserInfo(); const [userInfoResp, archivesResp] = await Promise.allSettled([
this.getUserInfo(),
// TODO see if we can add API endpoint to retrieve first archive
this.getArchives(),
]);
this.userInfo = { const userInfoSuccess = userInfoResp.status === "fulfilled";
id: data.id, let defaultTeamId: CurrentUser["defaultTeamId"];
email: data.email,
name: data.name, if (archivesResp.status === "fulfilled") {
isVerified: data.is_verified, const { archives } = archivesResp.value;
isAdmin: data.is_superuser, this.teams = archives;
}; if (userInfoSuccess) {
const userInfo = userInfoResp.value;
if (archives.length && !userInfo?.is_superuser) {
defaultTeamId = archives[0].id;
}
}
} else {
throw archivesResp.reason;
}
if (userInfoSuccess) {
const { value } = userInfoResp;
this.userInfo = {
id: value.id,
email: value.email,
name: value.name,
isVerified: value.is_verified,
isAdmin: value.is_superuser,
defaultTeamId,
};
} else {
throw userInfoResp.reason;
}
} catch (err: any) { } catch (err: any) {
if (err?.message === "Unauthorized") { if (err?.message === "Unauthorized") {
console.debug( console.debug(
"Unauthorized with authState:", "Unauthorized with authState:",
this.authService.authState this.authService.authState
); );
this.authService.logout(); this.clearUser();
this.navigate(ROUTES.login); this.navigate(ROUTES.login);
} }
} }
@ -205,8 +258,12 @@ export class App extends LiteElement {
`; `;
} }
renderNavBar() { private renderNavBar() {
const isAdmin = this.userInfo?.isAdmin; const isAdmin = this.userInfo?.isAdmin;
let homeHref = "/";
if (!isAdmin && this.selectedTeamId) {
homeHref = `/archives/${this.selectedTeamId}/crawls`;
}
return html` return html`
<div class="border-b"> <div class="border-b">
@ -214,7 +271,7 @@ export class App extends LiteElement {
class="max-w-screen-lg mx-auto pl-3 box-border h-12 flex items-center justify-between" class="max-w-screen-lg mx-auto pl-3 box-border h-12 flex items-center justify-between"
> >
<div> <div>
<a href="/" @click="${this.navLink}" <a href="${homeHref}" @click="${this.navLink}"
><h1 class="text-sm hover:text-neutral-400 font-medium"> ><h1 class="text-sm hover:text-neutral-400 font-medium">
${msg("Browsertrix Cloud")} ${msg("Browsertrix Cloud")}
</h1></a </h1></a
@ -226,12 +283,6 @@ export class App extends LiteElement {
<div <div
class="text-xs md:text-sm grid grid-flow-col gap-3 md:gap-5 items-center" class="text-xs md:text-sm grid grid-flow-col gap-3 md:gap-5 items-center"
> >
<a
class="text-neutral-500 hover:text-neutral-400 font-medium"
href="/archives"
@click=${this.navLink}
>${msg("All Archives")}</a
>
<a <a
class="text-neutral-500 hover:text-neutral-400 font-medium" class="text-neutral-500 hover:text-neutral-400 font-medium"
href="/crawls" href="/crawls"
@ -243,58 +294,40 @@ export class App extends LiteElement {
` `
: ""} : ""}
<div class="grid grid-flow-col gap-3 md:gap-5 items-center"> <div class="grid grid-flow-col auto-cols-max gap-3 items-center">
${this.authService.authState ${this.authService.authState
? html` <sl-dropdown placement="bottom-end"> ? html` ${this.renderTeams()}
<sl-icon-button <sl-dropdown placement="bottom-end">
slot="trigger" <sl-icon-button
name="person-circle" slot="trigger"
style="font-size: 1.5rem;" name="person-circle"
></sl-icon-button> style="font-size: 1.5rem;"
></sl-icon-button>
<sl-menu class="w-60 min-w-min max-w-full"> <sl-menu class="w-60 min-w-min max-w-full">
<div class="px-7 py-2"> <div class="px-7 py-2">${this.renderMenuUserInfo()}</div>
${isAdmin <sl-divider></sl-divider>
? html` <sl-menu-item
<div class="mb-2"> @click=${() => this.navigate(ROUTES.accountSettings)}
<sl-tag >
class="uppercase" <sl-icon slot="prefix" name="gear"></sl-icon>
variant="primary" ${msg("Account Settings")}
size="small" </sl-menu-item>
>${msg("admin")}</sl-tag ${this.userInfo?.isAdmin
> ? html` <sl-menu-item
</div> @click=${() => this.navigate(ROUTES.usersInvite)}
` >
<sl-icon slot="prefix" name="person-plus"></sl-icon>
${msg("Invite Users")}
</sl-menu-item>`
: ""} : ""}
<div class="font-medium text-neutral-700"> <sl-divider></sl-divider>
${this.userInfo?.name} <sl-menu-item @click="${this.onLogOut}">
</div> <sl-icon slot="prefix" name="box-arrow-right"></sl-icon>
<div class="text-sm text-neutral-500"> ${msg("Log Out")}
${this.userInfo?.email} </sl-menu-item>
</div> </sl-menu>
</div> </sl-dropdown>`
<sl-divider></sl-divider>
<sl-menu-item
@click=${() => this.navigate(ROUTES.accountSettings)}
>
<sl-icon slot="prefix" name="gear"></sl-icon>
${msg("Your account")}
</sl-menu-item>
${this.userInfo?.isAdmin
? html` <sl-menu-item
@click=${() => this.navigate(ROUTES.usersInvite)}
>
<sl-icon slot="prefix" name="person-plus"></sl-icon>
${msg("Invite Users")}
</sl-menu-item>`
: ""}
<sl-divider></sl-divider>
<sl-menu-item @click="${this.onLogOut}">
<sl-icon slot="prefix" name="box-arrow-right"></sl-icon>
${msg("Log Out")}
</sl-menu-item>
</sl-menu>
</sl-dropdown>`
: html` : html`
<a href="/log-in"> ${msg("Log In")} </a> <a href="/log-in"> ${msg("Log In")} </a>
${this.isRegistrationEnabled ${this.isRegistrationEnabled
@ -314,7 +347,91 @@ export class App extends LiteElement {
`; `;
} }
renderFooter() { private renderTeams() {
if (!this.teams || this.teams.length < 2 || !this.userInfo) return;
const selectedOption = this.selectedTeamId
? this.teams.find(({ id }) => id === this.selectedTeamId)
: { id: "", name: msg("All Teams") };
if (!selectedOption) {
console.debug(
`Could't find team with ID ${this.selectedTeamId}`,
this.teams
);
return;
}
return html`
<sl-dropdown placement="bottom-end">
<sl-button slot="trigger" variant="text" size="small" caret
>${selectedOption.name}</sl-button
>
<sl-menu
@sl-select=${(e: CustomEvent) => {
const { value } = e.detail.item;
this.navigate(`/archives/${value}${value ? "/crawls" : ""}`);
}}
>
${when(
this.userInfo.isAdmin,
() => html`
<sl-menu-item value="" ?checked=${!selectedOption.id}
>${msg("All Teams")}</sl-menu-item
>
<sl-divider></sl-divider>
`
)}
${this.teams.map(
(team) => html`
<sl-menu-item
value=${team.id}
?checked=${team.id === selectedOption.id}
>${team.name}</sl-menu-item
>
`
)}
</sl-menu>
</sl-dropdown>
`;
}
private renderMenuUserInfo() {
if (!this.userInfo) return;
if (this.userInfo.isAdmin) {
return html`
<div class="mb-2">
<sl-tag class="uppercase" variant="primary" size="small"
>${msg("admin")}</sl-tag
>
</div>
<div class="font-medium text-neutral-700">${this.userInfo?.name}</div>
<div class="text-xs text-neutral-500 whitespace-nowrap">
${this.userInfo?.email}
</div>
`;
}
if (this.teams?.length === 1) {
return html`
<div class="font-medium text-neutral-700 my-1">
${this.teams![0].name}
</div>
<div class="text-neutral-500">${this.userInfo?.name}</div>
<div class="text-xs text-neutral-500 whitespace-nowrap">
${this.userInfo?.email}
</div>
`;
}
return html`
<div class="font-medium text-neutral-700">${this.userInfo?.name}</div>
<div class="text-xs text-neutral-500 whitespace-nowrap">
${this.userInfo?.email}
</div>
`;
}
private renderFooter() {
return html` return html`
<footer <footer
class="w-full max-w-screen-lg mx-auto p-1 md:p-3 box-border flex justify-end" class="w-full max-w-screen-lg mx-auto p-1 md:p-3 box-border flex justify-end"
@ -347,7 +464,7 @@ export class App extends LiteElement {
`; `;
} }
renderPage() { private renderPage() {
switch (this.viewState.route) { switch (this.viewState.route) {
case "signUp": { case "signUp": {
if (!this.isAppSettingsLoaded) { if (!this.isAppSettingsLoaded) {
@ -426,7 +543,8 @@ export class App extends LiteElement {
@navigate=${this.onNavigateTo} @navigate=${this.onNavigateTo}
@logged-in=${this.onLoggedIn} @logged-in=${this.onLoggedIn}
.authState=${this.authService.authState} .authState=${this.authService.authState}
.userInfo="${this.userInfo}" .userInfo=${this.userInfo}
.teamId=${this.selectedTeamId}
></btrix-home>`; ></btrix-home>`;
case "archives": case "archives":
@ -518,7 +636,7 @@ export class App extends LiteElement {
} }
} }
renderSpinner() { private renderSpinner() {
return html` return html`
<div class="w-full flex items-center justify-center text-3xl"> <div class="w-full flex items-center justify-center text-3xl">
<sl-spinner></sl-spinner> <sl-spinner></sl-spinner>
@ -526,13 +644,13 @@ export class App extends LiteElement {
`; `;
} }
renderNotFoundPage() { private renderNotFoundPage() {
return html`<btrix-not-found return html`<btrix-not-found
class="w-full md:bg-neutral-50 flex items-center justify-center" class="w-full md:bg-neutral-50 flex items-center justify-center"
></btrix-not-found>`; ></btrix-not-found>`;
} }
renderFindCrawl() { private renderFindCrawl() {
return html` return html`
<sl-dropdown <sl-dropdown
@sl-after-show=${(e: any) => { @sl-after-show=${(e: any) => {
@ -585,9 +703,7 @@ export class App extends LiteElement {
const detail = event.detail || {}; const detail = event.detail || {};
const redirect = detail.redirect !== false; const redirect = detail.redirect !== false;
this.authService.logout(); this.clearUser();
this.authService = new AuthService();
this.userInfo = undefined;
if (redirect) { if (redirect) {
this.navigate("/log-in"); this.navigate("/log-in");
@ -615,8 +731,7 @@ export class App extends LiteElement {
} }
onNeedLogin() { onNeedLogin() {
this.authService.logout(); this.clearUser();
this.navigate(ROUTES.login); this.navigate(ROUTES.login);
} }
@ -676,10 +791,21 @@ export class App extends LiteElement {
alert.toast(); alert.toast();
} }
getUserInfo() { getUserInfo(): Promise<APIUser> {
return this.apiFetch("/users/me", this.authService.authState!); return this.apiFetch("/users/me", this.authService.authState!);
} }
private clearUser() {
this.authService.logout();
this.authService = new AuthService();
this.userInfo = undefined;
this.selectedTeamId = undefined;
}
private getArchives(): Promise<{ archives: ArchiveData[] }> {
return this.apiFetch("/archives", this.authService.authState!);
}
private showDialog(content: DialogContent) { private showDialog(content: DialogContent) {
this.globalDialogContent = content; this.globalDialogContent = content;
this.globalDialog.show(); this.globalDialog.show();
@ -730,7 +856,7 @@ export class App extends LiteElement {
this.updateUserInfo(); this.updateUserInfo();
this.syncViewState(); this.syncViewState();
} else { } else {
this.authService.logout(); this.clearUser();
this.navigate(ROUTES.login); this.navigate(ROUTES.login);
} }
} }

View File

@ -100,8 +100,12 @@ export class CrawlsList extends LiteElement {
)(crawls) as CrawlSearchResult[]; )(crawls) as CrawlSearchResult[];
} }
protected updated(changedProperties: Map<string, any>) { protected willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("shouldFetch")) { if (
changedProperties.has("shouldFetch") ||
changedProperties.get("crawlsBaseUrl") ||
changedProperties.get("crawlsAPIBaseUrl")
) {
if (this.shouldFetch) { if (this.shouldFetch) {
if (!this.crawlsBaseUrl) { if (!this.crawlsBaseUrl) {
throw new Error("Crawls base URL not defined"); throw new Error("Crawls base URL not defined");

View File

@ -71,29 +71,26 @@ export class Archive extends LiteElement {
@state() @state()
private successfullyInvitedEmail?: string; private successfullyInvitedEmail?: string;
async firstUpdated() { async willUpdate(changedProperties: Map<string, any>) {
if (!this.archiveId) return; if (changedProperties.has("archiveId") && this.archiveId) {
try {
const archive = await this.getArchive(this.archiveId);
try { if (!archive) {
const archive = await this.getArchive(this.archiveId); this.navTo("/archives");
} else {
this.archive = archive;
}
} catch {
this.archive = null;
if (!archive) { this.notify({
this.navTo("/archives"); message: msg("Sorry, couldn't retrieve archive at this time."),
} else { variant: "danger",
this.archive = archive; icon: "exclamation-octagon",
});
} }
} catch {
this.archive = null;
this.notify({
message: msg("Sorry, couldn't retrieve archive at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
} }
}
async updated(changedProperties: any) {
if (changedProperties.has("isAddingMember") && this.isAddingMember) { if (changedProperties.has("isAddingMember") && this.isAddingMember) {
this.successfullyInvitedEmail = undefined; this.successfullyInvitedEmail = undefined;
} }
@ -145,19 +142,6 @@ export class Archive extends LiteElement {
} }
return html`<article> return html`<article>
<header class="w-full max-w-screen-lg mx-auto px-3 box-border py-4">
<nav class="text-sm text-neutral-400">
<a
class="font-medium hover:underline"
href="/archives"
@click="${this.navLink}"
>${msg("Archives")}</a
>
<span class="font-mono">/</span>
<span>${this.archive.name}</span>
</nav>
</header>
<div class="w-full max-w-screen-lg mx-auto px-3 box-border"> <div class="w-full max-w-screen-lg mx-auto px-3 box-border">
<nav class="-ml-3 flex items-end overflow-x-auto"> <nav class="-ml-3 flex items-end overflow-x-auto">
${this.renderNavTab({ tabName: "crawls", label: msg("Crawls") })} ${this.renderNavTab({ tabName: "crawls", label: msg("Crawls") })}
@ -229,7 +213,6 @@ export class Archive extends LiteElement {
return html`<btrix-crawls-list return html`<btrix-crawls-list
.authState=${this.authState!} .authState=${this.authState!}
archiveId=${this.archiveId!}
crawlsBaseUrl=${crawlsBaseUrl} crawlsBaseUrl=${crawlsBaseUrl}
?shouldFetch=${this.archiveTab === "crawls"} ?shouldFetch=${this.archiveTab === "crawls"}
></btrix-crawls-list>`; ></btrix-crawls-list>`;

View File

@ -29,7 +29,7 @@ export class Archives extends LiteElement {
<header <header
class="w-full max-w-screen-lg mx-auto px-3 py-4 box-border md:py-8" class="w-full max-w-screen-lg mx-auto px-3 py-4 box-border md:py-8"
> >
<h1 class="text-xl font-medium">${msg("Archives")}</h1> <h1 class="text-xl font-medium">${msg("Teams")}</h1>
</header> </header>
<hr /> <hr />
</div> </div>

View File

@ -14,24 +14,34 @@ export class Home extends LiteElement {
@property({ type: Object }) @property({ type: Object })
userInfo?: CurrentUser; userInfo?: CurrentUser;
@property({ type: String })
teamId?: string;
@state() @state()
private isInviteComplete?: boolean; private isInviteComplete?: boolean;
@state() @state()
private archiveList?: ArchiveData[]; private archiveList?: ArchiveData[];
async firstUpdated() {
this.archiveList = await this.getArchives();
}
connectedCallback() { connectedCallback() {
if (this.authState) { if (this.authState) {
super.connectedCallback(); super.connectedCallback();
if (this.userInfo && !this.teamId) {
this.fetchArchives();
}
} else { } else {
this.navTo("/log-in"); this.navTo("/log-in");
} }
} }
willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("teamId") && this.teamId) {
this.navTo(`/archives/${this.teamId}/crawls`);
} else if (changedProperties.has("authState") && this.authState) {
this.fetchArchives();
}
}
render() { render() {
if (!this.userInfo || !this.archiveList) { if (!this.userInfo || !this.archiveList) {
return html` return html`
@ -50,7 +60,7 @@ export class Home extends LiteElement {
} }
if (this.userInfo.isAdmin === false) { if (this.userInfo.isAdmin === false) {
title = msg("Archives"); title = msg("Teams");
content = this.renderLoggedInNonAdmin(); content = this.renderLoggedInNonAdmin();
} }
@ -106,9 +116,7 @@ export class Home extends LiteElement {
<div class="grid grid-cols-3 gap-8"> <div class="grid grid-cols-3 gap-8">
<div class="col-span-3 md:col-span-2"> <div class="col-span-3 md:col-span-2">
<section> <section>
<h2 class="text-lg font-medium mb-3 mt-2"> <h2 class="text-lg font-medium mb-3 mt-2">${msg("All Teams")}</h2>
${msg("All Archives")}
</h2>
<btrix-archives-list <btrix-archives-list
.userInfo=${this.userInfo} .userInfo=${this.userInfo}
.archiveList=${this.archiveList} .archiveList=${this.archiveList}
@ -171,6 +179,10 @@ export class Home extends LiteElement {
`; `;
} }
private async fetchArchives() {
this.archiveList = await this.getArchives();
}
private async getArchives(): Promise<ArchiveData[]> { private async getArchives(): Promise<ArchiveData[]> {
const data = await this.apiFetch("/archives", this.authState!); const data = await this.apiFetch("/archives", this.authState!);

View File

@ -118,6 +118,11 @@ const theme = css`
box-shadow: var(--sl-shadow-small); box-shadow: var(--sl-shadow-small);
} }
/* Prevent horizontal scrollbar */
sl-select::part(menu) {
overflow-x: hidden;
}
/* Decrease control spacing on small select */ /* Decrease control spacing on small select */
sl-select[size="small"]::part(control) { sl-select[size="small"]::part(control) {
--sl-input-spacing-small: var(--sl-spacing-x-small); --sl-input-spacing-small: var(--sl-spacing-x-small);

View File

@ -4,4 +4,5 @@ export type CurrentUser = {
name: string; name: string;
isVerified: boolean; isVerified: boolean;
isAdmin: boolean; isAdmin: boolean;
defaultTeamId?: string;
}; };