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">
<h1 class="text-xl font-semibold">${msg("Account settings")}</h1>
<h1 class="text-xl font-semibold">${msg("Account Settings")}</h1>
${successMessage}
<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 class="mb-1 text-gray-500">${msg("Email")}</div>
<div class="inline-flex items-center">

View File

@ -1,7 +1,7 @@
import type { TemplateResult } from "lit";
import { render } from "lit";
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 type { SlDialog } from "@shoelace-style/shoelace";
import "tailwindcss/tailwind.css";
@ -15,6 +15,7 @@ import type { LoggedInEvent } from "./utils/AuthService";
import type { ViewState } from "./utils/APIRouter";
import type { CurrentUser } from "./types/user";
import type { AuthStorageEventData } from "./utils/AuthService";
import type { Archive, ArchiveData } from "./utils/archives";
import theme from "./theme";
import { ROUTES, DASHBOARD_ROUTE } from "./routes";
import "./shoelace";
@ -30,6 +31,14 @@ type DialogContent = {
noHeader?: boolean;
};
type APIUser = {
id: string;
email: string;
name: string;
is_verified: boolean;
is_superuser: boolean;
};
/**
* @event navigate
* @event notify
@ -64,10 +73,19 @@ export class App extends LiteElement {
@state()
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() {
const authState = await AuthService.initSessionStorage();
if (authState) {
this.authService.saveLogin(authState);
this.updateUserInfo();
}
this.syncViewState();
super.connectedCallback();
@ -77,6 +95,19 @@ export class App extends LiteElement {
});
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() {
@ -95,11 +126,7 @@ export class App extends LiteElement {
}
}
async firstUpdated() {
if (this.authService.authState) {
this.updateUserInfo();
}
private async fetchAppSettings() {
const settings = await this.getAppSettings();
if (settings) {
@ -111,22 +138,48 @@ export class App extends LiteElement {
private async updateUserInfo() {
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 = {
id: data.id,
email: data.email,
name: data.name,
isVerified: data.is_verified,
isAdmin: data.is_superuser,
};
const userInfoSuccess = userInfoResp.status === "fulfilled";
let defaultTeamId: CurrentUser["defaultTeamId"];
if (archivesResp.status === "fulfilled") {
const { archives } = archivesResp.value;
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) {
if (err?.message === "Unauthorized") {
console.debug(
"Unauthorized with authState:",
this.authService.authState
);
this.authService.logout();
this.clearUser();
this.navigate(ROUTES.login);
}
}
@ -205,8 +258,12 @@ export class App extends LiteElement {
`;
}
renderNavBar() {
private renderNavBar() {
const isAdmin = this.userInfo?.isAdmin;
let homeHref = "/";
if (!isAdmin && this.selectedTeamId) {
homeHref = `/archives/${this.selectedTeamId}/crawls`;
}
return html`
<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"
>
<div>
<a href="/" @click="${this.navLink}"
<a href="${homeHref}" @click="${this.navLink}"
><h1 class="text-sm hover:text-neutral-400 font-medium">
${msg("Browsertrix Cloud")}
</h1></a
@ -226,12 +283,6 @@ export class App extends LiteElement {
<div
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
class="text-neutral-500 hover:text-neutral-400 font-medium"
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
? html` <sl-dropdown placement="bottom-end">
<sl-icon-button
slot="trigger"
name="person-circle"
style="font-size: 1.5rem;"
></sl-icon-button>
? html` ${this.renderTeams()}
<sl-dropdown placement="bottom-end">
<sl-icon-button
slot="trigger"
name="person-circle"
style="font-size: 1.5rem;"
></sl-icon-button>
<sl-menu class="w-60 min-w-min max-w-full">
<div class="px-7 py-2">
${isAdmin
? html`
<div class="mb-2">
<sl-tag
class="uppercase"
variant="primary"
size="small"
>${msg("admin")}</sl-tag
>
</div>
`
<sl-menu class="w-60 min-w-min max-w-full">
<div class="px-7 py-2">${this.renderMenuUserInfo()}</div>
<sl-divider></sl-divider>
<sl-menu-item
@click=${() => this.navigate(ROUTES.accountSettings)}
>
<sl-icon slot="prefix" name="gear"></sl-icon>
${msg("Account Settings")}
</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>`
: ""}
<div class="font-medium text-neutral-700">
${this.userInfo?.name}
</div>
<div class="text-sm text-neutral-500">
${this.userInfo?.email}
</div>
</div>
<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>`
<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`
<a href="/log-in"> ${msg("Log In")} </a>
${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`
<footer
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) {
case "signUp": {
if (!this.isAppSettingsLoaded) {
@ -426,7 +543,8 @@ export class App extends LiteElement {
@navigate=${this.onNavigateTo}
@logged-in=${this.onLoggedIn}
.authState=${this.authService.authState}
.userInfo="${this.userInfo}"
.userInfo=${this.userInfo}
.teamId=${this.selectedTeamId}
></btrix-home>`;
case "archives":
@ -518,7 +636,7 @@ export class App extends LiteElement {
}
}
renderSpinner() {
private renderSpinner() {
return html`
<div class="w-full flex items-center justify-center text-3xl">
<sl-spinner></sl-spinner>
@ -526,13 +644,13 @@ export class App extends LiteElement {
`;
}
renderNotFoundPage() {
private renderNotFoundPage() {
return html`<btrix-not-found
class="w-full md:bg-neutral-50 flex items-center justify-center"
></btrix-not-found>`;
}
renderFindCrawl() {
private renderFindCrawl() {
return html`
<sl-dropdown
@sl-after-show=${(e: any) => {
@ -585,9 +703,7 @@ export class App extends LiteElement {
const detail = event.detail || {};
const redirect = detail.redirect !== false;
this.authService.logout();
this.authService = new AuthService();
this.userInfo = undefined;
this.clearUser();
if (redirect) {
this.navigate("/log-in");
@ -615,8 +731,7 @@ export class App extends LiteElement {
}
onNeedLogin() {
this.authService.logout();
this.clearUser();
this.navigate(ROUTES.login);
}
@ -676,10 +791,21 @@ export class App extends LiteElement {
alert.toast();
}
getUserInfo() {
getUserInfo(): Promise<APIUser> {
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) {
this.globalDialogContent = content;
this.globalDialog.show();
@ -730,7 +856,7 @@ export class App extends LiteElement {
this.updateUserInfo();
this.syncViewState();
} else {
this.authService.logout();
this.clearUser();
this.navigate(ROUTES.login);
}
}

View File

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

View File

@ -71,29 +71,26 @@ export class Archive extends LiteElement {
@state()
private successfullyInvitedEmail?: string;
async firstUpdated() {
if (!this.archiveId) return;
async willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("archiveId") && this.archiveId) {
try {
const archive = await this.getArchive(this.archiveId);
try {
const archive = await this.getArchive(this.archiveId);
if (!archive) {
this.navTo("/archives");
} else {
this.archive = archive;
}
} catch {
this.archive = null;
if (!archive) {
this.navTo("/archives");
} else {
this.archive = archive;
this.notify({
message: msg("Sorry, couldn't retrieve archive at this time."),
variant: "danger",
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) {
this.successfullyInvitedEmail = undefined;
}
@ -145,19 +142,6 @@ export class Archive extends LiteElement {
}
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">
<nav class="-ml-3 flex items-end overflow-x-auto">
${this.renderNavTab({ tabName: "crawls", label: msg("Crawls") })}
@ -229,7 +213,6 @@ export class Archive extends LiteElement {
return html`<btrix-crawls-list
.authState=${this.authState!}
archiveId=${this.archiveId!}
crawlsBaseUrl=${crawlsBaseUrl}
?shouldFetch=${this.archiveTab === "crawls"}
></btrix-crawls-list>`;

View File

@ -29,7 +29,7 @@ export class Archives extends LiteElement {
<header
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>
<hr />
</div>

View File

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

View File

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

View File

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